summrise-agent 1.2.490 → 1.2.492

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,169 @@ 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
+ * WHY IT IS NOT `online.saisi.summrise.agent`, WHICH IS WHAT IT WAS CALLED BEFORE —
315
+ * MEASURED on desktop-14rjcr8 (release 1.2.490), ONE VARIABLE AT A TIME:
316
+ *
317
+ * | window's AppUserModelID | shortcut carries it | taskbar draws |
318
+ * | window's AppUserModelID | shortcuts carrying it | taskbar draws |
319
+ * | online.saisi.summrise.agent | both desktops + Start Menu | Electron logo |
320
+ * | online.saisi.summrise.desktop | both desktops + Start Menu | THE SUNRISE |
321
+ * | online.saisi.summrise.desktop | both desktops, Start Menu GONE | Electron logo |
322
+ *
323
+ * That third row is the one that decides where the identity has to live, and it was taken
324
+ * WITHOUT restarting anything: the same running shell, the same AppUserModelID already set,
325
+ * and the icon fell back to the Electron logo the moment the Start Menu shortcut was
326
+ * deleted. So the shortcut this lookup resolves is the **Start Menu** one — a desktop
327
+ * shortcut is not enough, and writing the identity onto `Summrise.lnk` in
328
+ * `%PUBLIC%\Desktop` alone changes nothing. Microsoft's appids.md does say to apply the ID
329
+ * to "all of the shortcuts" when an application has several launch locations, and it lists
330
+ * the Start menu FIRST; the measurement is what says which of them the taskbar actually
331
+ * reads.
332
+ *
333
+ * The other variables were held: the same `IPropertyStore` call read back on every
334
+ * shortcut, the same `icon.ico` (downloaded and RENDERED — it is the orange sunrise), the
335
+ * same shell restarted under the `SummriseDesktop` task, and in each restarted run the
336
+ * running process appended `AUMID-SET-OK` to a log from inside `setAppUserModelId`, so "the
337
+ * ID was set" is measured rather than assumed.
338
+ *
339
+ * WHY THE OLD ID WAS DEAD. Releases up to 1.2.489 set `online.saisi.summrise.agent` on the
340
+ * process while no shortcut anywhere carried it, so the shell resolved that ID against the
341
+ * backing executable — stock `electron.exe` — and kept the result. `shell:AppsFolder` still
342
+ * lists it as `name=[Electron] path=[online.saisi.summrise.agent]`, an app entry named after
343
+ * `electron.exe`. An ID already resolved to the wrong image is not repaired by writing the
344
+ * shortcut afterwards; the association has to be under a name the machine has never seen.
345
+ * Keep this string STABLE from here: changing it again costs the pin and the jump list, so
346
+ * this is a one-time migration, not a knob.
347
+ */
348
+ exports.DESKTOP_AUMID = "online.saisi.summrise.desktop";
349
+ /** The two `IPropertyStore` values `Summrise.lnk` must carry, as DATA.
350
+ *
351
+ * Deliberately separated from the PowerShell that performs the write, and exported, so the
352
+ * DECISION is testable where the COM call is not: a test can pin what the CLI intends to
353
+ * write without a Windows box. `icoPath` is the shell's own sunrise `.ico`; the `,0` is the
354
+ * icon index and is REQUIRED — the property's own documentation: "because .ico files can
355
+ * contain multiple icon resources, a resource ID is required in the string. If the .ico
356
+ * file is a single image, use '0'".
357
+ *
358
+ * `IconLocation` on the shortcut is a DIFFERENT thing and is left alone: it is what
359
+ * Explorer draws for the .lnk FILE. These two are what the taskbar GROUP uses.
360
+ */
361
+ function lnkIdentity(icoPath) {
362
+ return { id: exports.DESKTOP_AUMID, icon: `${icoPath},0` };
363
+ }
364
+ /** C# interop for the two shortcut properties `WScript.Shell` cannot set.
365
+ *
366
+ * WHY THIS MECHANISM, given this repository prefers Rust for logic: `System.AppUserModel.ID`
367
+ * and `System.AppUserModel.RelaunchIconResource` are not fields of the .lnk header — they
368
+ * live in the shortcut's `IPropertyStore`, and `WScript.Shell` exposes no way to reach one.
369
+ * The route Microsoft documents for a shortcut is `IShellLink` + `QueryInterface` for
370
+ * `IPropertyStore` ("A shortcut (as an IShellLink, CLSID_ShellLink, or a .lnk file) supports
371
+ * properties through IPropertyStore", shell/appids.md) — COM interop, in the launcher's own
372
+ * script generator, which is already PowerShell and already writing this shortcut.
373
+ * `Add-Type` compiles it with the C# compiler that ships IN the box: nothing installed,
374
+ * nothing downloaded, and it runs on a stock Windows box with no Node, no Rust and no SDK.
375
+ * A Rust helper is the repository's usual preference and was rejected HERE on cost, not on
376
+ * taste: it needs a new cross-compiled binary in the npm package, and this package "carries
377
+ * NO boxed components" by design — `setup` fetches and hash-pins them — so ~60 lines of
378
+ * interop would buy a build stage, a release-host component and a new pin.
379
+ *
380
+ * TWO THINGS THE DEVICE TAUGHT, both load-bearing:
381
+ * - `IPersistFile::Load` must be given STGM_READWRITE (2). Loading with 0 opens the link
382
+ * READ-ONLY and the later `IPropertyStore::Commit` fails STG_E_ACCESSDENIED (0x80030005)
383
+ * — measured on desktop-14rjcr8, where the first version of this passed 0 and wrote
384
+ * nothing while reporting no error the caller could see.
385
+ * - the write is verified by READING THE PROPERTIES BACK with `Read`, which is also what
386
+ * decides whether the shortcut needs repairing at all.
387
+ */
388
+ const LNK_PROPS_CS = `using System;
389
+ using System.Runtime.InteropServices;
390
+ public static class SummriseLnk {
391
+ [StructLayout(LayoutKind.Sequential, Pack = 4)]
392
+ struct PROPERTYKEY { public Guid fmtid; public uint pid; }
393
+ [StructLayout(LayoutKind.Sequential)]
394
+ struct PROPVARIANT { public ushort vt, r1, r2, r3; public IntPtr p, p2; }
395
+ [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
396
+ interface IPropertyStore {
397
+ int GetCount(out uint c);
398
+ int GetAt(uint i, out PROPERTYKEY k);
399
+ int GetValue(ref PROPERTYKEY k, out PROPVARIANT v);
400
+ int SetValue(ref PROPERTYKEY k, ref PROPVARIANT v);
401
+ int Commit();
402
+ }
403
+ [ComImport, Guid("0000010b-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
404
+ interface IPersistFile {
405
+ void GetClassID(out Guid g);
406
+ [PreserveSig] int IsDirty();
407
+ void Load([MarshalAs(UnmanagedType.LPWStr)] string f, uint m);
408
+ void Save([MarshalAs(UnmanagedType.LPWStr)] string f, [MarshalAs(UnmanagedType.Bool)] bool r);
409
+ void SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string f);
410
+ void GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string f);
411
+ }
412
+ static readonly Guid CLSID = new Guid("00021401-0000-0000-C000-000000000046");
413
+ static readonly Guid FMT = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3");
414
+ const uint RW = 2;
415
+ static PROPERTYKEY K(uint pid) { PROPERTYKEY k; k.fmtid = FMT; k.pid = pid; return k; }
416
+ static object New(string lnk, uint mode) {
417
+ object o = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID));
418
+ ((IPersistFile)o).Load(lnk, mode);
419
+ return o;
420
+ }
421
+ static string Get(string lnk, uint pid, uint mode) {
422
+ IPropertyStore s = (IPropertyStore)New(lnk, mode);
423
+ PROPERTYKEY k = K(pid); PROPVARIANT v;
424
+ if (s.GetValue(ref k, out v) != 0) return "";
425
+ if (v.vt != 31 || v.p == IntPtr.Zero) return "";
426
+ string r = Marshal.PtrToStringUni(v.p);
427
+ Marshal.FreeCoTaskMem(v.p);
428
+ return r;
429
+ }
430
+ static void Put(IPropertyStore s, uint pid, string val) {
431
+ PROPERTYKEY k = K(pid); PROPVARIANT v = new PROPVARIANT();
432
+ v.vt = 31; v.p = Marshal.StringToCoTaskMemUni(val);
433
+ try { int hr = s.SetValue(ref k, ref v); if (hr != 0) Marshal.ThrowExceptionForHR(hr); }
434
+ finally { Marshal.FreeCoTaskMem(v.p); }
435
+ }
436
+ public static string Read(string lnk) { return Get(lnk, 5, 0) + "|" + Get(lnk, 3, 0); }
437
+ public static void Write(string lnk, string id, string icon) {
438
+ object o = New(lnk, RW);
439
+ IPropertyStore s = (IPropertyStore)o;
440
+ Put(s, 5, id); Put(s, 3, icon);
441
+ int hr = s.Commit();
442
+ if (hr != 0) Marshal.ThrowExceptionForHR(hr);
443
+ ((IPersistFile)o).Save(lnk, true);
444
+ }
445
+ }`;
301
446
  // stage-brand: healed desktop shortcut. A 2026-09-01 Summrise.lnk on devices
