summrise-agent 1.2.490 → 1.2.491

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/bin/summrise.js CHANGED
@@ -34,8 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  };
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
- exports.BOOT_TASKS = exports.psq = void 0;
37
+ exports.BOOT_TASKS = exports.DESKTOP_AUMID = exports.psq = void 0;
38
38
  exports.psArgv = psArgv;
39
+ exports.lnkIdentity = lnkIdentity;
39
40
  exports.deskShortcutRepairPs = deskShortcutRepairPs;
40
41
  exports.startDesktopPs = startDesktopPs;
41
42
  exports.desktopTaskPs = desktopTaskPs;
@@ -298,13 +299,135 @@ function psFile(script) {
298
299
  // exported: unit-tested in test/cli.test.mjs (SYSTEM-context PS quoting = injection surface)
299
300
  const psq = (x) => String(x).replace(/'/g, "''");
300
301
  exports.psq = psq;
302
+ /** The taskbar identity: ONE string, and it lives on BOTH sides of an association.
303
+ *
304
+ * It is the AppUserModelID the desktop shell sets on its process
305
+ * (`agent/summrise-desktop-electron/src/main.ts`) and the value written into
306
+ * `System.AppUserModel.ID` on the shortcut below. Microsoft's lookup for the taskbar
307
+ * group's icon takes the shortcut arm only when the window carries an explicit ID, and
308
+ * the window's arm only names a shortcut carrying the SAME ID — so the two halves are one
309
+ * association and this is the string that ties them. If they ever disagree, the icon
310
+ * silently reverts to the backing executable (stock `electron.exe`, since the shell runs
311
+ * unpackaged) with nothing on the device to read that says why.
312
+ * PowerShell/Windows AppUserModelIDs cannot contain spaces, and this one does not.
313
+ */
314
+ exports.DESKTOP_AUMID = "online.saisi.summrise.agent";
315
+ /** The two `IPropertyStore` values `Summrise.lnk` must carry, as DATA.
316
+ *
317
+ * Deliberately separated from the PowerShell that performs the write, and exported, so the
318
+ * DECISION is testable where the COM call is not: a test can pin what the CLI intends to
319
+ * write without a Windows box. `icoPath` is the shell's own sunrise `.ico`; the `,0` is the
320
+ * icon index and is REQUIRED — the property's own documentation: "because .ico files can
321
+ * contain multiple icon resources, a resource ID is required in the string. If the .ico
322
+ * file is a single image, use '0'".
323
+ *
324
+ * `IconLocation` on the shortcut is a DIFFERENT thing and is left alone: it is what
325
+ * Explorer draws for the .lnk FILE. These two are what the taskbar GROUP uses.
326
+ */
327
+ function lnkIdentity(icoPath) {
328
+ return { id: exports.DESKTOP_AUMID, icon: `${icoPath},0` };
329
+ }
330
+ /** C# interop for the two shortcut properties `WScript.Shell` cannot set.
331
+ *
332
+ * WHY THIS MECHANISM, given this repository prefers Rust for logic: `System.AppUserModel.ID`
333
+ * and `System.AppUserModel.RelaunchIconResource` are not fields of the .lnk header — they
334
+ * live in the shortcut's `IPropertyStore`, and `WScript.Shell` exposes no way to reach one.
335
+ * The route Microsoft documents for a shortcut is `IShellLink` + `QueryInterface` for
336
+ * `IPropertyStore` ("A shortcut (as an IShellLink, CLSID_ShellLink, or a .lnk file) supports
337
+ * properties through IPropertyStore", shell/appids.md) — COM interop, in the launcher's own
338
+ * script generator, which is already PowerShell and already writing this shortcut.
339
+ * `Add-Type` compiles it with the C# compiler that ships IN the box: nothing installed,
340
+ * nothing downloaded, and it runs on a stock Windows box with no Node, no Rust and no SDK.
341
+ * A Rust helper is the repository's usual preference and was rejected HERE on cost, not on
342
+ * taste: it needs a new cross-compiled binary in the npm package, and this package "carries
343
+ * NO boxed components" by design — `setup` fetches and hash-pins them — so ~60 lines of
344
+ * interop would buy a build stage, a release-host component and a new pin.
345
+ *
346
+ * TWO THINGS THE DEVICE TAUGHT, both load-bearing:
347
+ * - `IPersistFile::Load` must be given STGM_READWRITE (2). Loading with 0 opens the link
348
+ * READ-ONLY and the later `IPropertyStore::Commit` fails STG_E_ACCESSDENIED (0x80030005)
349
+ * — measured on desktop-14rjcr8, where the first version of this passed 0 and wrote
350
+ * nothing while reporting no error the caller could see.
351
+ * - the write is verified by READING THE PROPERTIES BACK with `Read`, which is also what
352
+ * decides whether the shortcut needs repairing at all.
353
+ */
354
+ const LNK_PROPS_CS = `using System;
355
+ using System.Runtime.InteropServices;
356
+ public static class SummriseLnk {
357
+ [StructLayout(LayoutKind.Sequential, Pack = 4)]
358
+ struct PROPERTYKEY { public Guid fmtid; public uint pid; }
359
+ [StructLayout(LayoutKind.Sequential)]
360
+ struct PROPVARIANT { public ushort vt, r1, r2, r3; public IntPtr p, p2; }
361
+ [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
362
+ interface IPropertyStore {
363
+ int GetCount(out uint c);
364
+ int GetAt(uint i, out PROPERTYKEY k);
365
+ int GetValue(ref PROPERTYKEY k, out PROPVARIANT v);
366
+ int SetValue(ref PROPERTYKEY k, ref PROPVARIANT v);
367
+ int Commit();
368
+ }
369
+ [ComImport, Guid("0000010b-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
370
+ interface IPersistFile {
371
+ void GetClassID(out Guid g);
372
+ [PreserveSig] int IsDirty();
373
+ void Load([MarshalAs(UnmanagedType.LPWStr)] string f, uint m);
374
+ void Save([MarshalAs(UnmanagedType.LPWStr)] string f, [MarshalAs(UnmanagedType.Bool)] bool r);
375
+ void SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string f);
376
+ void GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string f);
377
+ }
378
+ static readonly Guid CLSID = new Guid("00021401-0000-0000-C000-000000000046");
379
+ static readonly Guid FMT = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3");
380
+ const uint RW = 2;
381
+ static PROPERTYKEY K(uint pid) { PROPERTYKEY k; k.fmtid = FMT; k.pid = pid; return k; }
382
+ static object New(string lnk, uint mode) {
383
+ object o = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID));
384
+ ((IPersistFile)o).Load(lnk, mode);
385
+ return o;
386
+ }
387
+ static string Get(string lnk, uint pid, uint mode) {
388
+ IPropertyStore s = (IPropertyStore)New(lnk, mode);
389
+ PROPERTYKEY k = K(pid); PROPVARIANT v;
390
+ if (s.GetValue(ref k, out v) != 0) return "";
391
+ if (v.vt != 31 || v.p == IntPtr.Zero) return "";
392
+ string r = Marshal.PtrToStringUni(v.p);
393
+ Marshal.FreeCoTaskMem(v.p);
394
+ return r;
395
+ }
396
+ static void Put(IPropertyStore s, uint pid, string val) {
397
+ PROPERTYKEY k = K(pid); PROPVARIANT v = new PROPVARIANT();
398
+ v.vt = 31; v.p = Marshal.StringToCoTaskMemUni(val);
399
+ try { int hr = s.SetValue(ref k, ref v); if (hr != 0) Marshal.ThrowExceptionForHR(hr); }
400
+ finally { Marshal.FreeCoTaskMem(v.p); }
401
+ }
402
+ public static string Read(string lnk) { return Get(lnk, 5, 0) + "|" + Get(lnk, 3, 0); }
403
+ public static void Write(string lnk, string id, string icon) {
404
+ object o = New(lnk, RW);
405
+ IPropertyStore s = (IPropertyStore)o;
406
+ Put(s, 5, id); Put(s, 3, icon);
407
+ int hr = s.Commit();
408
+ if (hr != 0) Marshal.ThrowExceptionForHR(hr);
409
+ ((IPersistFile)o).Save(lnk, true);
410
+ }
411
+ }`;
301
412
  // stage-brand: healed desktop shortcut. A 2026-09-01 Summrise.lnk on devices
302
413
  // points at the RETIRED Tauri summrise-desktop.exe (embedded stale icon) —
303
414
  // double-clicking it launches the dead app instead of the Electron shell.
304
415
  // Repair (only if the link already exists — headless installs must not
305
416
  // sprout desktop icons): repoint at start-desktop.ps1 (the SummriseDesktop
306
- // onlogon path) with IconLocation pinned to the sunrise icon.ico, and
307
- // remove the retired Tauri/tray orphans nothing ships anymore.
417
+ // onlogon path) with IconLocation pinned to the sunrise icon.ico, remove the
418
+ // retired Tauri/tray orphans nothing ships anymore, AND write the taskbar
419
+ // identity (System.AppUserModel.ID + RelaunchIconResource) into the link's
420
+ // property store.
421
+ //
422
+ // WHY THE IDENTITY IS PART OF *THIS* FUNCTION: a shortcut with the right target
423
+ // and the wrong (or absent) identity is exactly the state that draws the
424
+ // Electron logo — the shell sets the same AppUserModelID on its process, and the
425
+ // taskbar's icon lookup reaches the shortcut arm only when a shortcut carrying
426
+ // that ID can be found. MEASURED on desktop-14rjcr8 before this change: both
427
+ // Summrise.lnk files existed and carried NEITHER property, which is why an
428
+ // explicit ID on the window alone changed nothing and why deleting that ID
429
+ // (d4edbb64) changed nothing either.
430
+ //
308
431
  // Best-effort + logged, never fatal. `sink` is a PS output pipe
309
432
  // (e.g. Write-Host, or the update log pipe). Single-quoted PS literals
310
433
  // only (npm audit #6) except the double quotes the .lnk Arguments path
@@ -313,18 +436,37 @@ exports.psq = psq;
313
436
  // script to `ps()` as argv and the update swap runs it from a file. The
314
437
  // backslash-escape `setup` used to apply was deleted with that conversion
315
438
  // (see step 7), which is why this line no longer says "escape them".
439
+ // The C# rides in a single-quoted HERE-STRING (`@'` … `'@`, both alone on
440
+ // their line): it contains double quotes and is literal end to end, so the
441
+ // doubling rule above cannot be defeated by anything inside it.
316
442
  // exported: unit-tested in test/cli.test.mjs.
317
443
  function deskShortcutRepairPs(scriptsQ, deskDirQ, sink) {
444
+ const { id, icon } = lnkIdentity(`${deskDirQ}\\icon.ico`);
318
445
  return [
319
446
  `$dLnk = Join-Path $env:PUBLIC 'Desktop\\Summrise.lnk'`,
320
447
  `$dIco = '${deskDirQ}\\icon.ico'`,
321
448
  `$dPs1 = '${scriptsQ}\\start-desktop.ps1'`,
449
+ `$dId = '${id}'`,
450
+ `$dRes = '${icon}'`,
451
+ `$dSet = $false`,
322
452
  `$dNeed = $false`,
323
453
  `if (Test-Path $dLnk) {`,
454
+ // Compiled only when there IS a link to write, so a headless install does not pay for
455
+ // it. The `-as [type]` guard keeps a second call in one process from turning a
456
+ // "type already exists" throw into a silent skip of the write below.
457
+ ` $dCs = @'\n${LNK_PROPS_CS}\n'@`,
458
+ ` try { if (-not ('SummriseLnk' -as [type])) { Add-Type -TypeDefinition $dCs }; $dSet = [bool]('SummriseLnk' -as [type]) } catch { ('desk: lnk identity helper unavailable: ' + $_.Exception.Message) | ${sink} }`,
324
459
  ` try { $dEx = (New-Object -ComObject WScript.Shell).CreateShortcut($dLnk); if (($dEx.TargetPath -like '*summrise-desktop.exe') -or ($dEx.TargetPath -like '*summrise-tray.exe') -or (-not (Test-Path $dEx.TargetPath))) { $dNeed = $true } } catch { $dNeed = $true }`,
460
+ // ...and a link that is otherwise healthy still needs repairing when its identity is
461
+ // missing or names a different ID/icon. `Read` returns "id|icon", so ONE comparison
462
+ // covers both properties — and it is the same call the verification below prints.
463
+ ` if ($dSet) { try { if (([SummriseLnk]::Read($dLnk)) -ne ($dId + '|' + $dRes)) { $dNeed = $true } } catch { $dNeed = $true } }`,
325
464
  `}`,
326
465
  `if ($dNeed -and (Test-Path $dIco) -and (Test-Path $dPs1)) {`,
327
466
  ` try { $dWs = New-Object -ComObject WScript.Shell; $dSc = $dWs.CreateShortcut($dLnk); $dSc.TargetPath = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'; $dSc.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "' + $dPs1 + '"'; $dSc.WorkingDirectory = '${deskDirQ}'; $dSc.IconLocation = $dIco + ',0'; $dSc.Save(); 'desk: Summrise.lnk repointed to electron shell' | ${sink} } catch { ('desk: Summrise.lnk repair failed: ' + $_.Exception.Message) | ${sink} }`,
467
+ // The verification is the READ-BACK, not the absence of an exception: SetValue+Commit
468
+ // returning cleanly is a claim about a call, and this records what the file now carries.
469
+ ` if ($dSet) { try { [SummriseLnk]::Write($dLnk, $dId, $dRes); ('desk: Summrise.lnk taskbar identity [' + [SummriseLnk]::Read($dLnk) + ']') | ${sink} } catch { ('desk: Summrise.lnk identity write failed: ' + $_.Exception.Message) | ${sink} } }`,
328
470
  `}`,
329
471
  `foreach ($dRx in @('summrise-desktop.exe','summrise-tray.exe')) { $dRp = '${deskDirQ}\\' + $dRx; if (Test-Path $dRp) { try { Remove-Item -Force -ErrorAction Stop $dRp; ('desk: removed retired ' + $dRx) | ${sink} } catch { ('desk: retired ' + $dRx + ' locked, kept') | ${sink} } } }`,
330
472
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "summrise-agent",
3
- "version": "1.2.490",
3
+ "version": "1.2.491",
4
4
  "description": "Summrise Agent — Windows device MCP server (terminal/SSH/serial) + panel. Self-hosted npm distribution.",
5
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 写法从未真正跑通过)。先编 electron TS(--noCheck 纯 emit,与 release.yml 一致)同步两处 src/*.js,再编 src/summrise.ts 到 bin/summrise.js 并校验 round-298 标记(弱 oracle,只证某次构建发生,新鲜性由 publish-release.sh 的 tsc 重编 + cmp 门保证)。",
6
6
  "bin": {
Binary file
@@ -47,6 +47,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
47
47
  // No spawn() of summrise-agent.exe from JS — that was the d1 Chrome-OOM root
48
48
  // cause (a bind-failed child wedged on "Press Enter to exit" forever).
49
49
  const electron_1 = require("electron");
50
+ // IPC audit #6: execSync/ChildProcess were vestigial imports from the
51
+ // pre-async-spawn era (round-117 removed the call sites) — dropped so
52
+ // eslint/tsc keep the surface honest. The last one to go was the empty
53
+ // `import type {} from "child_process"` that stood here: it imported
54
+ // NOTHING (the schtasks runner reaches the module through require() with its
55
+ // own `as typeof import(...)` type), and a type-only import of a module the
56
+ // shell does not otherwise import is a "dependency" no reader can act on.
50
57
  const path = __importStar(require("path"));
51
58
  const fs = __importStar(require("fs"));
52
59
  const http = __importStar(require("http"));
@@ -319,13 +326,7 @@ function buildMenu() {
319
326
  { label: "New Serial Connection…", accelerator: "CmdOrCtrl+Shift+P", click: () => sendMenu("new-serial") },
320
327
  { label: "New Browser Session…", accelerator: "CmdOrCtrl+Shift+B", click: () => sendMenu("new-browser") },
321
328
  { type: "separator" },
322
- { label: "Browser", accelerator: "CmdOrCtrl+Shift+G", click: () => sendMenu("open-browser") },
323
- { label: "Memory", accelerator: "CmdOrCtrl+Shift+M", click: () => sendMenu("open-memory") },
324
- { label: "Settings", accelerator: "CmdOrCtrl+Shift+,", click: () => sendMenu("open-settings") },
325
- { label: "Plugins", click: () => sendMenu("open-plugins") },
326
- { type: "separator" },
327
329
  { label: "Close Session", accelerator: "CmdOrCtrl+W", click: () => sendMenu("close-session") },
328
- { label: "Close Window", accelerator: isMac ? "Cmd+Shift+W" : "Alt+F4", role: "close" },
329
330
  { type: "separator" },
330
331
  isMac ? { role: "close" } : { role: "quit", label: "Exit" },
331
332
  ],
@@ -353,8 +354,6 @@ function buildMenu() {
353
354
  { role: "resetZoom" },
354
355
  { role: "zoomIn" },
355
356
  { role: "zoomOut" },
356
- { type: "separator" },
357
- { label: "Toggle Theme", accelerator: "CmdOrCtrl+Shift+D", click: () => sendMenu("toggle-theme") },
358
357
  { role: "toggleDevTools" },
359
358
  ],
360
359
  },
@@ -363,28 +362,13 @@ function buildMenu() {
363
362
  submenu: [
364
363
  { label: "Next Session", accelerator: "Ctrl+Tab", click: () => sendMenu("next-session") },
365
364
  { label: "Previous Session", accelerator: "Ctrl+Shift+Tab", click: () => sendMenu("prev-session") },
366
- { type: "separator" },
367
- { label: "Export Session Log…", accelerator: "CmdOrCtrl+Shift+E", click: () => sendMenu("export-session") },
368
- { type: "separator" },
369
365
  // IPC audit #6: the "List Sessions" entry dispatched 'list-sessions',
370
366
  // a command with NO SPA-side handler — deleted (dead menu surface).
371
- ],
372
- },
373
- {
374
- label: "Window",
375
- submenu: [
376
- { role: "minimize" },
377
- { role: "zoom" },
378
- ...(isMac ? [
379
- { type: "separator" },
380
- { role: "front" },
381
- ] : []),
382
- ],
383
- },
384
- {
385
- role: "help",
386
- submenu: [
387
- { label: "Summrise Agent Status", click: () => { focusMain(); sendMenu("show-status"); } },
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.
388
372
  ],
389
373
  },
390
374
  ];
@@ -671,7 +655,9 @@ function embeddedRecover() {
671
655
  return { ok: false };
672
656
  // Already decided by loadTarget() when embeddedUrl was set (the ONE load
673
657
  // door) — recovery re-loads that decision, it does not re-open the policy.
674
- const url = embeddedUrl && embeddedUrl !== "about:blank" ? embeddedUrl : "https://www.bing.com";
658
+ // 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";
675
661
  view.webContents.loadURL(url).catch(() => { });
676
662
  // Re-show if the SPA slot is live (bounds were placed before the crash).
677
663
  if (embeddedVisible && win && embeddedBounds) {
@@ -906,12 +892,31 @@ const httpServer = http.createServer((req, res) => {
906
892
  }
907
893
  });
908
894
  if (gotTheLock) {
909
- // Taskbar grouping/identity on Windows (taskbar button + jump list group
910
- // under Summrise instead of Electron). Must be set before ready.
895
+ // TASKBAR GROUPING AND THE TASKBAR GROUP'S ICON — HALF ONE OF TWO, AND THIS IS THE HALF
896
+ // THAT IS A PRECONDITION RATHER THAN A HINT.
897
+ // Microsoft's rule for System.AppUserModel.RelaunchIconResource is a three-step lookup:
898
+ // the property ON THE WINDOW; else "the system attempts to find a shortcut with the same
899
+ // AppUserModelID, and pins that shortcut to the taskbar to represent the window"; else
900
+ // "the backing executable of the process that owns it is used". Which step is reachable
901
+ // at all depends on THIS call — the same page: the shortcut lookup happens only "If an
902
+ // explicit AppUserModelID is set on the window", and without one the property "is ignored
903
+ // and the window is grouped and pinned as if it were part of its owning process".
904
+ // This shell is launched UNPACKAGED (`electron.exe .` out of the staged runtime), so
905
+ // step three is stock electron.exe — the Electron logo. Removing this call (d4edbb64)
906
+ // could not fix that: it deleted the caller's half of an association whose OTHER half is
907
+ // the shortcut, which left step three as the only reachable step in BOTH states. The
908
+ // shortcut half is written in agent/summrise-agent-npm/src/summrise.ts (see DESKTOP_AUMID
909
+ // there) as System.AppUserModel.ID — the same string — plus RelaunchIconResource pointing
910
+ // at the sunrise .ico. KEEP THE TWO IN STEP; the literal below is a copy because this file
911
+ // is emitted to plain JS in two packages and cannot import the CLI's module.
912
+ // (The same shortcut association is also the documented precondition for the hide-to-tray
913
+ // toast below: Electron requires a Start Menu shortcut carrying the ID and a
914
+ // ToastActivatorCLSID, and this one carries neither — unchanged by this call.)
915
+ const AUMID = "online.saisi.summrise.agent";
911
916
  try {
912
917
  if (process.platform === "win32")
913
- electron_1.app.setAppUserModelId("online.saisi.summrise.agent");
914
- iconReport["appUserModelId"] = process.platform === "win32" ? "online.saisi.summrise.agent" : "(non-windows)";
918
+ electron_1.app.setAppUserModelId(AUMID);
919
+ iconReport["appUserModelId"] = process.platform === "win32" ? AUMID : "(non-windows)";
915
920
  }
916
921
  catch {
917
922
  iconReport["appUserModelId"] = "(set-failed)";
@@ -934,6 +939,16 @@ if (gotTheLock) {
934
939
  electron_1.Menu.setApplicationMenu(buildMenu());
935
940
  win = new electron_1.BrowserWindow({
936
941
  width: 1200, height: 800, title: "Summrise",
942
+ // NO WHITE FLASH (window-creation honesty, round-275). The window used to be
943
+ // created VISIBLE, before loadDesktop() runs its ~800 ms /api/status probe and
944
+ // before the first loadURL — so every launch painted a blank white frame and then
945
+ // whatever the agent turned out to be. show:false keeps it off screen until the
946
+ // first document has painted (ready-to-show, below); backgroundColor paints that
947
+ // first frame in the shell's own ink instead of Chromium's white, which is the one
948
+ // of the two documents that had NOT already fixed this (WAIT_HTML hardcodes
949
+ // background:#111, and the SPA's chrome is dark).
950
+ show: false,
951
+ backgroundColor: "#111",
937
952
  // Taskbar + title-bar icon: without this Windows shows the stock
938
953
  // electron.exe icon (the running binary is stock Electron). PNG —
939
954
  // see windowIcon() on why not .ico here.
@@ -955,6 +970,11 @@ if (gotTheLock) {
955
970
  backgroundThrottling: false,
956
971
  },
957
972
  });
973
+ // The other half of show:false — the first paint shows the window. It fires on the
974
+ // first document to paint (the SPA when the agent answers, the wait page when it does
975
+ // not), and the handler is registered in the same tick as the constructor, before any
976
+ // loadURL can happen, so there is no window in which the event is missed.
977
+ win.once("ready-to-show", () => { win?.show(); });
958
978
  // review #6 (MED) TOP RISK: the main window carries the summriseDesktop/
959
979
  // summriseBrowser preload bridge (setAutoLaunch → schtasks /create …
960
980
  // -File <ps1>, browser-session:open). Without a navigation veto a
@@ -1048,25 +1068,15 @@ if (gotTheLock) {
1048
1068
  let retryMs = 2000;
1049
1069
  const nextRetry = () => { retryMs = Math.min(retryMs * 2, 30000); return retryMs; };
1050
1070
  const resetRetry = () => { retryMs = 2000; };
1051
- /** Window title carries the agent version for at-a-glance diagnosis:
1052
- * "Summrise — v1.2.x" (the npm RELEASE version — /api/status `release`,
1053
- * written by the update/setup swap paths; falls back to the Cargo
1054
- * protocol `version` when the marker is absent). "Summrise" when down. */
1055
- const setVersionTitle = async () => {
1056
- try {
1057
- const ctrl = new AbortController();
1058
- const t = setTimeout(() => ctrl.abort(), 3000);
1059
- const r = await fetch(`${(0, url_policy_1.agentBase)()}/api/status`, { signal: ctrl.signal, headers: authHeaders() });
1060
- clearTimeout(t);
1061
- if (r.ok) {
1062
- const j = await r.json();
1063
- const v = j.release || j.version;
1064
- if (v)
1065
- win?.setTitle(`Summrise — v${v}`);
1066
- }
1067
- }
1068
- catch { /* agent down — keep default title */ }
1069
- };
1071
+ // NO setVersionTitle() HERE, DELETED. It fetched /api/status with the device token and
1072
+ // called win.setTitle("Summrise — v1.2.x"), and that title NEVER REACHED THE SCREEN: the
1073
+ // SPA owns document.title (`useAttentionTitle` assigns it on every attention change,
1074
+ // panel-react/src/hooks/useAttention.ts:96 — plain "Summrise Agent" in the desktop window)
1075
+ // and no page-title-updated handler on THIS window stops it, so the renderer's title
1076
+ // replaced the native one on the first render. The version it wanted to show is already on
1077
+ // screen, in the panel's own status bar (DesktopShell's .desktop-status) and in the tray
1078
+ // tooltip, both of which read the same /api/status. A native title that only the process
1079
+ // could read was also the LAST credentialed fetch on this path — the tray keeps its own.
1070
1080
  // stage-n: SELF-HEAL watchdog. The wait-page button needed a human, but
1071
1081
  // when the AGENT is down the cloudflared tunnel (agent-supervised) dies
1072
1082
  // too — a remote operator then has NO channel left to click anything
@@ -1085,7 +1095,6 @@ if (gotTheLock) {
1085
1095
  if (await agentReady()) {
1086
1096
  agentMissCount = 0;
1087
1097
  resetRetry();
1088
- setVersionTitle();
1089
1098
  win?.loadURL(`${(0, url_policy_1.agentBase)()}/desktop/`).catch(() => { });
1090
1099
  }
1091
1100
  else {
@@ -1125,11 +1134,13 @@ if (gotTheLock) {
1125
1134
  // without it, playwright grabbed the MAIN window (now tripwired) or the
1126
1135
  // user had to open the Browser page first. The view is hidden until the
1127
1136
  // SPA's Browser page reports bounds (embeddedViewPlace shows it).
1137
+ // NO EAGER NAVIGATION: this used to loadURL("https://www.bing.com"), i.e. a
1138
+ // request to a third party on EVERY launch before anyone asked for a page.
1139
+ // The view is still created — about:blank is a first-class CDP target (the
1140
+ // reason it exists here is that a target be available, not that a page be
1141
+ // loaded), and the user/AI navigates it next (loadTarget is the one door).
1128
1142
  try {
1129
- const v = embeddedViewEnsure();
1130
- if (v && !v.webContents.isDestroyed()) {
1131
- v.webContents.loadURL("https://www.bing.com").catch(() => { });
1132
- }
1143
+ embeddedViewEnsure();
1133
1144
  }
1134
1145
  catch { /* non-fatal */ }
1135
1146
  console.log(`[summrise] CDP endpoint: http://127.0.0.1:${CDP_PORT} (playwright connectOverCDP)`);
@@ -1195,6 +1206,9 @@ if (gotTheLock) {
1195
1206
  // status strip shows the same pair).
1196
1207
  let trayAgentCpu = null;
1197
1208
  let trayAgentMem = null;
1209
+ /** WHEN /api/status last ANSWERED (null = it never has since launch) — the tray's
1210
+ * tooltip reports this instead of asserting a state it cannot date. */
1211
+ let trayLastAnsweredAt = null;
1198
1212
  /** "3m 24s" / "1h 5m" / "2d 3h" — compact uptime for the tray. */
1199
1213
  const fmtUptime = (secs) => {
1200
1214
  if (secs < 60)
@@ -1205,10 +1219,16 @@ if (gotTheLock) {
1205
1219
  return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
1206
1220
  return `${Math.floor(secs / 86400)}d ${Math.floor((secs % 86400) / 3600)}h`;
1207
1221
  };
1222
+ /** "14:02:31" local — the WHEN of an observation, for the one surface that cannot
1223
+ * re-derive it (a tray tooltip is a string set once per poll, not a render). */
1224
+ const fmtClock = (at) => new Date(at).toTimeString().slice(0, 8);
1208
1225
  const refreshTray = async () => {
1209
1226
  // HTTP liveness, not the TCP probe (see agentReady above): a wedged-
1210
1227
  // but-listening agent must show as STOPPED, not "running".
1211
1228
  const running = await agentResponds(1000);
1229
+ const observedAt = Date.now();
1230
+ if (running)
1231
+ trayLastAnsweredAt = observedAt;
1212
1232
  let version = trayAgentVersion;
1213
1233
  let uptime = trayAgentUptime;
1214
1234
  let sessions = trayAgentSessions;
@@ -1248,9 +1268,19 @@ if (gotTheLock) {
1248
1268
  const vitals = running && mem !== null
1249
1269
  ? `, CPU ${cpu === null ? "?" : Math.round(cpu)}% · MEM ${Math.round(mem)}%`
1250
1270
  : "";
1271
+ // THE TOOLTIP NAMES THE OBSERVATION, NOT A VERDICT (round-275). It used to print
1272
+ // "Agent stopped" as present tense from ONE un-timestamped probe, recomputed every
1273
+ // 30 s — half a minute stale while reading as now, the shape the panel's liveness
1274
+ // strings were already fixed for (`checked 12s ago`, `reachability NOT VERIFIED`).
1275
+ // The WHEN is a CLOCK rather than "answered 12s ago", deliberately: a tooltip is a
1276
+ // string written once per poll and Electron gives it no way to recompute, so a
1277
+ // relative age would freeze at the value it had when written and read as "now" for
1278
+ // the next 30 s — the very failure this line removes. A timestamp does not decay.
1251
1279
  const health = running
1252
- ? `Agent running (v${version || "?"}${uptime ? `, up ${uptime}` : ""}${sessions ? `, ${sessions} session${sessions === 1 ? "" : "s"}` : ""}${vitals})`
1253
- : "Agent stopped";
1280
+ ? `answered ${fmtClock(observedAt)} · v${version || "?"}${uptime ? `, up ${uptime}` : ""}${sessions ? `, ${sessions} session${sessions === 1 ? "" : "s"}` : ""}${vitals}`
1281
+ : trayLastAnsweredAt === null
1282
+ ? "reachability NOT VERIFIED · no reply since launch"
1283
+ : `not answering · last reply ${fmtClock(trayLastAnsweredAt)}`;
1254
1284
  // stage-n: the agent died WHILE the window was showing the SPA (the
1255
1285
  // wait page only renders when the boot probe fails). Swap the window
1256
1286
  // to the wait page — its "Start Agent" button is now actually visible
@@ -1266,16 +1296,15 @@ if (gotTheLock) {
1266
1296
  if (running && agentWatchActive)
1267
1297
  agentWatchActive = false;
1268
1298
  tray.setToolTip(`Summrise — ${health}`);
1299
+ // THE FOUR "New …" ROWS ARE GONE (round-275): they duplicated the desktop
1300
+ // header's `+ New` menu item for item (pty/ssh/serial/browser), one click
1301
+ // cheaper than the tray path, which had to restore and focus the window
1302
+ // first anyway. "Open" is the tray's job; creating sessions is the app's.
1269
1303
  tray.setContextMenu(electron_1.Menu.buildFromTemplate([
1270
1304
  { label: "Open", click: () => { focusMain(); } },
1271
1305
  { type: "separator" },
1272
1306
  { label: health, enabled: false },
1273
1307
  { type: "separator" },
1274
- { label: "New Terminal", click: () => { focusMain(); sendMenu("new-pty"); } },
1275
- { label: "New SSH…", click: () => { focusMain(); sendMenu("new-ssh"); } },
1276
- { label: "New Serial…", click: () => { focusMain(); sendMenu("new-serial"); } },
1277
- { label: "New Browser…", click: () => { focusMain(); sendMenu("new-browser"); } },
1278
- { type: "separator" },
1279
1308
  { label: "Quit", click: () => { electron_1.app.isQuitting = true; electron_1.app.quit(); } },
1280
1309
  ]));
1281
1310
  };