High-level direction and rationale. Not exhaustive; see the published Changelog (or docs/changelog.mdx in-repo) for shipped changes with WHYs.
Goal: Beat the felt cost of always-on dev shells on laptop battery while staying more visually distinctive than a flat editor surface—better UX and DX, not a spec-sheet stunt.
- Honest comparison: Cursor (and similar) ships a large persistent surface: Chromium/Electron-style shell, editor, extensions, LSP, indexing, often multiple web contexts. Milady’s desktop UI is narrower and task-shaped: companion, chat, settings, and bridges—on Electrobun / WKWebView plus optional 3D. Total J/s is workload-dependent; apples-to-apples needs the same scenario and tools (Activity Monitor,
powermetrics, Instruments). Our bar is excellent experience per watt for a local AI companion, not claiming victory in every head-to-head against a full IDE. - What we optimize: Wasted work—GPU and timers for hidden documents, off-screen canvases, and redundant HTTP polling. Battery-aware quality when unplugged (DPR cap, tighter Spark splats, no directional shadows, fewer background API ticks). Rich by default when the user is looking at the app and on AC.
- Shipped levers (see changelog + desktop docs):
VrmViewervisibility pause;desktop:getPowerState→VrmEngine.setLowPowerRenderMode; visibility-gated intervals (dashboard, stream, game logs, fine-tuning, cloud credits); vector 3D graphrAFpause when hidden; dev hooks opt-out so DX tooling does not accidentally burn watts (screenshot proxy, aggregated console). - Next UX/DX directions: User-visible Efficiency / Performance profile (single toggle),
prefers-reduced-motion, optional idle frame cap for the avatar when motion fidelity matters less than battery, and clearer in-app copy when battery savings are active (so users trust the tradeoff).
- Port collisions (dev + embedded desktop) —
dev:desktop/dev:desktop:watchpre-allocate free loopback ports forMILADY_API_PORTandMILADY_PORT(Vite) before spawning API, Vite, and Electrobun so env, proxy, and renderer URL stay aligned. Embedded agent: Electrobun picks the next free port from the preferredMILADY_PORTinstead of defaultlsof+ SIGKILL; optionalMILADY_AGENT_RECLAIM_STALE_PORT=1restores reclaim. Runtime:eliza.ts/dev-server.tssyncprocess.envto the API’s actual bind port where safe. UI:injectApiBaseon agent status for main + all surface windows. Why: two Milady stacks or stray processes should not require manual port hunting or killing unrelated processes; dynamic binds must propagate to renderer and dev tooling. Docs:docs/apps/desktop-local-development.md,docs/apps/desktop.md(port sections). Code:scripts/lib/allocate-loopback-port.mjs,apps/app/electrobun/src/native/loopback-port.ts,agent.ts,index.ts,surface-windows.ts,vite.config.ts,dev-server.ts,eliza.ts. - Desktop dev observability (IDEs / agents) —
GET /api/dev/stack,desktop:stack-status, default-on screenshot proxy (/api/dev/cursor-screenshot, loopback + token), default-on aggregated console (.milady/desktop-dev-console.log+/api/dev/console-logtail with basename allow-list). Why: multi-process dev is opaque to tools that cannot see the native window; explicit HTTP + file hooks avoid guessing ports and keep loopback/tokens bounded. Opt-out env vars documented. Docs:docs/apps/desktop-local-development.md(section IDE and agent observability). Rules:.cursor/rules/milady-desktop-dev-observability.mdc. - Electrobun Darwin → macOS mapping (WebGPU) —
getMacOSMajorVersion()usesDarwin − 9for 20–24 (macOS 11–15) andDarwin + 1for ≥ 25 (macOS 26+ Tahoe). Why:os.release()is Darwin; Tahoe is macOS 26 on Darwin 25—the old single formula reported 16 and broke WKWebView WebGPU messaging and gating. Docs:docs/apps/electrobun-darwin-macos-webgpu-version.md. Tests:webgpu-browser-support.test.ts. - Desktop menu reset (main process) — Confirm + API reset + restart + status poll run in Electrobun main; renderer syncs via
menu-reset-milady-appliedand sharedcompleteResetLocalStateAfterServerWipe. Why: WKWebView deferred renderer networking after native dialogs; users saw “nothing happens” after confirm. Reachable-base probe usesres.okonly. Docs:docs/apps/desktop-main-process-reset.md. Tests:menu-reset-from-main.test.ts,reset-main-process.test.ts. - Edge TTS disclosure — Document and surface
MILADY_DISABLE_EDGE_TTS/ELIZA_DISABLE_EDGE_TTS(registry +docs/cli/environment.md+ TTS doc). Why: orchestrator auto-loads Edge TTS →node-edge-tts→ Microsoft; “no API key” is not “offline.” - Vitest app-core coverage — Root config globs
packages/app-core/test/**/*.test.ts(x)andsrc/**/*.test.tsx; excludes app-core e2e undertest/from the default unit job. Why: new tests undertest/stateandtest/runtimewere skipped; a single hard-coded TSX path was brittle. - Node.js CI timeouts — Use
useblacksmith/setup-node@v5on Blacksmith for the desktop and API CI jobs; pinactions/setup-node@v3+check-latest: falseeverywhere else; add Bun global cache andtimeout-minutesto test, release, nightly, benchmark-tests, publish-npm. Why: v4 timeouts from nodejs.org and slow post-action; Blacksmith’s colocated cache and v3 fix it. Seedocs/build-and-release.md"Node.js and Bun in CI: WHYs". - Release workflow hardening — Strict shell (
bash -euo pipefail) for fail-fast steps; retry loops forbun installwith a final run so the step fails if all retries failed; crash dump uses the maintained ASAR CLI;find -print0/while IFS= read -r -d ''for safe paths; DMG path via find+stat; node-gyp artifact removal before pack; size report includes milady-dist; single Capacitor build step; packaged DMG E2E uses 240s CDP timeout in CI and dumps stdout/stderr on timeout. Why: Reproducible builds, clear failures, and debuggable CI; seedocs/build-and-release.md"Release workflow: design and WHYs". - Plugin resolution (NODE_PATH) — Set
NODE_PATHin three places so dynamicimport("@elizaos/plugin-*")resolves from CLI (run-node.mjschild), direct eliza load (eliza.tson load), and Electrobun (dev: walk up to findnode_modules; packaged: ASARnode_modules). Why: Node does not search repo root when the entry is underdist/or cwd is a subdir; without this, "Cannot find module" broke coding-agent and others. Seedocs/plugin-resolution-and-node-path.md. - Electrobun startup resilience — Keep API server up when runtime fails to load so the UI can show an error instead of "Failed to fetch". Why: A single missing native module (e.g. onnxruntime on Intel Mac) used to make the whole window dead with no explanation.
- Intel Mac x64 DMG — Release workflow runs install and desktop build under
arch -x86_64for the macos-x64 artifact so native.nodebinaries are x64. Why: CI runs on arm64; without Rosetta we shipped arm64 binaries and Intel users got "Cannot find module .../darwin/x64/...". - Auto-derived plugin deps —
copy-electrobun-plugins-and-deps.mjswalks each @elizaos package'spackage.jsondependencies instead of a curated list. Why: Curated lists missed new plugin deps and caused silent failures in packaged app; auto-walk stays correct as plugins change. - Regression tests for startup — E2E tests assert keep-server-alive and eliza.js load-failure behavior. Why: A failing test prevents removal of the exception-handling guards better than docs alone.
- Plugin resolution fix —
NODE_PATHset to repo rootnode_modulesineliza.ts,run-node.mjs, andagent.ts(Electrobun dev). Why: Dynamicimport("@elizaos/plugin-*")from bundledeliza.jscouldn't resolve packages at root;NODE_PATHtells Node where to look. No-op in packaged app (existsSync guard). Seedocs/plugin-resolution-and-node-path.md. - Bun exports patch — Postinstall in
patch-deps.mjsrewrites@elizaos/plugin-coding-agent(and any similar package) soexports["."]no longer has"bun": "./src/index.ts"when that file doesn't exist. Why: The published tarball only shipsdist/; Bun picks the"bun"condition first and fails. Removing the dead condition lets Bun use"import"→./dist/index.js. See "Bun and published package exports" indocs/plugin-resolution-and-node-path.md. - Release size-report: SIGPIPE 141 —
du | sort | headpipelines in the "Report packaged app size" step run in a subshell with|| r=$?and allow exit 141;sortstderr silenced. Why: Under-euo pipefail, 141 would exit the step before we could allow it; subshell captures it. Seedocs/build-and-release.md. - NFA routes: optional plugin —
/api/nfa/statusand/api/nfa/learningslazy-load@elizaos/plugin-bnb-identityand fall back when missing. Why: Core and tests work without the plugin; ambient type declaration keeps typecheck happy.
- Upstream plugin hygiene — Some plugins (e.g.
@elizaos/plugin-discord) listtypescriptindependenciesinstead ofdevDependencies; we skip it viaDEP_SKIPto avoid bundle bloat. Why: Fixing upstream would reduce our skip list and keep plugin package.json correct. - Optional: filter bundled deps — We intentionally copy all transitive deps (including ones tsdown may have inlined) because plugins can dynamic-require at runtime. Why: Excluding "likely bundled" deps would risk "Cannot find module" in packaged app. If we ever get static analysis of plugin dist/ to know what is never required at runtime, we could shrink the copy; not a priority.
- Desktop: Universal/fat macOS binary (single .app with arm64+x64) is possible via
lipoor desktop packaging targets but adds build time and complexity; separate DMGs are acceptable for now. - CI: Consider caching desktop native rebuilds per arch to speed up release matrix.