302
447
  // points at the RETIRED Tauri summrise-desktop.exe (embedded stale icon) —
303
448
  // double-clicking it launches the dead app instead of the Electron shell.
304
449
  // Repair (only if the link already exists — headless installs must not
305
450
  // 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.
451
+ // onlogon path) with IconLocation pinned to the sunrise icon.ico, remove the
452
+ // retired Tauri/tray orphans nothing ships anymore, AND write the taskbar
453
+ // identity (System.AppUserModel.ID + RelaunchIconResource) into the link's
454
+ // property store.
455
+ //
456
+ // WHY THE IDENTITY IS PART OF *THIS* FUNCTION: a shortcut with the right target
457
+ // and the wrong (or absent) identity is exactly the state that draws the
458
+ // Electron logo — the shell sets the same AppUserModelID on its process, and the
459
+ // taskbar's icon lookup reaches the shortcut arm only when a shortcut carrying
460
+ // that ID can be found. MEASURED on desktop-14rjcr8 before this change: both
461
+ // Summrise.lnk files existed and carried NEITHER property, which is why an
462
+ // explicit ID on the window alone changed nothing and why deleting that ID
463
+ // (d4edbb64) changed nothing either.
464
+ //
308
465
  // Best-effort + logged, never fatal. `sink` is a PS output pipe
