MusePi GUI Implementation Notes (Contracts & Pitfalls)
| English | 中文 |
Status: living document (established 2026-08-06, split out of
gui-design.md) — the factual record of thepackages/gui/packages/desktop-webimplementation: 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:
- daemon side (
daemon/server.ts):agentSession.subscribereceivesagent_end→#scheduleIdleRecap(readssettings.getGroup("recap"), skips on isCompacting/editorDraft, timer =idleSecondsclamped to 1–3600); when the timer fires →#runIdleRecap: re-checksisStreaming/isCompacting/editorDraft/session alive/non-empty entries →runEphemeralTurn(side-channel,recap-user.mdprompt) →previewLinetruncation at 280 → push{ kind: "recap", seq, payload: { text, at } }. Cancellation set = the TUI-aligned activity set:agent_start/turn_start/message_start/tool_execution_start/auto_compaction_startcancel;session.send(touch) andsession.setDraft(draft:true)cancel; passive frames such as notice/streaming update/retry do NOT cancel — otherwise a notice immediately after agent_end would kill the just-scheduled recap (hit in practice; guaranteed in goal mode). dispose cancels too. goal/todo anchors share the TUI source:getGoalModeState().goal.objective(fallback: session title) +nextActionableTask(getTodoPhases())(the same function as tools/todo; ModeSessionLike type aligned with TodoPhase). The daemon-side equivalent of the editor-draft guard: GUI composer drafts are reported through the new RPCsession.setDraft { sessionId, draft }(Composer.tsx:truedebounced 300ms,falseimmediate, false flushed on unmount); clearing the draft does not reschedule (waits for the next agent_end, TUI parity). Zero cost for historical sessions:session.resumeis snapshot-only (no activate) → no AgentSession/subscribe → no scheduling, no tokens burned; the recap lifecycle only begins whensession.sendtriggers activate. - Protocol: the
@musepi/sdkSessionStreamEventunion gains{ kind: "recap"; payload: { text; at } }(envelope TypeBox kind Literal added in sync). - GUI:
session-store.applyhandleskind === "recap"→ state.recap; any subsequent wire AgentEvent clears it;ChatViewrenders.gui-recap-rowoutside the scroll container, next to JumpToBottomButton (※+ text, fixed, does not scroll with content = TUI status-line semantics). Settings: an “Idle recap” section at the bottom of the “Notifications & sound” tab (rpc readssettings.getrecap.enabled/idleSeconds, writessettings.set, daemon flushes automatically; unlike renderer-local notification preferences, these two live in config.yml).Composerreports unsent drafts viasession.setDraft(daemon-side editor-draft guard). - Verification: connect directly to the daemon over ws RPC (idleSeconds=1) → send → recap envelope arrives 1s after agent_end; with draft=true there is no recap after agent_end and clearing does not reschedule; resuming a historical session gives 5s of zero events (nothing burned), and recap resumes normally only after send continues; GUI sends a message via CDP →
.gui-recap-rowappears, next message → disappears.
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:
- Session level (one
AgentPauseGateper session; the agent loop polls at model-call/tool-call boundaries):session.pause{sessionId}→{ engaged, paused, pausedAt }(returnsengaged:falsewhen already paused)session.pauseStatus{sessionId}→{ paused, pausedAt }(can activate archived sessions)session.pauseRelease{sessionId}→{ duration, paused }(duration = now − pausedAt)
- Global level (process-level
agentPauseGate, same semantics as TUI/pause, spanning all sessions):daemon.pause/daemon.pauseStatus/daemon.pauseRelease(same return shapes)
- Subscription stream: session subscriptions receive a
{ kind: "pause-state", payload: { paused, pausedAt } }envelope (server.ts:1644, driven by gate.onChange). - Persistence (key to TUI/GUI alignment): pause is host-layer state, not part of the agent event stream. The daemon mirrors every gate transition into a per-session sidecar
<journal>/<sessionId>.pause.json(exists = paused,{paused:true,pausedAt}; unlinked onrelease/paused:false= reversible);resumeSession(activation path for archived >30min sessions or after daemon restart) reads the sidecar to rehydrate the gate (AgentPauseGateconstructed with{paused,pausedAt}, pausedAt preserved as-is so duration stays correct).deleteSessionalso unlinks, preventing a same-named recreated session from resurrecting as paused. Global pause is process memory state and is lost on daemon restart (by design, not persisted). - wire:
SessionStategains optionalpaused/pausedAt(injected in both live snapshots and archived snapshots, server.ts:2056-2057);session.listrows also carry apausedfield. - Heartbeat companion: the daemon adds a
system.pingRPC (no args →{pong:true}) for WS client keepalive. - Verification: direct ws RPC → create → send (writes SDK files to disk; empty sessions have no files and cannot activate) → pause → assert the sidecar exists → SIGKILL the daemon and restart → pauseStatus still returns
{paused:true, same pausedAt}→ release → sidecar gone.
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:
- renderer keepalive (
packages/gui/src/lib/rpc.ts): 15s request timeout (REQUEST_TIMEOUT_MS) prevents hanging forever;system.pingevery 20s (KEEPALIVE_INTERVAL_MS); 45s (KEEPALIVE_DEAD_MS) without any response →ws.close()forcibly triggers reconnect (browser WS has no ping capability; this is the standard substitute). - Clean recovery path (
app.tsx):onStatus("closed")→recoverFromDrop= close old client (stop backoff retries) →setBooting(true)shows splash →boot()performs a brand-new reconnect (probe/spawn/events.subscribe/pauseStatus/settings full initialization) — the same path as the “Reconnect” button (ce1c9284d verified; the original automatic in-place restore froze the renderer).recoveringRefguards against re-entry. - Active wake-up:
electron/main.cjspowerMonitor.on("resume")(registered after app ready, electron#32576) → each window receivesapp-power-resume→ renderer triggers recovery upon receipt (visibilitychange/online are not guaranteed to fire on macOS wake). - Fallback: an App-level
ErrorBoundary(components/ErrorBoundary.tsx) renders a “Reconnect” page on crash instead of silently unmounting the root tree without bound (freeze signature: idle CPU, live console, dead clicks). - Server heartbeat (
ws-transport.ts): PING every 15s; destroys the connection after 3 missed PONG timeouts (clears dead connections’ subscriptions/buffers;teardownclears the interval synchronously). - Verification: real Electron + CDP, kill the daemon to simulate a drop → UI recovers interactive, no error bar, renderer log contains the recovery path; pause state consistent after reconnect.
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.
- RPC
models.discover { baseUrl, api, apiKey?, provider? }→{ models: [{ id, name }] }(handle branch in daemonserver.ts, next tomodels.listCustom; dynamic import ofdiscoverDraftModelsfromconfig/model-discovery— per AGENTS.md’s inline-import ban, dynamic import at the daemon layer is an established pattern). - Protocol gate: only
openai-completions/openai-responsescan be queried;anthropic-messages/google-generative-aithrow “protocol "X" has no model listing this build can read; enter this provider’s models by hand”. Rationale: OpenAI-compatible endpoints (official + gateways + self-hosted) share theGET {base}/modelsshape; other protocols have no unified listing. - Implementation:
discoverDraftModels(request, fetchImpl?)builds aDiscoveryProviderConfig(discovery.type =openai-models-list) + aDiscoveryContext(fetch injectable for tests, defaulting to globalThis;getBearerApiKeyResolverreturns the draft apiKey string — for this query only, never persisted), reusing the existingdiscoverOpenAIModelsList(same baseUrl/v1normalization, Bearer auth, bundled-reference name enrichment, timeout). - name semantics: ids that hit the bundled catalog are enriched to canonical names (
gpt-4→ “GPT-4”); misses keep the bare id; endpoint-reported name fields are not adopted (existing discoverOpenAIModelsList behavior). - Errors: HTTP 4xx/5xx throws
HTTP <code> from <url>; an endpoint with no models returns an empty array (GUI shows “This endpoint returned no available models”). - GUI: in the “Custom providers” tab click “Add custom provider” → config dialog (DialogFrame
gui-dialog--settings; the old add-tab was removed; user feedback: adding a custom provider deserves a properly designed dialog) — inside it, the “Fetch available models” button (disabled without baseUrl) → a candidate DialogFrame nested inside the config dialog (both portal to body without conflicting; checkboxes + select-all/none + add selected) →adoptedmerges into the models array ofmodels.add; validation enforces “at least one model” (manual single entry or adopted list). On successful save: dialog closes + brief “Provider added” feedback at the add button’s original position in the “Custom providers” tab (addedNamestate, cleared after 2.5s). Onboarding (OnboardingOverlayProviderSetup)’s custom form gets the same “Fetch available models” + candidate dialog, saving manual model-id typing;EMPTY_FORMwas extracted as a module-level constant shared by both forms for reset.
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:
extensions.list(10s TTL cache, reuses state-manager’s loadAllExtensions, returns a unified Extension shape across 10 kinds, no raw — raw goes throughextensions.raw{id}truncated at 16KB)extensions.setEnabled{id,enabled}writessettings.disabledExtensions(TUI same key, samekind:nameid;mcp:prefix goes through the mcp.json canonical denylist + legacy reconciliation; flush + cache invalidation)extensions.setProviderEnabled{providerId}uses enableProvider/disableProvider (native rejected; the capability layer only calls settings.set without flush, the daemon adds the flush)skills.setEnabledwas deleted (migrated to extensions.setEnabled);skills.list/read/deleteremain
| 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)
- GitHub OAuth token =
github.authStatus(spawnsgh auth statustext +gh api userto parse login/email — do not usegh auth status --json, field names change across gh versions) + auth card (avatar letter/username/email/”Authenticated via gh CLI”/disable — logout passes--yesto avoid interactive blocking). - gh resolution: the
ghPath()helper — PATH first, then probing/opt/homebrew/bin/gh,/usr/local/bin/gh(darwin) and%ProgramFiles%\GitHub CLI\gh.exe(win32) — daemons launched from GUI/launchd often lack/opt/homebrew/binon PATH;Bun.which("gh")alone would falsely report not installed. All 4 gh spawn sites (prs/authStatus/authPoll/authLogout) go through ghPath(). - Device Flow card (openchamber-native feel): “Authorize MusePi” title + hint + large monospace code block (24px/800/3px letter-spacing) + copy button (clipboard, 1.5s “Copied” feedback) + “Open GitHub” primary button (external-link icon) + “Cancel” link + waiting row (hand-drawn spinner + “Waiting for approval… (auto-refresh)”). CSS in gui.css
.gui-github-flow*. - GUI error mapping: a failed
github.authStatusRPC (e.g. an older daemon predating this RPC) ≠ gh not installed — catch keeps the detail, and theinstalled:falsebranch shows({detail})to distinguish “daemon too old” from “gh genuinely absent”. Pitfall: with an expired gh keyring token,gh auth statushangs on network verification (10s kill cap → detail is an empty string) — the GUI falls back to the “not authenticated” copy via||; poor network (API EOF) hangs gh the same way — users should first self-check with a terminalgh auth status. Device-flow network error classification (2026-08-06, verified against a broken proxy):classifyNetworkError— TLS/certificate/handshake = fatal (friendly message “Cannot verify TLS certificate — check your proxy/VPN”, polling stops); everything else (EOF/ECONNREFUSED/ETIMEDOUT/ENOTFOUND/Unable to connect etc.) = transient — authPoll returns{pending, interval+5}and keeps polling (per the GitHub device-flow spec). - gh auth storage model (2026-08-06, rewritten openchamber-style): no more
gh auth login --with-token— analyzing the openchamber source revealed its success recipe: store the device-flow token in our own config (not via gh) and tolerateapi.github.comfailures (only 401/403 invalidate) — whereas gh login’s token verification hits api.github.com, which on poorly connected machines (user env: github.com reachable, api.github.com EOF) made the entire auth flow fail. musepi now: after authPoll succeeds the token is written toagentDir/github-token.json(0600, includes login/email cache) — gh spawns like github.prs inject theGH_TOKENenv (priority above the keyring); authStatus prefers the daemon token (authenticated means true; cached identity + detail hint when the api is unreachable); authLogout deletes the token file + best-effort gh logout. The keyring (manual gh CLI login) remains as fallback. Verification: mock gh confirmed GH_TOKEN injection (api user fails without token / succeeds with it) and status falling back after logout. v2 (2026-08-06 evening, user reported Git tab loading forever): authStatus’s daemon-token branch no longer synchronously waits ongh api user— it returns stored login/email +avatarUrl: https://github.com/<login>.pngdirectly (avatar derived from login, zero network — api.github.com unreachable but github.com reachable, already proven by device flow); identity refresh became background fire-and-write (gh api user success → writeGhToken updates the login/email cache). Measured: with mock gh api user hanging 5s, authStatus returned in 299ms; the token file login updated itself after 6s; the keyring fallback branch carries avatarUrl too. GUI avatar =<img className="gui-github-avatar-img">(34px round, object-cover) + onError → letter fallback (avatarFailedstate, reset when avatarUrl changes). - GitHub avatar synced to chat user avatar (2026-08-06): on success the Git settings page’s
refreshAuthwritesauth.avatarUrlintolocalStorage["musepi-gui-user-avatar"](removed when absent — logout auto-clears); ChatView’sUserAvatarreads the key synchronously (one instance per message — per-instance RPC would fan out explosively, localStorage costs nothing), rendering.gui-user-avatar-img(20px round object-cover, container percentage sizing, media query follows at 28px), onError → initial fallback, failure state resets when avatarUrl changes. E2E verification: isolated daemon (mock gh + token) → Git tab writeshttps://github.com/MuseLinn.png→ open a historical session (user message) → transcript user bubble img renders and actually loads (imgLoaded true). Testing lessons: a fake SDK session file colliding on the same id with asession.createlive session — clicking the row opens the empty live (welcome state); fake files for historical sessions must be written before daemon startup so clicks take the reactivate path (snapshotFromJsonl) and actually have messages. On connection failure#retry()must reject all pending requests and clear them — otherwise requests issued during a reload/daemon-restart window hang forever (responses from the old ws never come, and new-connection response ids don’t match), leaving the Git tab’s authLoading stuck true (one source of the user’s “loading takes forever”). Measured: after killing the daemon, the Git tab shows a(not connected)error within 3s instead of spinning forever. GUI Git tab loading behavior:authLoading && !authshows a “loading” row, and simultaneously the “Add account” button also renders (!auth?.authenticatedholds for null) — an expected combination, not a bug. - RPC reconnect-window deadlock fix (rpc.ts, 2026-08-06): on connection failure
#retry()rejects all pending requests and clears them — otherwise requests issued during a reload/daemon-restart window hang forever (responses from the old ws never come, and new-connection response ids don’t match), leaving the Git tab’s authLoading stuck true (one source of the user’s “loading takes forever”). Measured: after killing the daemon, the Git tab shows a(not connected)error within 3s instead of spinning forever. GUI Git tab loading behavior:authLoading && !authshows a “loading” row, and simultaneously the “Add account” button also renders (!auth?.authenticatedholds for null) — an expected combination, not a bug. - Provider login card (SettingsView provider tab, 2026-08-06): the
loginStatepanel was upgraded from a bare version (URL link + text) to a native card identical to device flow (.gui-github-flowstyle family) — title + “Open login page” primary button (external-link) + cancel link + “Waiting for sign-in…” spinner (when not waitingInput) + paste code/URL input (when waitingInput). Render path is isomorphic to the git device-flow card (event-driven provider-login → loginState). - Add account = GitHub OAuth Device Flow (client_id
178c6fc778ccc68e1d6a, the gh CLI public client;github.authStartPOST /login/device/code → GUI shows the user_code + opens verification_uri + pollsgithub.authPollat interval (pending/slow_down keep polling; once the access_token arrives,gh auth login --with-tokenimports it into the gh keyring via stdin — all gh RPCs take effect immediately)). Note gh config lives in$HOME/.config/gh(token in the macOS Keychain) — a daemon with an isolated HOME cannot see the user’s gh auth (tests combine mock gh + GH_CONFIG_DIR). - Identities = commit identity list (localStorage
musepi-gui-git-identities+musepi-gui-git-default-identity, two-step prompt creation, default badge, confirm-on-delete) — consumed by git.commit (-cinjection). - Workspace changes panel (openchamber GitView/ChangesPanel parity): three groups (staged/unstaged/untracked + optional ignored), per-row hover stage/unstage (
git.stage=git add -- <paths>;git.unstage=git restore --staged --with fallbackgit reset HEAD --), header with flat/tree view toggle + Gitignored toggle (git.status {ignored}→--ignoredflag parsing!!lines) + commit button. - Project notes panel (ContextPanel NotesPane, 2026-08-06 openchamber alignment): quick notes (daemon notes.get/set, 3000-char cap +
{n}/3000counter, 400ms debounce autosave + blur flushes immediately) + todo list (localStoragemusepi-gui-todos:<cwd>; openchamber semantics: header “{count} todos”, 120 chars per item, additions insert before the first completed item, checked items move to the end (completed-last), clear-completed button (disabled at 0 completed), delete ✕) + plan files (daemonplans.list/get/save/delete, stored atagentDir/plans/<cwdHash>/<timestamp>-<slug>.md, format# Title\n\nBody— title parsed from the leading# headingline; GUI: “Plans ({count} files)” header ++new (two-step prompt: title+body) + list (title+date+delete) + open viewer (monospace pre)). Storage design: unlike openchamber’s~/.config/openchamber/projects/<id>.json— musepi uses agentDir files (same style as notes, never touching user projects). Not done (documented): dnd drag reorder, sending todos into a session, file import, chat-message “Save as plan” button, CodeMirror PlanView. - prompt dialog fix (prompt-dialog.tsx, 2026-08-06): the confirm button on the prompt branch used to
finish(true)→ returning null (equal to cancel) — prompt could only be confirmed via Enter, clicking confirm did nothing — changed tostate.kind === "prompt" ? finish(value.trim()) : finish(true). bun build CSS pitfall: bun’s CSS parser does not support bare@layerstatements (@layer a, b, c;throws Unexpected token with the error position drifting onto a*inside comments, misleading debugging) — the block form@layer a {} @layer b {}works (semantically equivalent, declares layer order). base.css’s layer-order declaration was replaced with four empty blocks. - File preview & editor interaction (FilePane.tsx, 2026-08-06): the file tree supports directory collapse (clicking a directory row toggles, caret rotates), click-to-preview (embedded right pane), and a context menu (open preview / open in app / copy path / refresh, reusing the ContextMenu component). Preview categories: read via
fs.readBytes(base64+mime+size, 8MiB default cap / 32MiB hard cap) → text (TEXT_EXT + no NUL in the first 4KB,<pre>monospace scroll) / image (Blob URL +<img>) / PDF rendered inline via pdf.js (pdfjs-dist@6, page-by-page canvas → data URL; render failure (encrypted/corrupt) falls back toopenWith("", path)system default app) / other binaries opened with the system handler. pdf.js worker: bun build does not support?url/?rawquery imports (bare and relative paths both fail) — the build script doescp node_modules/pdfjs-dist/build/pdf.worker.min.mjs dist/pdf.worker.min.mjs, withGlobalWorkerOptions.workerSrc = new URL("pdf.worker.min.mjs", location.href)(file:// same-origin worker works fine). Pitfall: workspace.tree’s entry.path is relative to the session cwd, while fs.readBytes resolves against the daemon cwd — FilePane must explicitly join${cwd}/${entry.path}(tests only passed by luck because both matched); copy path likewise uses the absolute path (VS Code semantics). Preview blob URLs are revoked when the preview changes to avoid leaks. - Embedded browser (ContextPanel BrowserPane, 2026-08-06): Electron
<webview>(main.cjswebviewTag: true+web-contents-createdpopup interception — target=_blank loads in place, http/https only; partitionpersist:omp-browser; webpreferencescontextIsolation=yes, sandbox=yes), iframe fallback for the web build (isElectron detection). Features: address bar (adds http:// when scheme missing), back/forward (URL history stack + disabled states), refresh, open in browser, quick ports, viewport size presets (adaptive / phone 393×852 / tablet 768×1024 / desktop 1440×900 — container width control, centered), element picker (bitfun/openchamber parity): injectsBROWSER_INSPECT_SCRIPT(Promise.withResolvers, executed cross-origin viawebview.executeJavaScript(script, true)) — hover highlight + label tooltip + click captures {tag, text≤500, selector (CSS path ≤6 levels), outerHTML≤2000} →window.dispatchEvent("musepi-gui-insert-text")→ Composer listener appends “Web element: text + selector: selector" to the draft. The webview's did-finish-load uses ref addEventListener (React webview JSX types carry no event props; Electron global types have WebViewHTMLAttributes). **Three reference implementations**: bitfun = Tauri native webview eval injection + context pill (`#element:` token); openchamber = Electron webview + screenshot-highlight attachment (capturePage); craft-agents = BrowserView+CDP (Runtime.evaluate, agent-driven ref clicks, no user-pick UI) — musepi adopts bitfun/openchamber's user-pick mode. **Not done**: screenshot attachment (capturePage + highlight rect, needs a main IPC — element metadata already carries text/outerHTML, sufficient for agents). **Testing lessons**: CDP `dispatchKeyEvent` Enter without text does not trigger a form's implicit submission — use `form.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}))`; React controlled input/select need the native setter + input/change events; the webview is a separate CDP target (send Input.dispatchMouseEvent against it to simulate page clicks). - Commit dialog: textarea (⌘⏎ submits) + 19 built-in gitmoji (a carloscuesta/gitmoji subset, usable offline — openchamber fetches remote JSON with a 7-day cache; musepi chose built-in) + identity (
git.commituses-c user.name/emailinjection, never writing repo config — openchamber writes local config; musepi desktop settings must not silently mutate the user’s repo; identities come from the Git tab’s default identity in settings). - Tree view: grouped by the first path segment (simplified
changesTree), directory rows collapsible. - daemon cwd semantics (2026-08-06 fix): the git RPCs originally operated on
#host.cwd()= the daemon start directory (not the session cwd) — the git view showed the daemon’s repo, and switching project sessions didn’t change it (pre-existing design)— now fixed: the 5 git RPCs (log/diff/status/stage/unstage/commit) acceptparams.cwd, preferringpath.resolve(params.cwd)and falling back to#host.cwd()when empty; the GUI’s GitLogPane/DiffPane passsnap.state.cwd(authoritative session cwd, same source as FilePane/NotesPane), and the load useCallback deps gainedcwd. E2E verification (isolated daemon cwd=/tmp/git3 + /tmp/gitrepo): no cwd →not a git repository(no longer misreads the daemon repo); status shows a.txt M / c.txt ??;ignored:true→ x.log; stage a.txt+c.txt → staged 2; commit-c user.name/email→ identity injected successfully and repo config untouched (git config user.name still Test User); unstage/diff/log fine; zero daemon-cwd leakage. tsgo phantom-error lesson: tsgo’s incremental cache may type-check stale sources (a 354-line"completed"reported as"done"with no overlap) — when facing impossible errors, firstrm -rf node_modules/.cache/tsgoand rerun. - Git preferences (settings Git tab): changes-view radio / gitmoji toggle / show-Gitignored toggle — localStorage
musepi-gui-git-view/musepi-gui-gitmoji/musepi-gui-git-show-ignored, sharing keys with DiffPane.
4. Session settings & cleanup (algorithms)
- Session defaults group = default model (
ModelSelectorwithout sessionId →models.listAvailable, writesmusepi-gui-default-model— WelcomeComposer reads the same key) + default thinking level (segmented,musepi-gui-default-thinking) + auto title (musepi-gui-autotitle) + show delete dialog (musepi-gui-confirm-delete). - Capsule merge, pure UI (ModelThinkingCapsule): the two selectors on the composer/welcome merge into one capsule button — left segment model (brand icon + name), right segment thinking (brain + level), clicking either pops its original menu (search/favorites/pins / level ladder). Zero new RPC: reuses the original chains of
models.list(available)/session.setModel/settings.get(modelRoles)/getSupportedEfforts; the capsule merely regroups trigger buttons + container-query shrinkage (@containerbased on.gui-composer-frameinline-size, text fades to icons-only over 180ms, instant switch undergui-motion-off); anchoring still goes throughuseFloatingMenu(two-phase + module-level mutex, only one menu open at a time). - Session retention group = enable auto-clean (
musepi-gui-autoclean) + retention stepper 1–365 days (musepi-gui-autoclean-days, default 30; when disabled the row getsgui-settings-row--disabledwith reduced opacity + pointer-events:none) + expiry action archive/delete (musepi-gui-autoclean-action) + manual clean (gui-btn+ “Currently cleanable: {0}”). - Action semantics: archive =
session.close(live session becomes a daemon snapshot; close errors for SDK-file sessions are swallowed — the daemon deliberately never touches workspace files); delete =session.delete(permanent since 2026-08-11: journal file + materialized.db materialized rows + the SDK transcript main file (<sessionsDir>/<project>/<timestamp>_<sid>.jsonl) + the identically-named artifacts directory — before the fix only the journal/db rows were deleted, leaving jsonl residue that madelistAllSessions()’s file scan re-list them and the history list “resurrect” deleted sessions). Known semantics:knownSessions’slistAllSessions()has a 10s TTL cache (#historyCache) — after deletion the list may lag up to 10s;session.listmay likewise show “deleted but cache-not-expired” sessions — not a bug. Implementation pitfall: deleting transcripts usesBun.Glob.scanSync(), which returns relative paths — you mustpath.join(sessionsRoot, f)before unlink/rm — bare relative paths resolve against the daemon cwd → ENOENT throws mid-loop. - Delete confirmation centralized in
deleteSession(useConfirm()dialog, closable via Escape/backdrop/cancel/confirm; GuiHeader and SessionSidebar no longer pop their own — avoiding double dialogs; toggle off = delete straight away).
5. Pet implementation details (dual windows: pet.html + bubble.html, updated 2026-08-11)
- Petdex marketplace embedded search/preview/install (2026-08-06): a persistent PetMarket (search box + result grid + install) at the bottom of the settings “Pet” section. Data-source reverse engineering: petdex.dev is a Next.js site that rejects all CORS (renderer fetch always fails) → everything goes through main-process IPC —
pet-search(net.fetchhttps://petdex.dev/api/pets/search?q=&limit=24&includeMeta=0, parameter set reverse-engineered from JS chunks: q/kinds/vibes/colors/batches/sort/cursor/limit/includeMeta; response pets[]: slug/displayName/description/spritesheetPath/zipUrl/soundUrl/featured/kind/vibes) +pet-install-url(download zip → reuse the importPetdexFromZip extraction path → return dataURL). Preview:<img src=remote spritesheet>is CORS*(assets.petdex.dev has ACAO headers — canvas-readable) → in PetMarketCard Image decode → measurePetdex → PetdexSprite animation; remote measurement is async (24 cards in parallel ~2-5s), spinner shown until meta is ready. Install: click button → download+extract → measure → savePetdex + auto-select (pickPet). Details: the main process uses net.fetch rather than global fetch (goes through system proxy); zipUrl validated against^https://assets\.petdex\.dev/; search debounced 350ms (clear fires immediately) + seq guard against out-of-order responses. Built-in bitfun removed at the same time (BUILTIN_PETDEX entry + public/pets/bitfun.webp, presets 10→9). Marketplace preview size (second fix, 2026-08-06): normalized PetdexSprite rendered ~97px wide, getting clipped by overflow inside the 64px thumb (looked like “zoomed into a part”) → each card computes a scale viafit = 56 / (frameW × 100/contentH)and passes it in (56 = thumb 64 − 8px padding), fully visible, no clipping (frame ratios differ per pack, so it’s computed per-pack — no fixed factor possible). Installed grid + Reveal:{expanded && …}conditional render became<Reveal open={expanded}>(useCollapse 240ms + 160ms fade, closed state aria-hidden+inert, node stays mounted — same motion language as other conditional sections in settings); the marketplace area stays persistent, never collapsing. Spinner naming pitfall:.gui-pet-market-card__loadingreferenced a nonexistent@keyframes gui-flow-spinner(it’s actually namedgui-flow-spin) — during the 2–5s remote-measurement window the ring sat static; fixed, it now spins. - Frame loop must skip empty columns (root cause of flicker, 2026-08-06): BitFun sheet rows = N valid frames + transparent filler columns — rest/waiting/working/analyzing rows have only 6 valid frames (cols 6-7 fully transparent), hover/dragging/error rows 8; panda-pix is the exception with all 8.
steps(7)actually renders the empty columns for 343ms per cycle — the pet vanished every 2.4s (“constantly flickering”). Correct formula = row’s valid frame count N:steps(N)+--gui-petdex-cycle-end: -(N×frame width)px(mathematically equivalent to BitFun’ssteps(6)+85.714%; the endpoint column only appears at t=100%, an invisible instant). Valid frame count sources: built-in pets usePETDEX_ROW_FRAMES_DEFAULT=[6,8,8,8,8,8,6,6,6](panda-pix explicitly all 8); imported packages scan alpha at import time viameasurePetdexRows()(<1% opaque counts as empty) stored intoPetdexPackage.rows. Old packages without rows → fall back to defaults. “Column md5 differs = 8 independent frames” was a wrong conclusion — empty frames’ md5 differs from other columns too. - Drag direction flip + hover freeze (2026-08-06): the dragging row’s frames are a fixed-direction walk cycle (BitFun original has no flip either — dragging right runs “backwards”) → pet-main.tsx tracks the incremental direction
ddx = e.clientX - s.lastX,flip = ddx > 0(frames natively run leftward, only mirroring rightward movement); the flip lives on a dedicated wrapper.pet-window__pet-flip--mirror { transform: scaleX(-1) }(transform-origin 50% 80%) — never on.pet-window__bump(animation overrides static transform, see below); resetDrag clears the flip. Jitter pulse fix (two rounds): ① per-frame incremental direction noise ±1-2px —setFlip(ddx > 0)made the pet thrash left-right mid-drag → switched to accumulated displacement + thresholddirAcc += ddx; flip only when |dirAcc| > 5px, then zero(DIR_FLIP_THRESHOLD_PX=5); ② the real root cause — the main process’spet-drag-clientincremental algorithm accumulated drift:abs = post-move window position + clientXwhilepetDragLaststored the pre-move position+clientX — every window move double-counted its own displacement, chasing ever faster → overshoot → pointer reversed direction relative to the window → clientX delta oscillated → flip flipped repeatedly (synthetic CDP tests can’t catch this: injected coordinates are window-relative, window movement doesn’t feed back into clientX; cliclick real-pointer testing exposed it). Fix: anchored drag —pet-drag-clientswitches toscreen.getCursorScreenPoint()(physical pointer): the first move anchors{cx, cy, wx, wy}, afterwardssetPosition(anchorWx + (pointerX - anchorCx), …)— window position derives solely from the physical pointer, zero feedback from the window’s own position, no drift, no oscillation;movePetWindowreuses throttled persistence. cliclick measurements: drag right 200px flips=1 + window tracks precisely; drag left flips=1 + mirror correctly removed. Edge case (original design): moving the pointer out of the window mid-drag breaks the event stream (pointerup lost → stale anchor); real usage tracks the window to the pointer so it won’t trigger. Hover frozen frame: the hover row’s frame cycle is walking content in most packs — playing frames on hover = “running with no directional context” → PetdexSprite gained afrozenprop (animation keeps only thegui-petdex-{mood}transform bounce, background-position parked at the hover row’s frame 0;--gui-petdex-cycle-endomitted), pet-main passesfrozen={displayMood === "hover"}. E2E (CDP synthetic mouse): drag right mirror ✓, drag left none ✓, release clears ✓, hover animation onlygui-petdex-hover✓. Drag state machine aligned with clawd-on-desk/openpets (2026-08-06): user reported “after the mouse lost focus, the window followed the mouse even without dragging” — root cause: pointerup swallowed leavespressedstuck true (up event lost when the window loses focus / pointer capture seized by the system), after which every hover move took the drag branch. Following those two projects’ state machines: ①onLostPointerCapture={resetDrag}(clawd: lostpointercapture → stopDrag); ② window blur → resetDrag (clawd blur backstop); ③ resetDrag made idempotent (clawd stopDrag guard: pressed&&dragging both false returns immediately, guarding against up/blur double-fire); ④ RAF-coalesced move IPC (clawd queueDragMove pattern: pointermove fires at 120Hz+, sending IPC per event queues up behind setPosition → window lags and chases noise; RAF coalesces frames, sending only the latest client point); ⑤ main process clears petDragLast on did-finish-load (openpets resetForNavigation: after renderer reload pressed is fresh; a stale anchor lets the first hover move drag the window). cliclick real-pointer verification: RAF drag tracks ✓; blur mid-drag → hover moves don’t move the window ✓; lostpointercapture mid-drag → stops ✓. Reference implementations: clawd-on-desk hit-renderer.js (five reset paths + RAF + dragLock), openpets pet-preload.cjs (document-level listeners + main-process snapshot + navigation backstop). input-mode pet placement (2026-08-06): the pet moved from ComposerFramefooterLeft(bottom-left inside the input box) to the.gui-composer-petslot (absolute within the frame,right:10px; bottom:calc(100% + 4px)= upper-right above the input box’s top edge, pointer-events:none + z-index:1) — welcome/session share the single ComposerFrame implementation (FLIP morph animates along); settings copy updated (“inside the input box” → “upper-right above the input box”). Pitfall: geometric verification ≠ visual visibility — BorderBeam injects CSS[data-beam] { overflow: hidden }which clipped the pet’s overflowing top edge (both welcome and session scenes have beams) — getBoundingClientRect ignores overflow, so pure geometry E2E went all green while the user saw nothing; fixed.gui-border-beam.gui-border-beam--pet { overflow: visible }((0,2,0) beats (0,1,0); safe because the beam decoration layer clips itself via clip-path). Verification requires screenshots (captureScreenshot + visual confirmation), never rects alone. - bump animation must preserve the base transform:
.pet-window__petcenters viatranslateX(-50%), and animation overrides the base transform — every keyframe ofgui-pet-stage-bumpmust includetranslateX(-50%), otherwise the pet jumps 48px sideways on every mood switch. - Mood-switch micro-bounce
gui-pet-bumpretriggers via classList (remove → reflow → add), never via key remount (that restarts the frame loop). - Click-through mechanism (darwin-only): the window defaults to
setIgnoreMouseEvents(true); main.cjs polls the cursor every 120ms (screen.getCursorScreenPointvs window position + union of renderer-reported hitboxes) and flips ignore; hover state is pushed by the main process via thepet:hoverIPC (under click-through the renderer’s pointerleave is unreliable); the renderer re-reports hitboxes on every layout change (bubble add/remove) (pet-set-hitbox, settingpetIgnoreState=nullto force a refresh next tick). - Three iron laws of dragging (established after the 2026-08-06 interaction review): ① never switch back to click-through mid-drag —
updatePetClickThroughskips the entire flip logic whilepetDragLast !== null. During fast drags the cursor momentarily escapes the hitbox; once ignore flips, pointer capture dies, pointerup is lost,petDragLastremains, and the next drag’s first frame jumps the window by the old delta. ② The anchor must be cleaned on every lost-pointer path — the renderer’sresetDrag(shared by pointerup and pointercancel) must callpet-drag-end; main-sidesetPetVisible(false)and windowclosedalso clear it. ③ Position persistence must be throttled — sync fs writes on every pointermove stall the main process; during drags ≤1 write per 150ms (petLastPosWrite+petPosDirty), withflushPetPos()atpet-drag-endguaranteeing the final position lands. - Hitbox union must include
.pet-bubble__dismiss: the × button sits attop:-7px; right:-7px, overhanging the bubble rectangle; unioning only.pet-bubbleleaves the ×’ outer 7px a click-through dead zone (clicks fall through to apps underneath); selectors:.pet-window__pet, .pet-bubble, .pet-bubble__dismiss. - Known trade-offs: 120ms polling = fast clicks within ≤120ms of entering the hitbox pass through (same interval as BitFun, acceptable); the rectangle union swallows clicks in the gap between bubble stack and pet; Windows/Linux don’t enable click-through (a fully interactive 320×290 window would block desktop clicks — darwin-only is deliberate; needs real testing for cross-platform).
- pet-pos.json validation: on load, if the persisted position doesn’t fully fit inside any display workArea, reject and revert to the default spot (macOS clamps out-of-bounds frames, decoupling storage from reality and jumping back to the old frame on click-through flips).
- Dual-window architecture (split 2026-08-06; window material switched to cross-platform transparency 2026-08-22): bubbles and the interaction panel moved out of the pet window into a dedicated bubble window (
bubble.html→src/bubble-main.tsx). Window size is fully content-driven —report()(RO observing.pet-bubbles/.pet-panel+ animationend re-report + 300ms delayed re-report) →bubble-set-sizeIPC →setBubbleSize→syncBubbleWindow(window setSize + anchoring). The window is a per-pixel transparent window on all platforms (transparent: true,hasShadow: false) — macOS under-window vibrancy was tried, but vibrancy paints the entire window rectangle as frosted glass (an exposed “base color” ring around the rounded panel), conflicting with “only the card itself has a surface”; now cards paint their own glass (semi-transparent tint + top highlight + hairline). The bubble window is the only one of all 6 BrowserWindows that reports size after rendering (others: pet/mini fixed, pin widget precomputed at creation, glow/main fullscreen) — all “content-driven window motion” problems concentrate here. - Anchoring math (
syncBubbleWindow, main.cjs): anchor to the character sprite (petRect, not the window — the 320 window is wider than the centered sprite; centering the window would hang it off-screen) →x = petCx - bubbleW/2,y = petCy - bubbleH - 6, workArea clamp;petWindow.on("move")→watchPetMovefollows during drags. - Collapsed↔expanded width+height dual-axis morph (2026-08-11, fixing “width jump”):
stackMorph = { from: {width,height} }; the morph effect transitions width+height together (320ms overshoot); switchStack captures the from rect, the render branch locks it inline, effect lifts → measures to → locks back → reflow → transitions. Key pitfall: lift measurement must useel.style.width = ""(clears inline back to CSSmax-content) —"auto"on block elements = fill the containing block (overriding max-content degenerates toW into the window width → width transition silently skipped); collapsed cards must not usewidth: min(240px, 70vw)(vw dependence plus content-sized windows creates shrink oscillation). - Panel entrance gating (width first, 2026-08-11, fixing “right half first, then everything”): the panel mounts with
opacity:0, waits for the window resize event (120ms backstop) → addspet-panel--in(320ms entrance,forwardsholds the final state — base rule already opacity 0). Root cause: the 316px panel rendered inside a window sized to the bubble stack (~140px); the window resize lags a frame via IPC — right half exposed first, left half popping in later; compounded by bakedtranslateX(-50%)in keyframes (absolute-centering residue) shifting the flow-positioned panel half-width left. The bubble version uses flat keyframes (pet-panel-in-flat, no horizontal displacement). General pattern: content-driven windows must play entrance animations only after window size stabilizes (resizeevent) — see gui-design.md §5c clawd-on-desk analysis. - report animated sizes (2026-08-11, fixing panel-open window flicker): report uses
offsetWidth/offsetHeight(layout box, excludes transform) for elements with CSS animations in progress —getBoundingClientRectincludes the scale(0.98) shrunk value; reporting mid-animation sizes the window small, then it jumps after animationend; detect viael.getAnimations().some(a => a.playState === "running"). - mount effect dead code (2026-08-11, flagged by biome noUnreachable):
return bridge?.onPetActivity?.(...)early-returned past the subsequentrequestPetState— subscription teardown uses theconst off = ...; void sideEffect(); return offpattern.
6. CSS anti-pattern checklist (every one bitten us)
- 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 — overridebackgrounditself directly. - calc length × percentage (
calc(28px * 100%)) → invalid, silently falls back to 0. - 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. - backdrop-filter first-frame flash → two-phase mount (opacity 0 on screen first, animation class added next frame).
- Animating at mount kills frost (measured 2026-08-06, same root as 4):
gui-menu-inwithtransform: 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--enterednext 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/--enteredclasses), 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. - Flex children lacking
min-width:0→ content blows out / inconsistent sizing; flex children withmargin-inline:autobeat stretch. - popup/floater clipped by ancestor overflow/transform → portal to body.
- rAF throttle latch not released inside the frame callback → subsequent events swallowed.
- 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). - 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 sidebarmx-2.5/mt-3/ml-autoand friends all silently computed to 0px (pill flush against the left edge, right button cluster hugging the pill,mt-3spacing 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. - 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 lastbun 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 (noteag-*/tr-*/tv-*/spinetc. 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: runwc -cbefore truncating big files; periodicallygit addimportant CSS (the index blob can rescue you). - 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 istransform: 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). getBoundingClientRectduring 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 usesoffsetWidth/offsetHeight(layout box) for animated elements; detectel.getAnimations().some(a => a.playState === "running").- Width-lock measurement with
width:"auto"overrides CSSmax-content(2026-08-11): when measuring morph target widths,style.width = "auto"on block elements = fill the containing block (overrides CSSwidth:max-content), degrading toW to container width → width transition silently skipped (height fine; visually “only shrinks height, not width,” then jumps). Must usestyle.width = ""(remove the inline declaration, restoring the CSS value). - 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 rootgui-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 === 1and outer computedborder-radius: 0,background: transparent.
7. macOS app icon handling (investigated + fixed 2026-08-06)
Three independent paths, completely different rules:
- Packaged Dock / Finder icon =
Contents/Resources/icon.icnsinside the bundle (electron-builder defaultbuildResources/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). - 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.icns1024 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). - 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)
- Launch an isolated instance:
electron . --remote-debugging-port=9223 --user-data-dir=/tmp/<name>(read-only connection to the same daemon, doesn’t disturb the user instance), driven via CDP (browsertoolcdp_urlconnection). - Layout/animation assertions: sample getBoundingClientRect/background-position/getAnimations frame traces; animation smoothness depends on window occlusion (background windows get rAF-throttled) — compare under identical conditions.
- Visual confirmation:
tab.screenshot({selector})+ vision-model check; rebuild withbun run build(desktop:rundoesn’t rebuild, eats stale dist). - Pet verification: settings page CDP attach asserting 10 cards + selection state + 14px icons; frame loops sampled via 100ms
backgroundPositionsampling enumerating visible columns (must ⊆ valid columns); real cursor via SwiftCGEvent(mouseMoved/leftMouseDown/leftMouseUp).post(.cghidEventTap)(AppleScriptset mouse positionerrors -2740 on macOS 26); occasional all-empty frames fromscreencapture -l <windowID>are capture artifacts — use-Rregion shots for flicker verification. - Bubble/panel frame-by-frame verification (2026-08-11 playbook): main window eval
window.electronAPI.setPetVisible(true)→petSetPanel(true)(creates the bubble window) → inside the bubble target rAF pushes{iw, panel.getBoundingClientRect()}samples into an array → main windowtoggleBubblePanel()triggers → read samples after 1.5s. Assert: panel mount frameopacity:0, animation only appears after the window resize (event), panel rect stays inside the window throughout (l ≥ -4 && r ≤ iw+4, tolerating tiny scale-animation overshoot); compressed printing of state changes (adjacent identical frames folded). Panel toggling goes throughpet-toggle-panel(emits thepet:panel-toggleevent) —pet-set-panelonly ensures the window exists, it doesn’t open the panel. - Note: CDP-attached instances occasionally drop React onClick delegation (buttons unresponsive; keyboard/direct fiber calls work) — judged an environment artifact; switch to keyboard-path verification, don’t treat as an app bug.
9. Platform adaptation (2026-08-11)
- Principle: implement cross-platform features per platform; don’t cut features/settings just because “currently only macOS implements it”; hide only what is hardware-level macOS-exclusive.
- haptic (the only truly macOS-exclusive): the Taptic Engine is macOS hardware; Windows/Linux have no equivalent application-layer API — the renderer’s
lib/haptic.tschecksshellPlatform() === "darwin"first (preload exposesplatform; empty string in web builds) before sending IPC; the settings “Notifications & sound” toggle row simply doesn’t render on non-darwin; darwin guard atop the main.cjs handler. Helper:electron/haptic-helper.m(clang-compiled resident stdin process, NSHapticFeedbackManager; the JXA bridge exposes no methods on the private NSTrackpadHapticFeedbackPerformer class — osascript approach failed 100%),build:hapticcompiled insidebun run build, shipped via asarUnpack, main.cjs lazily compiles when dev lacks the binary. - keep-awake (cross-platform): swapped
caffeinate -i(macOS binary) for ElectronpowerSaveBlocker.start("prevent-app-suspension")— macOS mapskIOPMAssertionTypePreventUserIdleSystemSleep(same as caffeinate -i), WindowsES_SYSTEM_REQUIRED, Linux ScreenSaver Inhibit; released automatically on process exit, no child processes to kill. - open-in-apps / open-with (cross-platform):
appNamenormalized to absolute paths (macOSopen -aaccepts both display names and paths); open-with launches per platform — darwinopen -a <path> <dir>/ defaultopen <dir>, win32 spawns the exe directly orexplorer.exe, linux spawns absolute paths orxdg-open. Discovery lists: darwin scans .app in /Applications; win32 probes common install dirs (Code/Cursor/Zed/JetBrains/notepad/wt); linuxwhichprobes (nautilus/dolphin/terminal emulators/code/cursor/zed/kate/gedit). Empty lists fall to the renderer’s existing “no apps” empty state. - vibrancy/glass (cross-platform): CSS frost (backdrop-filter + semi-transparent scrim +
--gui-glass-overlay) works on all platforms; the native under-window material is a macOS enhancement,setVibrancysilently no-ops elsewhere — the setting is kept.
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
electron/managed-browser.cjs:ManagedBrowserController— tab lifecycle (persist:omp-managed-browserpersistent partition, credentials survive restarts), navigation/loading state, activity ledger (redacted: no page text/Cookie/full script bodies), layout projection (renderer reports slot rect → × zoomFactor →view.setBounds), permissions deny-all,agentActivityevents (auto-summons the panel when the agent drives a hidden tab).- CDP bridge: loopback HTTP+WS, emulating Chrome
/json/version+ browser-levelTarget.*(relay bridge subset: setDiscoverTargets / setAutoAttach / attachToTarget / createTarget / closeTarget / getTargets). OnewebContents.debuggersession per tab, reused by multiple puppeteer connections. WS frame codec handwritten (nowsdependency): mask/unmask, fragmentation, ping/pong/close. - The bridge exposes only managed tabs (
TAB<n>/PAGE<n>target ids), never the GUI’s own windows; upgrade rejects requests bearing Origin (web pages cannot drive it). - Frontend
ManagedBrowserPane.tsx: placeholder div +useLayoutEffectprojection (ResizeObserver + MutationObserver for overlay lifecycles; streaming text doesn’t trigger IPC) + toolbar/tab strip (Agent badge)/activity row;ContextPanellistens foragentActivityand auto-switches to the browser tool. Non-Electron builds fall back to the old iframe pane (LegacyBrowserPane). - Wiring:
main.cjscallsmanagedBrowser.start(mainWindow)after whenReady;preload.cjsexposes themanagedBrowser*API; types inenv.d.ts.
Configuration (settings-schema + Settings → Tools → Grep & Browser)
browser.gui(default false): agent browser tool switches to the managed browser (connectedkind →browser.guiUrl, defaulthttp://127.0.0.1:9230); priority app.cdp_url/path > relay > cdpUrl > cmux > gui > headless.browser.policy.restrictToPublic(default false): public http/https only + DNS rebinding re-check (tools/browser/policy.ts, ported from Proma browser-policy; off by default — localhost is core functionality). Browser launch gains--deny-permission-prompts.
Pitfalls (verified)
- about:blank initial-state debugger wedge: calling
debugger.attachon 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. - 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. Page.captureScreenshottimes out on webContents.debugger: intercept it and usecapturePage()instead (same as Proma).- Tab-level attachedToTarget(page) events must carry message-level sessionId (scoped to the tab session), otherwise puppeteer’s
#targetsIdsForInitnever completes andconnect()waits forever.
Verification
- Unit tests:
browser-policy.test.ts(URL/private nets/DNS),browser-gui-kind.test.ts(kind priority, 19 assertions all pass). - E2E (
/tmp/managed-browser-e2e.cjs): isolated instanceelectron . --remote-debugging-port=9229 --user-data-dir=/tmp/...→ renderer IPC opens the panel → puppeteerconnect({browserURL: 9230})drives it (goto/evaluate/title) → project layout →fromSurface:falsescreenshot sampling confirms the page truly rendered (projected area #EEEEEE = example.com backdrop, outside it the GUI dark theme).
Boundary items landed (2026-08-12, all E2E verified)
- agentTabId separation: the bridge maintains a dedicated agent tab (
ManagedBrowser.ensureAgentTab, a browser-level command requested by the supervisor under gui kind viabrowser.target().createCDPSession()); the agent never touches user tabs, first open auto-creates the agent tab (openedByAgentbadge + auto-activated display). Supporting changes: the bridge advertises atype:"browser"browser target (attached:true, puppeteer’sCdpBrowser.target()depends on it) and supports attachToTarget onbrowsertargetIds (browser-kind sessions route to handleBrowserCommand); new tabs’ attach events fire only afterwhenDebuggerReady(already-connected clients can adopt newly created tabs too). - omp-file:// local preview protocol:
registerSchemesAsPrivileged(standard+secure+fetch+stream) +ses.protocol.handleserving local files by pathname. Canonical form must include a host:omp-file://localhost<absolute path>— an empty host gets normalized by Chromium intoomp-file://tmp/x(first segment absorbed into the host), leaving the handler without the absolute path. - Risk-disclosure gate: when the agent lane (CDP Page.navigate / Target.createTarget) navigates to file://, credential-bearing URLs, or exotic schemes, ask the renderer first (
managed-browser:confirm+confirm-result, 30s timeout auto-denies, one question at a time); http/https/omp-file pass straight through. On denial CDP repliesNavigation blocked by the user, and the agent sees the failure immediately. - Stop button: ledger states dispatched→completed/failed/canceled;
managed-browser:stop→webContents.stop()+ closing the agent tab (the Electron debugger acceptsRuntime.terminateExecutionbut doesn’t abort scripts; closing the tab is the only reliable hard abort — pending CDP calls reject as the target closes, and the daemon tool fails fast). Detach makes pending sendCommand resolve rather than reject, so success marks must only be written while the tab is alive (otherwise they overwrite canceled).Runtime.callFunctionOnmaps to evaluate activity. - 9229 remote-debugging lists managed views as targets too (the agent won’t touch them by default; the bridge surface exposes only managed tabs).
10.1 Best practices (usage + engineering)
Usage (desktop)
- Mode choice: watch the agent’s page / sign in within the panel →
browser.gui; want your own Chrome’s login/extensions/2FA →browser.relay; terminal or headless environments → headless (default); local dev servers → keeprestrictToPublicoff. - Credentials: signing in via the panel = instantly available to the agent (same persistent partition); one-time migration from Chrome → Settings → Browser data → Import Chrome Data; relay = your Chrome’s real profile. All credentials stay local (the panel constantly shows “login state stored locally only”).
- Login collaboration (quota/dashboard scenarios): agent opens the login page → you sign in manually in the panel → agent
waitFors (URL/element) or waits until you say “signed in” → continues scraping →boardtool writes~/.musepi/boards/boards.json,data.taskattaches refresh. - Gotchas:
browser.guion while the GUI isn’t running → connected fails with an error (not a silent fallback — intended); if the GUI port is taken it tries 9230–9239 automatically, and the daemon’sbrowser.guiUrlmust match the non-default port; main-process changes need an Electron restart, daemon source changes need a daemon restart.
Engineering
- CDP bridge iron laws: ① attach must wait for renderer readiness (
whenDebuggerReady/did-finish-load), else the debugger wedges forever; ②Target.setAutoAttach/setDiscoverTargets/Runtime.runIfWaitingForDebuggerare always answered locally, never forwarded (single-session debugger has no child targets); ③Page.captureScreenshotintercepts viacapturePage(); ④ tab-levelattachedToTarget(page) events must carry message-level sessionId (else puppeteer’s#targetsIdsForInitnever completes). - Calibrate the emulation surface against the relay bridge (browser-level + tab-level setAutoAttach dual channels, TAB/PAGE dual targets, createTarget returning PAGE ids); after upgrading puppeteer, run E2E first.
- Single source of truth lives in the main process: the renderer only projects/reads; layout drops late arrivals via monotonic revision; URL/ledger redaction happens only in main.
- New settings: settings-schema (ui group “Grep & Browser”) + GUI settings page + priority-chain comment + kind-resolution test (
browser-gui-kind.test.tspattern). - Verification: unit tests (policy/kind) → isolated-instance E2E (open → puppeteer connect → drive → project →
fromSurface:falsepixel sampling: projected area = page backdrop, outside = GUI theme). - i18n: keys land in the corresponding domain file
desktop-web/src/i18n/zh-CN/<domain>.tsfirst (English is pass-through; the en domain must stay in sync — seedocs/i18n.md);t()only at render time; status copy reuses existing keys.
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:
- render keys keyed by
provider→ React duplicate-key warnings (historical tray bug); - one collapsible block per credential → visually stacked duplicates (/usage panel historical bug).
Merge algorithm (gui/src/components/composer/usage-panel.tsx UsageProviderSection; tray tray-menu-main.tsx buildUsageRows same logic):
- group by
provider→ one collapsible section; split windows bylabel|windowId→ one row per window; - column order is a provider-level fixed order: descending cross-window average usage, ties by label — never sort worst-first independently per window (credentials would “jump around left-right,” perceived by users as misalignment). Column cap 4 (
.slice(0, 4)); - rightmost
Totalcolumn: the mean of that window’s credential scores (TUI aggregation semantics), with separator; providers sorted ascending least-pressure (TUI parity); - the Total column pct shows numbers only (
55%, no “used”) — a 52px column can’t fit “55% used” (truncated before).
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):
- Ranking tiers: exact name match > name prefix > name substring > description substring; within a tier, GUI native commands first (usage/context — commands the composer intercepts to open panels outrank daemon commands like
clear/compactionin the same tier); - Empty query preserves catalog order (bare
/list isn’t reordered); non-matches sink to the bottom preserving order (survivors of skill: queries aren’t dropped); - Pure function + unit test
lib/slash-rank.test.ts(tiers/GUI tie-break/stable order/sink).
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
- New
TrajectoryViewprops:roundDurations?: RoundDurationMap(i.e.ReadonlyMap<number, number> | readonly (readonly [number, number])[]; the GUI store exposes Map form, persisted snapshots/tests appear as arrays). Source = per-round durations frozen at daemonagent_end, keyed by the round’s last assistant message tsMs (session-store.ts/MaterializedView.#roundDurationssame source). trajectory-data.ts: events gaintsMs(numeric timestamp);buildTrajectoryTree(entries, roundDurations?)outputTrajectoryTurnGroupgainsstartMs(first event in group)/endMs(last event in group; closed tostartMs + roundDurationMswhen roundDurations hits)/roundDurationMs. Turns without a hit never fabricate round durations (replay/historical sessions without agent_end have none).isTrajectoryEventInRange(ev, startMs, endMs): closed-interval check; events withouttsMsnever match (range mode never lights up unknown moments). Pure function shared by component and tests.usage/durationMs/ttftMs(added 2026-08-21): wireAssistantMessagecarriesusage(WireUsage)/duration/ttftnatively (settled turns only) — MaterializedView stores wire messages wholesale, sosnap.entries[].messagecarries them as-is and trajectory events extract directly, zero daemon changes; same source as transcript usage rows (desktop-web Transcript.tsx usageRow, gated bydisplay.showTokenUsage). Un-settled assistant events fabricate no stats.TimelineOverview: time domain = [earliest startMs, latest endMs] across all turns; with no valid span (max ≤ min) it returns null and renders nothing.
Component behavior
- Range dragging: pointer capture + commits only after ≥3px movement; clicking a segment selects that whole round, clicking blank space clears;
msAtClientXback-computes viagetBoundingClientRect, zero re-render dependencies. - Esc key: clears range first, then selection (
windowkeydown, deps [range, selectedId] — same ordering as the modal keyboard contract). - Focus = dim, don’t filter (
.traj-event--dim0.35 + saturate .6), DSH-style “focus, don’t filter”.
Pitfalls
- TS property narrowing doesn’t enter closures: inside
onPointerEnter={() => d(group.startMs)}, TS re-widensgroup.startMstonumber | undefined— copy into a local constant before use in handlers (const start = group.startMs). - Icons: oc-icons sprite has no
focus-3— the focus chip usestarget; new icons go intovendor/oc-icons/sprite.ts(script-generated; never hand-edit the file header). fractionalSecondDigitsisn’t guaranteed by the TS lib: precise-moment hints manually append.mmm(getMilliseconds().padStart(3,"0")).- Wrapping the whole row in a jump button = row width collapse + click hijack (real rendering regression 2026-08-21, located via CDP on an isolated instance):
- flex collapse: wrapping
.traj-event(flex, pre-wrap content) inside adisplay:flex.traj-event-jumpbutton shrank rows to min-content ~83px wide (traj-contentcollapsed to 11px), with pre-wrap text wrapping per-character into ~1000px tall stacks; addingflex:1 1 0%made it worse (4px). Switched to grid1fr auto(.traj-row { display:grid; grid-template-columns:1fr auto }+.traj-event{min-width:0}) filling 227px in one shot; runtime verified. Lesson: in narrow panels flex auto-shrink + pre-wrap content = disaster; 1fr grid columns are most reliable. - Click semantics misaligned: once a button wraps the row, clicking the row triggers the button onClick (jumps to transcript) and
traj-row’s “selection” can never be clicked → inspector unreachable. Fix: row stays a plain div (click selects), jump becomes a separate small arrow button revealed on hover (.traj-row:hover .traj-event-jump{opacity:1}, buttonstopPropagation). - Tooltip clipped:
.traj-ov-tiporiginally lived inside theoverflow:hidden.traj-ov-track, its top clipped (tipY measured flush against trackTop). Moved to the wrap layer (sibling of track, same width and coordinate space),bottom: calc(100% + 6px)floating above the bar.- Verification playbook: isolated instance (
electron . --remote-debugging-port=9224 --user-data-dir=/tmp/<name>) connect → click session → trajectory tab → CDPRuntime.evaluatemeasure.traj-event/.traj-content/.traj-ov-tipgetBoundingClientRect()(row width ≈227, content ≈155, tipY above track); builds must use fullbun run build(build:bundleonly emits hash-named html, missingindex.html, reload ERR_FILE_NOT_FOUND).
- Verification playbook: isolated instance (
- flex collapse: wrapping
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:
- Current split: TUI’s message tree relies on the session-level
session-manager’sleafId()hard-writingparentIdat entry creation (session-manager.ts:1091); wire message events never carry parentId;MaterializedViewhardcodesparentId: nullwhen projecting messages (materialized-view.ts), and only historical/persisted snapshots (fromSnapshotstored verbatim) and the legacy transcript read path (server.ts ~397) retain real parentIds. - Landed (forward-compatible):
wire/src/index.ts: User/Developer/Assistant/ToolResult four role messages gain optionalparentId?: string | null(live events still lack it).MaterializedView.#upsertMessage:parentId: message.parentId ?? null(keep when present, null when absent).packages/gui/src/lib/message-tree.ts:buildMessageTree(entries)/flattenMessageTree— builds a branching tree from entries’ id/parentId (orphans become roots, cycles safe, siblings ordered); immediately usable on historical snapshots, 6 test cases.
- Remaining seam for live message trees: the daemon emitter stamps message events (
agentSession.sessionManager.leafEntry()?.parentIdat the forwarding point in server.ts’sagentSession.subscribe) — once landed, the GUI trajectory panel can add a timeline/branch-tree toggle (reusingbuildMessageTree). TUI/traceplan indocs/tui-trace-plan.md.
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):
Markdown.tsxrenderStreamingMarkdown: while streaming, closed\n\nboundary chunks render as markdown immediately (reused across frames), and the undecided tail chunk accumulates verbatim as RAW TEXT (single entrance animation); withstreaming:falseonly the tail chunk re-parses into real markdown (head chunks reused, avoiding whole-stringmd.parse’s “stutter then everything renders at once”).- Thinking blocks pass through
Markdownsentence-by-sentence (tr-think-sentence--liveentrance). - DSH (
deepseek-harness)-style incrementality: everything except the last two chunks frozen as cached React elements, tail chunk re-parsed per chunk viaIncrementalMarkdownParser; known deviation — reference-style link/footnote definitions crossing frozen boundaries display literally during streaming, self-healing on settle’s full parse.
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) |
- Resolution order (updater.cjs
manifestUrl(), notes fetch only — the electron-updater feed comes from the build publish config):OMP_UPDATE_MANIFEST_URLenv →package.json update.manifestUrl→RELEASE_MANIFEST_URLdefault. - Graceful 404 degradation: before the repo goes public,
releases/latest404s →{enabled:false, reason:"no-update-source"}, settings page shows “No public update source yet (available after publishing)”;updates.checkreturns{latest:null}, announcement panel stays silent. - Release contract:
update-manifest.json({version,url,notes}) uploads as an asset namedupdate-manifest.jsonwith every GitHub release —/releases/latest/download/<asset>auto-redirects to the latest copy, no branch changes needed. url = direct dmg link, notes = feature descriptions, version matching package.json. - Initialization must not hardcode raw.githubusercontent: the old channel
raw.githubusercontent.com/MuseLinn/MusePi/main/packages/gui/update-manifest.json404s unconditionally while the repo is private (effectively a dead link); everything switched to release assets.
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:
turn_endfires once per tool batch, not at run end (agent-loop.tspushTurnEndfires per tool batch;types.ts: “a turn is one assistant response + any tool calls/results”). Clearing working onturn_endmakes the button flash back to the send arrow during inter-turn provider preparation (user report 2026-08-22).- Correct delimitation (
gui/src/lib/session-store.ts):agent_start/turn_start/usermessage_start→#working = true;turn_endonly clears#streaming; onlyagent_endclears#working+#streaming; daemon{kind:"state", payload:{isStreaming}}frames act as authoritative correction (backstop for aborts without turn_end). #buildSnapshotOR trap: old codeworking: this.#working || snap.state.isStreaming— the view’s turn-levelisStreamingsticks true on abort paths without turn_end, OR-ing back the already-reset flags, so the stop capsule never extinguishes. Changed to store as single source of truthworking: this.#working, seeded at construction from the resume snapshot’sstate.isStreaming(joining a working session mid-flight displays correctly too).- Tests:
packages/gui/test/session-store.test.tscovers “run-level boundaries don’t flash back + only agent_end extinguishes + state-frame backstop”. When changing button/indicator semantics, read that file’s switch and#buildSnapshotfirst.
Update toast (bitfun DailyAppUpdateGate parity)
- Main process
main.cjschecks quietly 12s after launch; ifcheckForUpdates()yieldsnewer, sendswebContents.send("update-available", result). - Renderer
UpdateToast.tsx(gui/src/components/UpdateToast.tsx) subscribes viaonUpdateAvailable(preload-exposed); bottom-right card: version (v current → v latest) + notes + “Download”/”Skip this version”. Notes come from theupdater-notesIPC (main-process manifest fetch, success-cached) — not the daemon RPC — so the preview survives a not-yet-connected daemon and a disabledstartup.checkUpdate; notes longer than 200 chars get a show-more toggle. - Download states (updater-state pushes):
preparingis set synchronously byupdater-download(indeterminate bar — covers the click → first-byte gap before electron-updater emitsdownload-progress, and its re-entry guard makes double-clicks safe),downloadingshows percent + transferred/total MB +bytesPerSecond,downloadedshows the success row + 立即重启. A dismissed toast revives onpreparing/downloadedpushes — withautoInstallOnAppQuit=false, 立即重启 is the only install path, so it must stay reachable (the settings page’s in-app 下载更新 button relies on the same revival). Dismissal plays a 180ms exit animation (close-timer +--closingclass, prompt-dialog parity); download failure offers retry + “Go to download”. - “Skip this version” remembers per version (
localStorage["musepi-update-skip-version"], bitfun-style) — same version won’t nag again; release notes and the “What’s new” dialog are two independent chains: the toast readsupdate-manifest.json’snotes(a plain string — the{zh,en}shape is reserved for a future split manifest; daemonupdates.checkpasses both through), the dialog readsCHANGELOG.musepi.md— fill both on release. - Bridging goes uniformly through
gui/src/lib/electron.ts’sElectronAPI.checkUpdates/onUpdateAvailable/getUpdateNotes+UpdateCheckResulttypes (no inline window assertions in components).
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:
gui-release.ymlpublishes darwin-arm64 only (x64 cut — x64 runners building arm64 dmgs miss thesherpa-onnx-darwin-arm64arch variant, structurally failing electron-builder).- Signing: mac
identity "-"(ad-hoc) + hardenedRuntime +build/entitlements.mac.plist(following openchamber: allow-jit / disable-library-validation etc., solving Electron JIT + dlopen of native modules). Ad-hoc only removes “completely unsigned shows as damaged”; double-click open is still blocked by Gatekeeper — “double-click opens” needs Developer ID signing +notarize: true(needs APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID secrets; the workflow’sMACOS_SIGNINGcondition is reserved). - Unlike VSCode: the app bundles a daemon for the GUI’s own use but doesn’t register PATH (
extraResources: [], no CLI symlink). Typingmusepiin a terminal does nothing by default; for a terminal CLI usebun run setup/bun linkorbun install -g @musepi/pi-coding-agent, …or add an electron-builderafterInstallsymlink hook (needs admin rights and may conflict with a separately installed CLI — a user decision).
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):
- Config:
packages/gui/package.jsonbuildpublish={provider:"github", owner:"MuseLinn", repo:"MusePi", channel:"latest"}(emitslatest*.yml). Never setallowPrerelease— 6.4.1 derives it (prerelease version → beta channel; stable →/releases/latest, prereleases ignored). - IPC:
updater-check(enriched result{enabled,newer,latest,current,notes}— updateInfo vsapp.getVersion(), notes fetched beside the feed check)/updater-download/updater-install/updater-notes(renderer→main) +updater-state(checking/preparing/downloading(percent+bytes)/downloaded/error) +update-available.autoDownload=false,autoInstallOnAppQuit=false. - Daemon sidecar:
updater-installawaitskill(daemonPort)thensetImmediate(() => autoUpdater.quitAndInstall())(setImmediate flushes the IPC reply first); the vendored daemon lands with the new app. - macOS needs
.zip: MacUpdaterfindFile(files,"zip",…)throwsERR_UPDATER_ZIP_FILE_NOT_FOUNDwithout it —mac.targetmust include"zip"and CI must ship*.zip(also buys blockmap deltas). Beta:-betatag →-c.publish.channel=beta+ prerelease; CI yml wildcards widenedlatest*.yml→*.yml(the gap that dropped beta feeds). - Degradation: unsigned Windows NSIS → SmartScreen confirm; ad-hoc macOS dmg → verification failure falls back to “Go to download”; Linux AppImage replaces itself without signing.
Extension P3/P4 seams (plugin-design.md P-tiers)
Landed since the 2026-08-25 audit (P3 ❌ / P4 service ❌). In extensibility/extensions/:
registerNotificationChannel(channel,{label})→ send(message): the runner’sonNotificationsink forwards each send → daemon → GUI (rendered alongside built-in notify events); the channel+send are dropped on unload so reload re-registers atomically.ExtensionNotificationMessage { text, title?, kind? }.registerThemeToken(key,value): the runner aggregates tokens (applyThemeTokens); the theme adds only new keys (never overriding built-ins); on unload the token is dropped and the theme re-renders without it.registerService(name,{start?,stop?}): a long-lived service;starton load (startServices),stopon unload / shutdown / rollback — guarded (always called, throw isolated), must leave no residual process.
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.ts → showTraceSelector). 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 ps → commands/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:
- runtime serves the renderer —
musepi serve --web-port <n>→daemon/static-web.tsstartDaemonWebserves the builtdesktop-webSPA over loopback HTTP (plain GET only; the JSON-RPC WS stays onnet.createServer/ws-transport.ts—Bun.serve’s http compat drops bytes on upgraded sockets). It also serves the boot config atGET /__daemon.json→{ wsUrl: "ws://127.0.0.1:<wsPort>/", token? }(token present only when--remote-token). A missing renderer dist is non-fatal — the shell falls back to its local bundle. - shell wraps it — Electron
main.cjs: whenMUSEPI_GUI_COMPAT_URLis set,mainWindow.loadURL(compatUrl)instead of the local bundle / Vite dev server. - renderer connects as host —
desktop-web/src/lib/host-client.tsHostClientimplements theSessionClientinterface (sameGuestSnapshotcontract the collab guest exposes): connects to the daemon WS via?token=,session.list→session.subscribe(initial snapshot +entry/event/statestream), rendering reuses the guest Transcript/tool cards/composer untouched. App auto-connects as host when the served page reads/__daemon.json(no collab deep link + config present →connectHost, skipping ConnectScreen).
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=1 — desktop-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-request→uiRequest (Composer renders it, sendUiResponse→session.askAnswer via a #askReqIds bridge between the daemon string id and the composer’s numeric reqId), approval-request→approvalRequest (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 的 CompatSlotHost(lib/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
git.logstructured contract (breaking for consumers of the old shape): default now returns{ commits: [{ hash, shortHash, author, timestamp(ms), refs: [{kind: "head"|"local"|"remote"|"tag", name}], parents: string[], subject }], hasMore }— parsed fromgit log --all --topo-order --pretty=format:%H%x1f%h%x1f%an%x1f%at%x1f%D%x1f%P%x1f%s%x1e(limitdefault 100, one extra record yieldshasMore;skippages). Remote refs are classified againstgit remotenames (so localfeature/x≠origin/...). Params{ cwd?, limit?, skip?, graph? };graph: truekeeps the legacy ASCII string (unused by the GUI now). Async spawn + 10s kill guard — NOT spawnSync, which froze the whole daemon event loop once (same lesson as git.status). GUI:ContextPanelGitLogPanerenders a ZCode-style table via the lane solvergui/src/lib/git-graph-lanes.ts(solveGraphLanes, pure + tested ingui/test/git-graph-lanes.test.ts): first parent keeps the lane, extra parents fork/merge out, stale columns join into the node (kind: "join"). Load-more accumulates commits and re-solves the whole list so lanes stay continuous across pages. Rows copy the full hash on click (1.2s “copied” feedback).git.statusnumstatparam:{ numstat: true }additionally returns summedadded/deletedfromgit diff HEAD --numstat(binary “-“ lines skipped) — consumed by the floating status card’s +N/−M badge.- Status cards (
gui/src/components/StatusCards.tsx, mounted in ChatView’s transcript wrapper, z-4 under the loading overlay): git card (15sgit.statuspoll + branch popup reusinggit.branches/git.checkoutwith the sharedmusepi-gui-toasterror path), agents card (snap.progresslive/ended split; elapsed timers measured from first sighting — the wire payload carries no start ts), todo card (scans entries for the latesttodotoolResultdetails.phases). Collapse state persists in localStoragemusepi-gui-status-cards; the stack renders nothing when all cards are empty. - Transcript
jumpRequestprop ({ timestamp, nonce }): the ONE jump seam for message tree / trajectory / canvas / branch-bar jumps. Expands the tail window (target index − 20 rows of context) and the compaction fold (setCompactedOpen) until the row mounts, then scrollIntoView +tr-flash-highlight(defined in transcript.css, host-independent). The oldgui/lib/transcript-jump.ts(scrollTop-0 fallback when the row was unmounted) is deleted — callers passrequestJump(ts); ChatView owns the nonce. - Reward overlay (
gui/src/components/RewardOverlay.tsx): mounted by AnnouncementOverlay whenchangelog.startupreturnsreward(read from optional<agentDir>/reward.json:{ id, amountrequired, brand/label/subtitle/expires/success/primaryUrl/secondary optional}). Seen-once semantics ride the existing announcement flow (force peek re-opens). Motion: layered transforms — tilt wrapper (–tilt-x/–tilt-yfrom pointer), idle float, one-shot entrance — never two animations on onetransform; CountUp rolls the amount;sfxFor(“complete”)on open; everything degrades undergui-motion-off/reduced-motion. New i18n domainreward.ts` (zh+en registered in both index.ts duplicate guards). - Maximize layering (user: 前后内容重叠): three fixes — (1)
.gui-pane-maximize-backdropscrim (fixed, top 48px, z-840, click restores) behind the z-850 panel; (2).gui-float-scrollbarz 100000 → 30 (a fixed element was painting OVER the maximized panel; tier is now transcript-local); (3) the agent-browser auto-open (onManagedBrowserState→onViewChange("browser")) is suppressed while maximized (maximizedRefmirror). Pitfall: fixed-position elements join the root stacking context — any z-index above a floating panel’s will bleed through; auditz-index(grep) before adding floating layers. - Managed browser re-projection signals: the layout effect now also listens
scroll(capture), windowfocus, andvisibilitychange— ResizeObserver only fires on SIZE changes, so minimize→restore / capture-scroll / DPI-change left the native WebContentsView at stale bounds (page content misplaced/blank). - ExtensionsCenter load-failed phase (dsh PluginInventory parity):
stateLabelputsloadErrorfirst (“加载失败”), and list rows (main tree + search results) show a red dot (gui-ext-dot--error) +gui-ext-item-tag--errbadge so a broken extension is visible without opening its detail pane.
Sidebar sort / tab motion / switch-lag polish (2026-08-29, user-reported round 2)
- Session-list reorder-on-click fix:
SessionList’sstatusTimesort rankedworking(+2)/unread(+1)FIRST — but both flags flip as a direct result of clicking a row (opening clears the unread mark; leaving dropsworking), so the row under the cursor jumped up/down on every switch. The sort is now pure last-activity (sessionSortKeydesc + stable id tiebreak); working/unread remain visual-only row markers (pulse dot, bold). Grouping (GroupedSessionListdate buckets) was already last-activity based. - Groups/projects tab motion: the capsule gained a sliding thumb (
.gui-tab-thumb,data-tabdriventranslateX(calc(100% + 2px)), 200ms spring — both pills are fixed-width so no measuring) and the active pill’s own fill goes transparent inside the capsule (base--activefill preserved for other users of the class). The list pane is keyed by view (archived | groups | projects) and remounts with a 160ms fade (.gui-tab-pane-in). - Switch-stagger reveal tightened: the reveal (desktop-web
transcript.css,data-switchedmarker from ChatView) had TWO conflicting ladders — gui/pet.css duplicated the desktop-web rule with different delays, winner decided by bundle order. The duplicate is removed (transcript.css is the single source), duration 300→240ms, tail delay 210→120ms (last row settles ~360ms instead of ~510ms), and motion-off/reduced-motion now disable the reveal entirely. - Skeleton flicker threshold 150→250ms: fast local switches keep the old transcript visible (it stays mounted during load); only genuinely slow opens (history reactivate) get the skeleton.
- Send path audited (no change needed): the composer clears text immediately (
onSendis fire-and-forget), the store frame-coalesces stream bursts (dsh Notifier.markFrameDirty parity) and collapses consecutivemessage_updates, snapshots are tail-capped at 200 (tailSnapshot), andsession.send’s sidebar refresh is hooked to the send ack. Perceived send/switch latency is daemon-bound, not GUI-bound. - dsh upstream scan (user pulled): new commits are the code-mode→PTC rename, Connection-owned RPC transport (ApiProxy removed) and a session-export download route — structural cleanups with nothing actionable for musepi’s GUI this round; the earlier-absorbed seams (Notifier frame-dirty coalescing, plugin inventory) are unchanged.
Transparency audit + compat-shell glass (2026-08-29, “该透明的区域仍没有透明”)
- Audit result: the gui (local bundle) glass pipeline is intact end-to-end — Electron main creates the window with
backgroundColor #00000000+ Win11backgroundMaterial: "acrylic"/ macOSvibrancy,gui-vibrancytoggles material + base color,gui-base.cssforceshtml:root, html:root body { background: transparent }(specificity beats desktop-web base.css’s opaquevar(--bg)), and.gui-main/.gui-pane-side--immersivepaint translucent scrims. If the LOCAL bundle still looks opaque, check in order: 设置 → 外观 → 窗口透明 toggle (musepi-gui-glass-enabled, OFF forces--gui-glass-overlay:100%+ an opaque window base), Windows 个性化 → 颜色 → 透明效果 (system-level OFF makes DWM acrylic render SOLID — nothing the app can do), and insider-build DWM quirks. - Real gap found and fixed: the glass contract existed ONLY in the gui bundle. When the shell loads the SERVED desktop-web renderer (compat chain —
MUSEPI_GUI_COMPAT_URLor the daemonweb.portdiscovery file withshell.enableddefault on), the page painted opaque--bgeverywhere: sidebar, top bar and the area around the main card could never be transparent. Fix:desktop-web/src/lib/native-glass.ts— when the compat marker (?shell=1) AND the Electron bridge (setWindowGlass) are present, the renderer addssh-native-glass(transparent html/body,sh-appscrim 100%→38%, header/rail mixes 80%→45% in shell.css) and mirrors the theme onto the window material;enableNativeGlass()runs at app import so the FIRST paint is already glass. Plain-browser guests (no bridge/marker) keep the opaque paint; Android/mobile shells unaffected.
20. Windows NSIS shortcut persistence (2026-08-30)
Symptom: after an OTA update the desktop shortcut was missing. Root cause (two layers):
- electron-builder
KeepShortcutsretention — first install writesKeepShortcuts=truetoHKCU\Software\<APP_GUID>(GUID = UUID v5 of appId,multiUser.nshINSTALL_REGISTRY_KEY). On subsequent installsinstallSection.nshreads it: withKeepShortcuts=true+ exe present →$keepShortcuts=true→addDesktopLink/addStartMenuLinktake the retention branch (only rename old→new when paths differ; no recreation whenoldLink == newLink). Desktop shortcut deleted by user/cleanup tool → never recreated by any later update. createDesktopShortcut:"always"doesn’t help updates: it definesRECREATE_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):
- Fresh install → desktop (
D:\Desktopon this machine — desktop redirected; check[Environment]::GetFolderPath('Desktop'), not%USERPROFILE%\Desktop) + start-menu shortcuts created. - OTA path: delete desktop shortcut → reinstall with
--updated(electron-updater parity) → desktop shortcut recreated — withKeepShortcuts=true+isUpdated=truethe default template cannot do this, so the recreation is attributable to the macro. - Registry check:
HKCU\Software\fe444d0e-2326-5f07-be39-027b4a8e8598(APP_GUID)KeepShortcuts=trueconfirmed during the test.
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
cron.list → { tasks, runs }— runs = global last 20 (on-disk window is 100; per-task history needscron.runs).cron.upsert { task } → { tasks, task }— validate →mergeCronTask(existing|undefined, task, now, defaultCwd)(daemon/crons.ts): NEW tasks use an explicit field whitelist and must carrymodel/thinkingLevelthere (a regression silently dropped both on create); edits spread-merge.nextRunAtis recomputed (cleared when disabled). The collab guest host mirrors the call withsessionManager.getCwd()as default cwd.cron.delete { id, cleanup? }—cleanup:"delete"removes every session the task ever ran (journal + row + transcript) and purges its runs.cron.toggle { id, enabled }/cron.runNow { id }(fire-and-forget;runNowis NOT in the guest mutating allowlist — read-only links can’t trigger runs).cron.runs { id?, limit? } → { runs }(new) — per-task run history, newest first, default 50 / cap 100. Read-only, so guests may call it.cron.nextRuns { schedule, count? } → { runs: epoch[] }(new) — editor cron preview computed by the daemon’s own parser; the client-side fork (nextCronRuns) is deleted (single source of truth).
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)
schedule.timezone(IANA) is honored end-to-end: wall-clock times, idle windows and cron expressions are evaluated in that timezone (zonedTimeToEpoch, Intl two-step offset, DST-safe; cron expands per calendar day with Vixie dom/dow semantics, ≤4-year day walk). Unset/unknown falls back to host-local — all pre-existing behavior. Invalid tz strings are rejected byvalidateCronSchedule.- Run status: the LAST assistant message of
agent_enddecides —stopReason "aborted"/"error"(+errorMessage) marks the run error, anything else success.finishrefuses to overwrite an already-settled run (abort/agent_end race). Limitation: only the final message is inspected. constrainToIdleWindow(at, window, from, tz?)interprets the window in the task tz.
Pitfalls
- The editor’s timezone select defaulted to
Asia/Shanghaiwhile the daemon ignored the field entirely (next-run math was host-local) — silent mismatch. Drafts now default to the empty 主机时区 option, and any explicit tz shows in the schedule label. - Board-view pause/resume called a nonexistent
cron.updateand swallowed the rejection (.catch(() => {})) — dead UI with no signal; the daemon only hascron.toggle/cron.upsert.rpc.requestmethod names are unchecked strings: always grep the daemon handler switch when wiring a new caller. - Calendar week-start is shared: page calendar (
TaskCalendarView) and editorCalendarPickerboth useweekStartIndex()/orderedWeekdayKeys()fromgui/src/lib/appearance.ts; weekday labels come from thescheduled sun..satkeys — never hardcode Sunday or["日","一",…].