MusePi

MusePi GUI Implementation Notes (Contracts & Pitfalls)

English 中文

Status: living document (established 2026-08-06, split out of gui-design.md) — the factual record of the packages/gui / packages/desktop-web implementation: daemon RPC contracts, IPC shapes, algorithm semantics, pitfalls, and verification methods. Kept in sync with the implementation; implementation files are the source of truth.

For design style specs (layout/tokens/motion/component patterns), see docs/gui-design.md.

1. daemon lifecycle

musepi serve is spawned detached by Electron main (daemon.cjs); it survives GUI exit, and restarting the GUI only reconnects without picking up new code — after daemon code changes you must restart it. The instance menu (header) has a “Restart daemon” item (daemon-restart IPC): lsof finds the listening pid → SIGTERM → wait for port release → re-spawn → wait for bind → GUI auto-reconnects (boot chain). The lsof argument must be composed as the single token -tiTCP:<port> (splitting it makes lsof treat the pieces as filenames). Main-process code (main.cjs/daemon.cjs) does not hot-reload — you must restart the Electron instance after changes.

1b. Idle recap (daemon contract)

Full parity implementation of TUI recap.enabled/recap.idleSeconds (schema tab interaction, group Notifications) for daemon sessions:

1c. Pause (daemon contract, 2026-08-20)

Pause comes in two levels; all state lives only on the daemon side and survives GUI reconnect/restart:

1d. Connection recovery (2026-08-20, lid-close sleep/wake freeze fix)