309
466
  // (e.g. Write-Host, or the update log pipe). Single-quoted PS literals
310
467
  // only (npm audit #6) except the double quotes the .lnk Arguments path
@@ -313,18 +470,66 @@ exports.psq = psq;
313
470
  // script to `ps()` as argv and the update swap runs it from a file. The
314
471
  // backslash-escape `setup` used to apply was deleted with that conversion
315
472
  // (see step 7), which is why this line no longer says "escape them".
473
+ // The C# rides in a single-quoted HERE-STRING (`@'` … `'@`, both alone on
474
+ // their line): it contains double quotes and is literal end to end, so the
475
+ // doubling rule above cannot be defeated by anything inside it.
316
476
  // exported: unit-tested in test/cli.test.mjs.
317
477
  function deskShortcutRepairPs(scriptsQ, deskDirQ, sink) {
478
+ const { id, icon } = lnkIdentity(`${deskDirQ}\\icon.ico`);
479
+ // BOTH shortcuts get the identity, and the START MENU one is the load-bearing half:
480
+ // deleting it from a running shell reverted the taskbar to the Electron logo with the ID
481
+ // still set on the process and both desktop shortcuts still carrying it (see DESKTOP_AUMID).
482
+ // The desktop link is kept because it is what a person double-clicks and because
483
+ // appids.md says to apply the ID to "all of the shortcuts" a user can launch from.
484
+ const lnk = (p, tag) => [
485
+ ` try { $dWs${tag} = New-Object -ComObject WScript.Shell; $dSc${tag} = $dWs${tag}.CreateShortcut(${p}); $dSc${tag}.TargetPath = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'; $dSc${tag}.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "' + $dPs1 + '"'; $dSc${tag}.WorkingDirectory = '${deskDirQ}'; $dSc${tag}.IconLocation = $dIco + ',0'; $dSc${tag}.Save(); 'desk: ' + ${p} + ' repointed to electron shell' | ${sink} } catch { ('desk: ' + ${p} + ' repair failed: ' + $_.Exception.Message) | ${sink} }`,
486
+ ` if ($dSet) { try { [SummriseLnk]::Write(${p}, $dId, $dRes); ('desk: ' + ${p} + ' taskbar identity [' + [SummriseLnk]::Read(${p}) + ']') | ${sink} } catch { ('desk: ' + ${p} + ' identity write failed: ' + $_.Exception.Message) | ${sink} } }`,
487
+ ];
318
488
  return [
319
489
  `$dLnk = Join-Path $env:PUBLIC 'Desktop\\Summrise.lnk'`,
490
+ // ALL-USERS Start Menu: the shortcut the shell's app resolver actually reads, and it is
491
+ // CREATED here when absent — a machine that never had one is exactly the machine whose
492
+ // taskbar shows the Electron logo, and no amount of desktop-side repair reaches it.
493
+ // `setup` already requires elevation (HKLM writes, Program Files), so ProgramData is
494
+ // writable on every path that calls this.
495
+ `$dSm = Join-Path $env:ProgramData 'Microsoft\\Windows\\Start Menu\\Programs\\Summrise.lnk'`,
320
496
  `$dIco = '${deskDirQ}\\icon.ico'`,
321
497
  `$dPs1 = '${scriptsQ}\\start-desktop.ps1'`,
498
+ `$dId = '${id}'`,
499
+ `$dRes = '${icon}'`,
500
+ `$dSet = $false`,
501
+ `$dShell = (Test-Path $dIco) -and (Test-Path $dPs1)`,
502
+ // Compiled when there is a SHELL to point at, not when a desktop link happens to exist:
503
+ // the Start Menu shortcut is created from nothing, so gating this on `$dLnk` would skip
504
+ // the identity on precisely the installs that need it. The `-as [type]` guard keeps a
505
+ // second call in one process from turning a "type already exists" throw into a silent
506
+ // skip of every write below.
507
+ `if ($dShell) {`,
508
+ ` $dCs = @'\n${LNK_PROPS_CS}\n'@`,
509
+ ` try { if (-not ('SummriseLnk' -as [type])) { Add-Type -TypeDefinition $dCs }; $dSet = [bool]('SummriseLnk' -as [type]) } catch { ('desk: lnk identity helper unavailable: ' + $_.Exception.Message) | ${sink} }`,
510
+ `}`,
322
511
  `$dNeed = $false`,
323
512
  `if (Test-Path $dLnk) {`,
324
513
  ` 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 }`,
514
+ // ...and a link that is otherwise healthy still needs repairing when its identity is
515
+ // missing or names a different ID/icon. `Read` returns "id|icon", so ONE comparison
516
+ // covers both properties — and it is the same call the verification below prints.
517
+ ` if ($dSet) { try { if (([SummriseLnk]::Read($dLnk)) -ne ($dId + '|' + $dRes)) { $dNeed = $true } } catch { $dNeed = $true } }`,
325
518
  `}`,
326
- `if ($dNeed -and (Test-Path $dIco) -and (Test-Path $dPs1)) {`,
327
- ` 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} }`,
519
+ // The Start Menu link is created when ABSENT and rewritten when its identity is wrong —
520
+ // it is never deleted, and a link that already matches is left alone so a second `setup`
521
+ // does not churn the file.
522
+ `$dSmNeed = $false`,
523
+ `if ($dShell) {`,
524
+ ` if (-not (Test-Path $dSm)) { $dSmNeed = $true }`,
525
+ ` elseif (-not $dSet) { $dSmNeed = $true }`,
526
+ ` else { try { if (([SummriseLnk]::Read($dSm)) -ne ($dId + '|' + $dRes)) { $dSmNeed = $true } } catch { $dSmNeed = $true } }`,
527
+ `}`,
528
+ `if ($dNeed -and $dShell) {`,
529
+ ...lnk("$dLnk", "1"),
530
+ `}`,
531
+ `if ($dSmNeed -and $dShell) {`,
532
+ ...lnk("$dSm", "2"),
328
533
  `}`,
329
534
  `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
535
  ];
@@ -363,7 +568,30 @@ function desktopTaskPs(installQ) {
363
568
  `$dt1 = New-ScheduledTaskTrigger -AtLogOn`,
364
569
  `$dw1 = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(3) -RepetitionInterval (New-TimeSpan -Minutes 5)`,
365
570
  `$pr = New-ScheduledTaskPrincipal -UserId ('{0}\\{1}' -f $env:USERDOMAIN, $env:USERNAME) -LogonType Interactive -RunLevel Highest`,
366
- `$st = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 10) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew`,
571
+ // NO EXECUTION TIME LIMIT -- the same value, in the same spelling, the agent's own task has
572
+ // carried since round 118 for the same reason: `- ExecutionTimeLimit 0 never kill the
573
+ // running task`. It was `-Minutes 10` here, and that WAS the defect.
574
+ //
575
+ // WHY A LIMIT KILLS A SHELL MEANT TO LIVE INDEFINITELY: this task's action is
576
+ // desktop-pulse.vbs -> ensure-desktop.ps1 -> start-desktop.ps1, and that last line is
577
+ // `& electron.exe .` -- the PowerShell CALL OPERATOR, which WAITS. The PowerShell the task
578
+ // launched therefore stays alive for as long as the shell does, as the shell's PARENT, and
579
+ // Task Scheduler enforces ExecutionTimeLimit on that process tree. Ten minutes after every
580
+ // start the scheduler killed the running shell; five minutes later the guarded pulse
581
+ // correctly brought a fresh one back; ten minutes after that it died again. The operator
582
+ // reported that as "反复重启", four times.
583
+ //
584
+ // NOTHING RELIED ON THE KILL, which is why removing it is the fix rather than a trade. This
585
+ // repository's recovery for a shell that is GONE is the guarded 5-minute pulse, and
586
+ // summrise-desktop-electron/src/main.ts says so from the other side, naming a logon-only task
587
+ // as one that "cannot recover a wedged shell". A shell that is RUNNING but wedged was never
588
+ // this limit's business either: the pulse's guard is a process-EXISTENCE check
589
+ // (`if (Get-Process electron) { exit }`), so it cannot see a wedged shell whether the limit is
590
+ // ten minutes or absent. `summrise update` is what heals a wedged shell, and its log says so.
591
+ //
592
+ // Measured on desktop-14rjcr8: under PT10M the four electron PIDs were replaced about every
593
+ // ten minutes; under PT0S the same four PIDs were still there 11 minutes later.
594
+ `$st = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew`,
367
595
  `Register-ScheduledTask SummriseDesktop -Action $da -Trigger @($dt1,$dw1) -Principal $pr -Settings $st -Force | Out-Null`,
368
596
  `Start-ScheduledTask -TaskName SummriseDesktop`,
369
597
  ];
@@ -2879,7 +3107,15 @@ const commands = {
2879
3107
  ` $da = New-ScheduledTaskAction -Execute 'wscript.exe' -Argument ('"' + $vb1 + '"') -WorkingDirectory '${q}'`,
2880
3108
  ` $dt1 = New-ScheduledTaskTrigger -AtLogOn`,
2881
3109
  ` $dw1 = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(3) -RepetitionInterval (New-TimeSpan -Minutes 5)`,
2882
- ` Set-ScheduledTask -TaskName 'SummriseDesktop' -Action $da -Trigger @($dt1, $dw1) | Out-Null`,
3110
+ // AND THE SETTINGS, WHICH IS THE HALF THAT REACHES A MACHINE ALREADY INSTALLED.
3111
+ // This call used to pass only -Action and -Trigger, and `Set-ScheduledTask` PRESERVES the
3112
+ // components it is not given — so an execution limit already on the task survived every
3113
+ // update. That is the wrong shape for a heal: `summrise update` is the only mechanism that
3114
+ // reaches the fleet, and a device carrying the old 10-minute limit would have kept it
3115
+ // forever while its log said "hardened". The value is the same one `setup` writes, so the
3116
+ // two cannot diverge — and installer_integrity.rs holds both ends to it.
3117
+ ` $dst = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew`,
3118
+ ` Set-ScheduledTask -TaskName 'SummriseDesktop' -Action $da -Trigger @($dt1, $dw1) -Settings $dst | Out-Null`,
2883
3119
  ` "[$(Get-Date -Format o)] desk: SummriseDesktop hardened (guarded 5-min pulse)" | ${log}`,
2884
3120
  `}`,
2885
3121
  `if ((Test-Path $deskDir) -and (Test-Path $deskStart)) {`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "summrise-agent",
3
- "version": "1.2.490",
3
+ "version": "1.2.492",
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,38 @@ 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 VALUE IS NOT THE ONE THIS FILE USED BEFORE ("…summrise.agent"), and that is the
913
+ // measured half of the fix rather than a rename. On desktop-14rjcr8, with that string set
914
+ // on the window AND read back off the shortcut, the taskbar still drew the Electron logo:
915
+ // releases up to 1.2.489 had already resolved that AUMID — set with no shortcut anywhere —
916
+ // against the backing executable and kept the answer, and `shell:AppsFolder` still lists it
917
+ // as an app named "Electron". The same machine drew the SUNRISE under a string it had never
918
+ // seen, with every other variable held. DESKTOP_AUMID carries the two-row table.
919
+ // (The same shortcut association is also the documented precondition for the hide-to-tray
920
+ // toast below: Electron requires a Start Menu shortcut carrying the ID and a
921
+ // ToastActivatorCLSID, and this one carries neither — unchanged by this call.)
922
+ const AUMID = "online.saisi.summrise.desktop";
911
923
  try {
912
924
  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)";
925
+ electron_1.app.setAppUserModelId(AUMID);
926
+ iconReport["appUserModelId"] = process.platform === "win32" ? AUMID : "(non-windows)";
915
927
  }