Trigger: Electron drops the renderer↔daemon WebSocket during system sleep (electron#19993, localhost included); it must reconnect after wake. The recovery chain has three layers:

1e. Custom provider model discovery (models.discover, 2026-08-22)

The daemon contract behind the “Fetch available models” button: a one-shot query against a draft endpoint during configuration; writes no config, touches no cache.

2. Extensions control center (daemon contract)

The settings “Extensions” tab + the sidebar “Extensions” entry share components/ExtensionsCenter.tsx, TUI /extensions parity. UI shape (gui-design.md documents none of this — contracts only here): top provider tabs (buildProviderTabs ordering: ALL → enabled with content → disabled → empty, disabled grayed but clickable) + left provider→kind(count)→item three-level tree (provider-level toggles go through setProviderEnabled, native/builtin nodes read-only; kind collapse memory) + right detail pane (name/type/description/triggers/source via X (level)/path/status/instruction content/raw inspector collapsed).

daemon RPC:

Semantics: three-dot status colors green active / gray disabled / orange shadowed (details show shadowedBy). Once a provider is disabled, its entries disappear from the list (loadCapability-layer behavior, same source as TUI, not a bug). Builtin determination: provider native omp-managed builtin-defaults.

3. Git settings & features (implementation)

4. Session settings & cleanup (algorithms)

5. Pet implementation details (dual windows: pet.html + bubble.html, updated 2026-08-11)

6. CSS anti-pattern checklist (every one bitten us)

  1. Custom property self-reference/cycle (--border: var(--border)) → guaranteed-invalid, silently disappears. Same pit recurring: a floating scrim wanting --gui-glass-overlay: max(95%, var(--gui-glass-overlay)) is self-reference, silently falling back to the inherited value — override background itself directly.
  2. calc length × percentage (calc(28px * 100%)) → invalid, silently falls back to 0.
  3. transform-animated keyframes replace static transform (translate(-50%,-50%) + scale keyframes) → anchor lost mid-animation, element jumps; use opacity-only or bake the full transform into keyframes.
  4. backdrop-filter first-frame flash → two-phase mount (opacity 0 on screen first, animation class added next frame).
  5. Animating at mount kills frost (measured 2026-08-06, same root as 4): gui-menu-in with transform: scale, if played directly on the mount frame (old ContextMenu/Pop implementation), makes Chromium’s real screen compositor skip backdrop sampling and never resample after the animation ends — menus permanently render as plain translucency (text behind bleeds through, no frost); useFloatingMenu’s two-phase (no animation class on mount frame, rAF adds --entered next frame) is unaffected. CDP screenshots (offscreen compositing) still show blur — the biggest misleader — we once misjudged from it that “transparent-window blur is entirely broken” (electron#30412 is a long-unresolved separate issue, but this GUI’s menu blur was always fixable) and wrongly covered all floating layers with a 95% scrim (user instantly noticed “none of them are frosted anymore”; reverted). Fix: ContextMenu/Pop entered two-phase (--pending/--entered classes), Pop callers’ classes dropped their own animations. **Verifying floating-layer frost requires real screen capture (screencapture -l ); CDP screenshots/computed styles don't count.** **All floaters unified (2026-08-11)**: shared hook `useTwoPhaseEnter(active)` (gui/src/lib/use-two-phase-enter.ts; returns the `--entered` suffix after two rAFs, resets on close) — wired into the 5 previously off-reservation spots: Board zoom/widget tasks/onboarding overlay/⌘K command palette (switched to persistent mount + exit animation)/selection toolbar (entrance+exit added); base `opacity:0` + `--entered` makes motion-off naturally instantaneous. Those spots previously played `gui-fade-in` (pure opacity, no scale) directly on the mount frame — a mild variant of this item's risk class, never measured failing on a real screen, but contractually violating two-phase; unified to eliminate divergence.
  6. Flex children lacking min-width:0 → content blows out / inconsistent sizing; flex children with margin-inline:auto beat stretch.
  7. popup/floater clipped by ancestor overflow/transform → portal to body.
  8. rAF throttle latch not released inside the frame callback → subsequent events swallowed.
  9. SVG stroke gradients require gradientUnits="userSpaceOnUse" (measured 2026-08-06 icon rendering): stroke="url(#g)" with default objectBoundingBox units renders the entire stroke blank in Chromium renderers (Chrome/Electron headless), while fill with the same gradient is fine; explicit coordinates + userSpaceOnUse restore it. Always hit when rendering app icon SVGs (build/icon.svg).
  10. Unlayered rules suppress all @layer rules (root cause of the 2026-08-06 edge-flush sidebar tabstrip): desktop-web base.css’s *{margin:0} is unlayered, Tailwind v4 utilities all live in @layer utilities — in the cascade unlayered always beats every layer, so sidebar mx-2.5/mt-3/ml-auto and friends all silently computed to 0px (pill flush against the left edge, right button cluster hugging the pill, mt-3 spacing gone), while padding utilities worked fine (no universal padding reset) — symptoms extremely misleading. Fix: move the reset into @layer base, preceded by empty @layer theme/base/components/utilities {} blocks pinning order (tailwind CLI normalizes @layer a,b,c; statements into this empty-block form anyway; build idempotent). Lesson: when Tailwind margin utilities don’t apply in the GUI, check the unlayered universal margin reset first.
  11. Recovery path for accidentally truncated source files (incident record, 2026-08-06): head -c <N> truncates big CSS by bytes (8251 lines ≈ 370KB); with no WIP commits, no sourcemaps, no TM snapshots, the only complete recovery source is the dist minified CSS from the last bun run build (contains all rules): ① selector-set diff (HEAD vs dist) enumerates WIP rules; ② extract each rule from dist (minified single lines; brace-balanced scan + backward search to the selector list start; merge comma-grouped blocks); ③ record context for rules inside @media; ④ compare @keyframes one by one (note ag-*/tr-*/tv-*/spin etc. belong to desktop-web — don’t mix them into gui.css); ⑤ append after formatting/reordering, with recovery comments. Functionally equivalent after recovery; formatting/comments lost. Lesson: run wc -c before truncating big files; periodically git add important CSS (the index blob can rescue you).
  12. Legacy keyframes baking static transform misaligns under flow layout (2026-08-11): the panel keyframes baked translateX(-50%) (absolute-centering residue), while the new layout is transform: none (flow + margin centering) — during animation the element shifts half-width left, “right half first, left half popping”. Lesson: whenever changing layout positioning, audit static transforms inside keyframes in sync; decouple animation from static layout with flat keyframes (only animate relative displacement/scale/blur).
  13. getBoundingClientRect during animation includes transform (2026-08-11): rect is the shrunk value under a scale(0.98) entrance — content-driven windows reporting it size themselves small, then jump after animationend. Content-size reporting uses offsetWidth/offsetHeight (layout box) for animated elements; detect el.getAnimations().some(a => a.playState === "running").
  14. Width-lock measurement with width:"auto" overrides CSS max-content (2026-08-11): when measuring morph target widths, style.width = "auto" on block elements = fill the containing block (overrides CSS width:max-content), degrading toW to container width → width transition silently skipped (height fine; visually “only shrinks height, not width,” then jumps). Must use style.width = "" (remove the inline declaration, restoring the CSS value).
  15. Floating card class applied twice = nested double-rounded corners (2026-08-15, custom accent color picker): useFloatingMenu(…, { className }) puts the class on the outer portal container; if the content component’s root carries the same card class (ColorPickerPanel root gui-color-picker), both layers get frost+radius+shadow → the background draws an extra rounded container (div.gui-menu-popup.gui-color-picker.gui-menu-popup--entered > div.gui-color-picker). Rule: the card class may appear on exactly one layer — when passing className, flatten content (proj/todo/queue/creds classes); when not passing, the content root owns the card (quota/context/color-picker classes). Verify: document.querySelectorAll('.gui-color-picker').length === 1 and outer computed border-radius: 0, background: transparent.

7. macOS app icon handling (investigated + fixed 2026-08-06)

Three independent paths, completely different rules:

  1. Packaged Dock / Finder icon = Contents/Resources/icon.icns inside the bundle (electron-builder default buildResources/icon.icns = build/icon.icns). LaunchServices automatically applies the system squircle mask when resolving bundles → the icns must be full-bleed 1024 square, never draw your own rounded corners (double rounding otherwise). Generation chain: SVG → 1024 PNG → iconutil -c icns (10 tiers 16-1024 @1x/@2x). After release packaging, manually sync the bundle icns (md5 match).
  2. dev-mode Dock icon = app.dock.setIcon(image): official semantics is just stamping an image onto NSDockTile, not going through LaunchServices, so the system mask doesn’t apply → passing a full-bleed square PNG yields sharp corners (root cause of the user seeing “square icons”). dev must use the pre-rounded PNG (build/icon-dock.png). Fill ratio calibrated against kimi = 80.5% (2026-08-06, unpacked /Applications/Kimi.app/Contents/Resources/icon.icns 1024 tier, alpha bbox x100-923): dark card 824/1024 centered + superellipse n=5 corner rounding + symmetric transparent margins all around. Full-bleed 100% makes the Dock icon ~24% bigger than kimi (root cause of “always slightly bigger”; the 92% inset version is still >80.5%); the earlier “never inset” conclusion was wrong — kimi’s official desktop asset is card-within-card; visual consistency with neighboring apps takes priority. Generated with Python/numpy. The packaged icns carries the same 80.5% margins (card-within-card is design, not a masking mistake).
  3. Window icon (BrowserWindow icon) = Linux/Windows window chrome; on macOS Cmd+Tab/window previews render from the bundle/Dock icon by the system (full-bleed + system mask); the window preview showing a “big square” is the window content preview, not the icon — system behavior.

Splash embedded logo (src/vendor/logo.png) = an in-UI img; the system mask doesn’t apply, so it also needs the pre-rounded version (512, same origin and params as icon-dock); .gui-splash-logo must not get additional CSS border-radius (double rounding). Changing icons = sync three places: build/icon.icns (packaged) + build/icon-dock.png (dev Dock) + src/vendor/logo.png (splash/embedded).

8. Verification workflow (mandatory for UI changes)

9. Platform adaptation (2026-08-11)

10. Managed browser (Proma absorption, 2026-08-11)

The right-side Browser tool upgraded from a “standalone webview” to a managed browser: the Electron main process owns a WebContentsView (one per tab), and the agent’s browser tool drives the same instance through a local CDP bridge — the page the user sees in the panel is exactly the page the agent operates, with login state naturally shared (Proma browser-controller pattern).

Architecture

Configuration (settings-schema + Settings → Tools → Grep & Browser)

Pitfalls (verified)

  1. about:blank initial-state debugger wedge: calling debugger.attach on a webContents whose initial load hasn’t completed leaves all commands pending forever; only alive after navigation. Fix: backgroundThrottling: false + loadURL("about:blank") forcing renderer startup + whenDebuggerReady() (wait for did-finish-load) before attach.
  2. Forwarding Target.setAutoAttach (waitForDebuggerOnStart:true) to the real debugger wedges: Electron’s single-session debugger has no child targets; intercept setAutoAttach/setDiscoverTargets/runIfWaitingForDebugger inside the page session and answer {} locally.
  3. Page.captureScreenshot times out on webContents.debugger: intercept it and use capturePage() instead (same as Proma).
  4. Tab-level attachedToTarget(page) events must carry message-level sessionId (scoped to the tab session), otherwise puppeteer’s #targetsIdsForInit never completes and connect() waits forever.

Verification

Boundary items landed (2026-08-12, all E2E verified)

10.1 Best practices (usage + engineering)

Usage (desktop)

Engineering

12. Usage view (usage.reports / tray / ContextRing, 2026-08-16)

daemon RPC usage.reports (server.ts, after session.askAnswer): session scope (params.sessionId → live session’s fetchUsageReports) + global scope (no sessionId; the empty-state composer spins a registry via ensureRegistry()). Returns { reports, unreportedAccounts, disabledCredentials, reloginDeadlines, activeAccount? } — same source as TUI /usage (usage-shared.ts shares aggregation); activeAccount exists only in session scope (● marker).

Data shape: one UsageReport per credential (provider + limits[] + metadata); multiple credentials per provider → the GUI must merge, otherwise:

Merge algorithm (gui/src/components/composer/usage-panel.tsx UsageProviderSection; tray tray-menu-main.tsx buildUsageRows same logic):

Tray menu (gui-tray-menu): fixed window height TRAY_MENU_HEIGHT = 440 (main.cjs) — don’t resize dynamically (tray-menu:set-size IPC was deleted); content scrolls internally (__scroll flex:1 + overflow-y:auto); footer padding 10px 10px 14px (breathing room between buttons and window bottom). The window itself is acrylic (DWM); the page provides chrome only.

Usage cache (packages/ai AuthStorage.fetchUsageReports, shared by GUI/TUI/tray): SQLite cache table disk persistence + 5min TTL (USAGE_REPORT_TTL_MS, ±25% jitter prevents per-IP 429 fan-out) + last-good fallback on upstream failure (24h) + in-flight coalescing (concurrent requests from multiple surfaces hit upstream once). Cache survives daemon restarts; a cold cache’s first view blocks on one upstream round (coalescing guarantees just one).

Verification playbook: component-level headless (bun build temp entry + stubbed electronAPI.trayMenu/props) → assert DOM column order/total/no key warnings; the real tray needs an Electron restart (main-process changes don’t hot-reload).

13. Slash completion ranking (2026-08-16)

gui/src/lib/slash-rank.ts rankSlashEntries(entries, query, guiNative) (shared by session Composer’s use-completion.ts + WelcomeComposer):

14. Trajectory Overview timeline + selection inspector (2026-08-21, DSH Trajectory absorption)

Design spec in gui-design.md §1 (trajectory timeline & inspector). This section records data contracts and pitfalls.

Data contracts

Component behavior

Pitfalls

Verification

packages/gui/test/trajectory.test.ts 10 cases (tsMs extraction / turn chronology / roundDurations Map+array forms / no-hit doesn’t close / range check / usage·duration·ttft extraction); tsgo -p tsconfig.json --noEmit all green; bun run build:bundle passes. CDP/screenshot verification follows the §8 workflow.

15. Message-tree data seam (/tree semantics, 2026-08-21)

Naming normalization in docs/gui-design.md §0 (session list / message tree / trajectory glossary). Data contracts recorded here:

16. Transcript custom-message rendering + streaming markdown contract (2026-08-22)

desktop-web components/transcript/Transcript.tsx’s custom_message branch dispatches on entry.customType; handled:

customType Rendering Data source
collab-prompt user line + source badge details.from
ttsr TtsrBlock (warnings collapsed) details.rules[]
irc:* tr-irc line details.message/body
async-result .tr-async-result card (one line per job: “✓ Background task completed [type] id (duration)”) details.jobs[]
advisor AdvisorBlock (severity-colored rail + badge, blocker count, collapsed beyond 3 items) details.notes[]
others default tr-custom (chip customType + content markdown)

Iron law: model-facing templates (<system-notice>, <advisory severity=…>) exist only inside content (the LLM-facing payload); the GUI renderer reads only clean text from details.* and never renders template bodies to users — both async-result/advisor got GUI rendering added on 2026-08-22 (before that they fell into default tr-custom, displaying raw <system-notice>/<advisory> XML, misaligned with TUI’s buildAsyncResultBlock/createAdvisorMessageCard).

Streaming markdown timing (answering “does it only render after completion” — no):

17. OTA update channel + tri-state send/stop button run-level working semantics (2026-08-22)

OTA update channels (GitHub release asset redirects, bitfun parity)

Three sites, one source, all via /releases/latest/download/update-manifest.json (302 to the latest release asset, no api.github.com rate limits):

Location Purpose Default
gui/electron/updater.cjs main-process OTA check (checkForUpdates) + release-notes fetch (fetchManifestNotes) RELEASE_MANIFEST_URL constant
gui/package.json update.manifestUrl overrides the notes-fetch default at packaging same URL
daemon/server.ts updates.check daemon-side version/notes probe (legacy — UpdateToast moved to updater-notes; RPC kept for parity) same URL (hardcoded)

Tri-state send/stop button: run-level working (turn-level boundary trap)

SendOrStopButton (gui/src/components/composer/action-buttons.tsx) shows the send arrow when idle; working state becomes capsule + dot bloom + dual labels (“Working”/”Stop”, swapping on hover). Key trap:

Update toast (bitfun DailyAppUpdateGate parity)

Release artifacts and CLI relationship (confirmed by measurement 2026-08-23)

The dmg ships a self-contained daemon, no reliance on bun run setup polluting the system: daemonCommand() (electron/daemon.cjs) resolution order — ① musepi on PATH (only if the user installed it separately) → ② packaged Resources/app.asar.unpacked/vendor/daemon/musepi (120MB full CLI binary; the workflow’s “Build daemon binary + Stage into vendor + asarUnpack vendor/daemon/” guarantees delivery) → ③ dev-mode bun src/cli.ts serve.

Release pipeline essentials:

18. Recent landed features (2026-08-24 → 2026-08-26)

Contracts, RPC shapes and pitfalls for work landed after the earlier sections; design intent in docs/gui-design.md §5g, per-feature specs in the referenced docs.

OTA update via electron-updater (v0.4.4, 2026-08-24)

docs/ota-update-design.md. Replaces §17’s “Go to download” with download → verify → install → restart (electron-updater v6.4.1 + GitHub provider):

Extension P3/P4 seams (plugin-design.md P-tiers)

Landed since the 2026-08-25 audit (P3 ❌ / P4 service ❌). In extensibility/extensions/:

Widget data-proxy gap + scheduling engine (board-dashboard.md)

widget.data proxy RPC is implemented for the fx-rates feed — daemon server.ts widget.data handler → getFxRates (open.er-api.com, 60s in-process cache). The renderer market cards (fx.tsx, stocks.tsx) currently fetch directly via widgetFetch (open.er-api.com / Tencent JSONP) — the proxy is not yet a renderer consumer. Migrating the market cards onto widget.data (§4 数据源代理) is a whole-card architectural decision (fx + stocks + ticker together), not a per-card change — a single-card routing would leave a half-migrated, inconsistent data path. The scheduling engine is implemented GUI-side (desktop-web task-run engine + BoardPage 30s poll runs data.task.schedule; a manual run uses the same executor, not a setTimeout).

TUI /trace + /tree

/tree (structural) and /trace (time/cost projection) both landed in the TUI — tree-selector.ts TreeProjection = "tree"|"trace", /trace slash command (builtin-session.tsshowTraceSelector). Data = tree-selector’s SessionEntry tree + live AssistantMessage.usage/.duration/.ttft (no new data dependency). GUI-side message-tree seam (message-tree.ts buildMessageTree) stays the forward-compatible path. Plan: docs/tui-trace-plan.md.

musepi ps CLI

cli-commands.ts registers pscommands/ps.ts (runPsCommand): actions list|info|logs|stop|kill|restart; flags -a/--all, -j/--json, --plain, --dir, --global, -f/--follow, --head, -n/--lines, --grep, --timeout. Inspects/controls daemon-broker supervised processes from outside the harness (machine-global --global scope, e.g. browser-relay).

Telemetry metric rename → pi.musepi.agent.*

telemetry-export.ts OTel metrics/attributes namespace pi.musepi.agent.* (counters chat.cost.estimated_usd, runs, steps, chat.calls, tool.calls, errors; histograms chat.duration, tool.duration; plus the pi.musepi.agent.run.completed event). Chat-token recording still tags pi.gen_ai.agent.id/pi.gen_ai.agent.name. Verified by test/otel-signals-probe.ts.

win32 frosted glass fix (2026-08-26)

gui-base.css: html:root, html:root body { background: transparent } (the overlooked layer carrying opaque var(--bg)); [data-platform="win32"] .gui-main, [data-platform="linux"] .gui-main { backdrop-filter:none } (blur from the window material); [data-platform="win32"][data-theme="light"] scrim 22–58%.

dsh-desktop compat chain (runtime serves renderer + host-mode, 2026-08-27)

The dsh-desktop parity target is a shell that wraps the runtime’s served renderer rather than bundling Electron-coupled UI. MusePi’s three halves:

Gotchas: server.port is typed number | undefined under tsgo (const port = server.port ?? options.port); the host view is v1-minimal (recap/approval-request/ask-request events ignored, sendUiResponse no-op, subagent chat/kill route agents.* rpc); desktop-web must never import @musepi/gui (layering: collab-proto ← desktop-web ← gui), so the host client lives in desktop-web and the GUI supplies its own slot-host where needed.

Frame overlay + host rich interactions (2026-08-27): the compat shell signals the renderer with ?shell=1desktop-web/src/lib/compat-shell.ts isCompatShell() matches the exact value "1"; the page then adds .sh-app--compat (padding-top:48px) + a .compat-titlebar (48px fixed drag strip, -webkit-app-region: drag), and Electron loads compatUrl + "?shell=1" with the existing titleBarStyle:"hidden" + titleBarOverlay:{height:48} (win32/linux). Inert in a plain browser (no OS controls). The host view now wires the rich interactions: HostClient maps ask-requestuiRequest (Composer renders it, sendUiResponsesession.askAnswer via a #askReqIds bridge between the daemon string id and the composer’s numeric reqId), approval-requestapprovalRequest (ApprovalCard in components/shell/ApprovalCard.tsx renders approve/deny → tool.approve/tool.deny), recap→notice. GuestSnapshot/SessionClient gained approvalRequest + respondApproval; the collab guest keeps both null/no-op.

Compat slot host(serve 注入,2026-08-28):desktop-web 保持被动渲染器——musepi serve?shell=1(Electron compat)请求根路径时,把 compatSlotHostScript() 注入 </head> 前:脚本开自己的 daemon WS(读 /__daemon.json),调 extensions.list,过滤 slot:"transcript.node" 组件,blob-import 已编译 ESM(react 绑定 window.MusePiReact,desktop-web 入口同 GUI 一样暴露),然后 window.MusePiCompatHost.register(slot, entryKinds, Component, extensionId)。Transcript 在未注入 renderTranscriptNode 时(独立页/ compat 页)回退查该注册表按 kind 分派——纯浏览器 guest 无注册表,内建渲染不受影响。注入缝在 static-web.ts;seam 是 Transcript 条目行的 data-entry-kind/data-entry-id(被动的 DOM 锚,React 树不改)。

desktop-shell 扩展 + Shell 三模式(2026-08-28 增补):Electron 壳是一等扩展(kind:"desktop-shell", id desktop-shell:shell, builtin-registry.ts)——extensions.list 顶层 shell: { enabled, mode, webUrl }shell.enabled/shell.mode 设置键驱动,webUrl 来自 daemon --web-port);extensions.setEnabled("desktop-shell:shell", { enabled, mode }) 切换开关与模式并管理 web.port 发现文件(壳 probeWeb() 读它 loadURL compat or 本地 bundle)。注入脚本按 registry.shell.mode 选 slot 集合:compatibility=transcript.node;extended/enhanced 加 composer.dock/panel.tab.workbench/statusbar——desktop-web 的 CompatSlotHostlib/compat-slot-host.tsx,memo 化)按 slot 渲染,App 挂 composer.dock(composer 上方)/statusbar(底部)/workbench 面板(HeaderBar GuestPanel)。桌面 host 视图标 sh-app--host

19. GUI absorption round (2026-08-29): git graph / status cards / reward overlay / maximize layering

20. Windows NSIS shortcut persistence (2026-08-30)

Symptom: after an OTA update the desktop shortcut was missing. Root cause (two layers):

  1. electron-builder KeepShortcuts retention — first install writes KeepShortcuts=true to HKCU\Software\<APP_GUID> (GUID = UUID v5 of appId, multiUser.nsh INSTALL_REGISTRY_KEY). On subsequent installs installSection.nsh reads it: with KeepShortcuts=true + exe present → $keepShortcuts=trueaddDesktopLink/addStartMenuLink take the retention branch (only rename old→new when paths differ; no recreation when oldLink == newLink). Desktop shortcut deleted by user/cleanup tool → never recreated by any later update.
  2. createDesktopShortcut:"always" doesn’t help updates: it defines RECREATE_DESKTOP_SHORTCUT (NsisTarget.js), but that branch is gated by ${ifNot} ${isUpdated} — electron-updater spawns the installer with --updated (NsisUpdater.js args ["--updated","/S",...]), so updates skip desktop recreation even with "always".

Fix (open-design custom-NSIS parity): packages/gui/release/ensure-shortcuts.nsh defines the customInstall macro — installSection.nsh invokes it after file install completes (!ifmacrodef customInstall). It unconditionally CreateShortCuts desktop + start-menu shortcuts (bypassing keepShortcuts/isUpdated gating) and notifies the shell. Wired via nsis.include in packages/gui/package.json.

Verified (real installer, 0.4.7):

21. Task center hardening (2026-09-03): cron contracts, timezone semantics, run history

The 任务中心 page (gui/src/components/ScheduledTasksPage.tsx + TaskCenterViews.tsx) is the only GUI client of the daemon cron store (~/.musepi/crons.json + crons.runs.json; scheduler in coding-agent/src/daemon/server.ts, merge/validate/next-run in daemon/crons.ts).

cron.* RPC contract

crons.changed broadcast (new)

After every cron mutation and run start/finish the daemon broadcasts { type: "crons.changed", at } on the events.subscribe stream (same seq mechanism as extensions.changed; payload is a timestamp only — clients re-pull cron.list). Consumers: ScheduledTasksPage (instant refresh) and the app-level run-completion notifications. Both KEEP polling (30s page / 20s app) as fallback; collab guests receive no broadcasts and stay poll-only.

Scheduling semantics (crons.ts)

Pitfalls