916
928
  catch {
917
929
  iconReport["appUserModelId"] = "(set-failed)";
@@ -934,6 +946,16 @@ if (gotTheLock) {
934
946
  electron_1.Menu.setApplicationMenu(buildMenu());
935
947
  win = new electron_1.BrowserWindow({
936
948
  width: 1200, height: 800, title: "Summrise",
949
+ // NO WHITE FLASH (window-creation honesty, round-275). The window used to be
950
+ // created VISIBLE, before loadDesktop() runs its ~800 ms /api/status probe and
951
+ // before the first loadURL — so every launch painted a blank white frame and then
952
+ // whatever the agent turned out to be. show:false keeps it off screen until the
953
+ // first document has painted (ready-to-show, below); backgroundColor paints that
954
+ // first frame in the shell's own ink instead of Chromium's white, which is the one
955
+ // of the two documents that had NOT already fixed this (WAIT_HTML hardcodes
956
+ // background:#111, and the SPA's chrome is dark).
957
+ show: false,
958
+ backgroundColor: "#111",
937
959
  // Taskbar + title-bar icon: without this Windows shows the stock
938
960
  // electron.exe icon (the running binary is stock Electron). PNG —
939
961
  // see windowIcon() on why not .ico here.
@@ -955,6 +977,11 @@ if (gotTheLock) {
955
977
  backgroundThrottling: false,
956
978
  },
957
979
  });
980
+ // The other half of show:false — the first paint shows the window. It fires on the
981
+ // first document to paint (the SPA when the agent answers, the wait page when it does
982
+ // not), and the handler is registered in the same tick as the constructor, before any
983
+ // loadURL can happen, so there is no window in which the event is missed.
984
+ win.once("ready-to-show", () => { win?.show(); });
958
985
  // review #6 (MED) TOP RISK: the main window carries the summriseDesktop/
959
986
  // summriseBrowser preload bridge (setAutoLaunch → schtasks /create …
960
987
  // -File <ps1>, browser-session:open). Without a navigation veto a
@@ -1048,25 +1075,15 @@ if (gotTheLock) {
1048
1075
  let retryMs = 2000;
1049
1076
  const nextRetry = () => { retryMs = Math.min(retryMs * 2, 30000); return retryMs; };
1050
1077
  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
- };
1078
+ // NO setVersionTitle() HERE, DELETED. It fetched /api/status with the device token and
1079
+ // called win.setTitle("Summrise — v1.2.x"), and that title NEVER REACHED THE SCREEN: the
1080
+ // SPA owns document.title (`useAttentionTitle` assigns it on every attention change,
1081
+ // panel-react/src/hooks/useAttention.ts:96 — plain "Summrise Agent" in the desktop window)
1082
+ // and no page-title-updated handler on THIS window stops it, so the renderer's title
1083
+ // replaced the native one on the first render. The version it wanted to show is already on
1084
+ // screen, in the panel's own status bar (DesktopShell's .desktop-status) and in the tray
1085
+ // tooltip, both of which read the same /api/status. A native title that only the process
1086
+ // could read was also the LAST credentialed fetch on this path — the tray keeps its own.
1070
1087
  // stage-n: SELF-HEAL watchdog. The wait-page button needed a human, but
1071
1088
  // when the AGENT is down the cloudflared tunnel (agent-supervised) dies
1072
1089
  // too — a remote operator then has NO channel left to click anything
@@ -1085,7 +1102,6 @@ if (gotTheLock) {
1085
1102
  if (await agentReady()) {
1086
1103
  agentMissCount = 0;
1087
1104
  resetRetry();
1088
- setVersionTitle();
1089
1105
  win?.loadURL(`${(0, url_policy_1.agentBase)()}/desktop/`).catch(() => { });
1090
1106
  }
1091
1107
  else {
@@ -1125,11 +1141,13 @@ if (gotTheLock) {
1125
1141
  // without it, playwright grabbed the MAIN window (now tripwired) or the
1126
1142
  // user had to open the Browser page first. The view is hidden until the
1127
1143
  // SPA's Browser page reports bounds (embeddedViewPlace shows it).
1144
+ // NO EAGER NAVIGATION: this used to loadURL("https://www.bing.com"), i.e. a
1145
+ // request to a third party on EVERY launch before anyone asked for a page.
1146
+ // The view is still created — about:blank is a first-class CDP target (the
1147
+ // reason it exists here is that a target be available, not that a page be
1148
+ // loaded), and the user/AI navigates it next (loadTarget is the one door).
1128
1149
  try {
1129
- const v = embeddedViewEnsure();
1130
- if (v && !v.webContents.isDestroyed()) {
1131
- v.webContents.loadURL("https://www.bing.com").catch(() => { });
1132
- }
1150
+ embeddedViewEnsure();
1133
1151
  }
1134
1152
  catch { /* non-fatal */ }
1135
1153
  console.log(`[summrise] CDP endpoint: http://127.0.0.1:${CDP_PORT} (playwright connectOverCDP)`);
@@ -1195,6 +1213,9 @@ if (gotTheLock) {
1195
1213
  // status strip shows the same pair).
1196
1214
  let trayAgentCpu = null;
1197
1215
  let trayAgentMem = null;
1216
+ /** WHEN /api/status last ANSWERED (null = it never has since launch) — the tray's
1217
+ * tooltip reports this instead of asserting a state it cannot date. */
1218
+ let trayLastAnsweredAt = null;
1198
1219
  /** "3m 24s" / "1h 5m" / "2d 3h" — compact uptime for the tray. */
1199
1220
  const fmtUptime = (secs) => {
1200
1221
  if (secs < 60)
@@ -1205,10 +1226,16 @@ if (gotTheLock) {
1205
1226
  return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
1206
1227
  return `${Math.floor(secs / 86400)}d ${Math.floor((secs % 86400) / 3600)}h`;
1207
1228
  };
1229
+ /** "14:02:31" local — the WHEN of an observation, for the one surface that cannot
1230
+ * re-derive it (a tray tooltip is a string set once per poll, not a render). */
1231
+ const fmtClock = (at) => new Date(at).toTimeString().slice(0, 8);
1208
1232
  const refreshTray = async () => {
1209
1233
  // HTTP liveness, not the TCP probe (see agentReady above): a wedged-
1210
1234
  // but-listening agent must show as STOPPED, not "running".
1211
1235
  const running = await agentResponds(1000);
1236
+ const observedAt = Date.now();
1237
+ if (running)
1238
+ trayLastAnsweredAt = observedAt;
1212
1239
  let version = trayAgentVersion;
1213
1240
  let uptime = trayAgentUptime;
1214
1241
  let sessions = trayAgentSessions;
@@ -1248,9 +1275,19 @@ if (gotTheLock) {
1248
1275
  const vitals = running && mem !== null
1249
1276
  ? `, CPU ${cpu === null ? "?" : Math.round(cpu)}% · MEM ${Math.round(mem)}%`
1250
1277
  : "";
1278
+ // THE TOOLTIP NAMES THE OBSERVATION, NOT A VERDICT (round-275). It used to print
1279
+ // "Agent stopped" as present tense from ONE un-timestamped probe, recomputed every
1280
+ // 30 s — half a minute stale while reading as now, the shape the panel's liveness
1281
+ // strings were already fixed for (`checked 12s ago`, `reachability NOT VERIFIED`).
1282
+ // The WHEN is a CLOCK rather than "answered 12s ago", deliberately: a tooltip is a
1283
+ // string written once per poll and Electron gives it no way to recompute, so a
1284
+ // relative age would freeze at the value it had when written and read as "now" for
1285
+ // the next 30 s — the very failure this line removes. A timestamp does not decay.
1251
1286
  const health = running
1252
- ? `Agent running (v${version || "?"}${uptime ? `, up ${uptime}` : ""}${sessions ? `, ${sessions} session${sessions === 1 ? "" : "s"}` : ""}${vitals})`
1253
- : "Agent stopped";
1287
+ ? `answered ${fmtClock(observedAt)} · v${version || "?"}${uptime ? `, up ${uptime}` : ""}${sessions ? `, ${sessions} session${sessions === 1 ? "" : "s"}` : ""}${vitals}`
1288
+ : trayLastAnsweredAt === null
1289
+ ? "reachability NOT VERIFIED · no reply since launch"
1290
+ : `not answering · last reply ${fmtClock(trayLastAnsweredAt)}`;
1254
1291
  // stage-n: the agent died WHILE the window was showing the SPA (the
1255
1292
  // wait page only renders when the boot probe fails). Swap the window
1256
1293
  // to the wait page — its "Start Agent" button is now actually visible
@@ -1266,16 +1303,15 @@ if (gotTheLock) {
1266
1303
  if (running && agentWatchActive)
1267
1304
  agentWatchActive = false;
1268
1305
  tray.setToolTip(`Summrise — ${health}`);
1306
+ // THE FOUR "New …" ROWS ARE GONE (round-275): they duplicated the desktop
1307
+ // header's `+ New` menu item for item (pty/ssh/serial/browser), one click
1308
+ // cheaper than the tray path, which had to restore and focus the window
1309
+ // first anyway. "Open" is the tray's job; creating sessions is the app's.
1269
1310
  tray.setContextMenu(electron_1.Menu.buildFromTemplate([
1270
1311
  { label: "Open", click: () => { focusMain(); } },
1271
1312
  { type: "separator" },
1272
1313
  { label: health, enabled: false },
1273
1314
  { 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
1315
  { label: "Quit", click: () => { electron_1.app.isQuitting = true; electron_1.app.quit(); } },
1280
1316
  ]));
1281
1317
  };