| title | Desktop local development |
|---|---|
| sidebarTitle | Local development |
| description | Why and how the Milady desktop dev orchestrator (scripts/dev-platform.mjs) runs Vite, the API, and Electrobun together — environment variables, signals, and shutdown behavior. |
The desktop dev stack is not a single binary. bun run dev:desktop and bun run dev:desktop:watch run scripts/dev-platform.mjs, which orchestrates separate processes: optional one-off vite build, optional repo-root tsdown, then long-lived Vite (watch mode only), bun --watch API, and Electrobun.
Why orchestrate? Electrobun needs (a) a renderer URL, (b) often a running dashboard API, and (c) in dev, a root dist/ bundle for the embedded Milady runtime. Doing that manually is error-prone; one script keeps ports, env vars, and shutdown consistent.
| Command | What starts | Typical use |
|---|---|---|
bun run dev:desktop |
API (unless --no-api) + Electrobun; skips vite build when apps/app/dist is fresher than sources |
Fast iteration against built renderer assets |
bun run dev:desktop:watch |
Same, but Vite dev server + MILADY_RENDERER_URL for HMR |
UI work; avoids vite build --watch (slow on large graphs) |
Why two commands? A full production Vite build is still useful when you want parity with shipped assets or when you are not touching the UI. Watch mode trades that for instant HMR by pointing Electrobun at the Vite dev server.
If you explicitly need file output on every save (e.g. debugging Rollup behavior):
MILADY_DESKTOP_VITE_WATCH=1 MILADY_DESKTOP_VITE_BUILD_WATCH=1 bun scripts/dev-platform.mjsWhy this is opt-in: vite build --watch still runs Rollup production emits; “3 modules transformed” can still mean seconds rewriting multi‑MB chunks. The default watch path uses the Vite dev server instead.
| Variable | Purpose |
|---|---|
MILADY_DESKTOP_VITE_WATCH=1 |
Enables watch workflow (dev server by default; see below) |
MILADY_DESKTOP_VITE_BUILD_WATCH=1 |
With VITE_WATCH, use vite build --watch instead of vite dev |
MILADY_PORT |
Vite / expected UI port (default 2138) |
MILADY_API_PORT |
API port (default 31337); forwarded to Vite proxy env and Electrobun |
MILADY_RENDERER_URL |
Set by the orchestrator when using Vite dev — Electrobun’s resolveRendererUrl() prefers this over the built-in static server (why: HMR only works against the dev server) |
MILADY_DESKTOP_RENDERER_BUILD=always |
Force vite build even when dist/ looks fresh |
--force-renderer |
Same as always rebuilding the renderer |
--no-api |
Electrobun only; no dev-server.ts child |
MILADY_DESKTOP_SCREENSHOT_SERVER |
Default on for dev:desktop / dev:desktop:watch: Electrobun listens on 127.0.0.1:MILADY_SCREENSHOT_SERVER_PORT (default 31339); the Milady API proxies GET /api/dev/cursor-screenshot (loopback) as a full-screen PNG for agents/tools (macOS needs Screen Recording permission). Set to 0, false, no, or off to disable. |
MILADY_DESKTOP_DEV_LOG |
Default on: child logs (vite / api / electrobun) are mirrored to .milady/desktop-dev-console.log at the repo root. GET /api/dev/console-log on the API (loopback) returns a tail (?maxLines=, ?maxBytes=). Set to 0 / false / no / off to disable. |
scripts/dev-platform.mjs runs dev:desktop and dev:desktop:watch. Before starting long-lived children it probes loopback TCP starting at:
| Env | Role | Default |
|---|---|---|
MILADY_API_PORT |
Milady API (dev-server.ts) |
31337 |
MILADY_PORT |
Vite dev server (watch mode only) | 2138 |
If the preferred port is already bound, the orchestrator tries preferred + 1, then +2, … (capped), and passes the resolved values into every child (MILADY_DESKTOP_API_BASE, MILADY_RENDERER_URL, Vite’s MILADY_PORT, etc.).
Why pre-allocate in the parent (not only inside the API process): Vite reads vite.config.ts once at startup; the proxy’s target must match the API port before the first request. If only the API shifted ports after bind, the UI would still proxy to the old default until someone restarted Vite. Resolving ports once in dev-platform.mjs keeps orchestrator logs, env, proxy, and Electrobun on the same numbers.
Packaged desktop (local embedded agent): the Electrobun main process calls findFirstAvailableLoopbackPort (apps/app/electrobun/src/native/loopback-port.ts) from the preferred MILADY_PORT (default 2138), passes that to the entry.js start child, and after a healthy start updates process.env.MILADY_PORT / MILADY_API_PORT / ELIZA_PORT in the shell. Why we stopped default lsof + SIGKILL: a second Milady (or any app) on the same default port is valid when state dirs differ; killing PIDs from the shell is surprising and can terminate unrelated work. Opt-in reclaim: MILADY_AGENT_RECLAIM_STALE_PORT=1 runs the old “free this port first” behavior for developers who want single-instance takeover.
Detached windows: when the embedded API port is finalized or changes, injectApiBase runs for the main window and all SurfaceWindowManager windows (why: chat/settings/etc. must not keep polling a stale http://127.0.0.1:…).
Related: Desktop app — Port configuration; GET /api/dev/stack overwrites api.listenPort from the accepted socket when possible (why: truth beats env if something else retargets the server).
On macOS, Electrobun only copies libMacWindowEffects.dylib into the dev bundle when that file exists (see apps/app/electrobun/electrobun.config.ts). Without it, traffic-light layout, drag regions, and inner-edge resize can be missing or wrong — easy to mistake for a generic Electrobun bug.
After cloning the repo, or whenever you change native/macos/window-effects.mm, build the dylib from the Electrobun package:
cd apps/app/electrobun && bun run build:native-effectsMore detail: Electrobun shell package (README: macOS window chrome), and Electrobun macOS window chrome.
The desktop shell uses Bonjour/mDNS to discover Milady gateways on your LAN. macOS may show a Local Network privacy dialog — choose Allow if you rely on local discovery.
Milady’s pinned Electrobun config types (as of the version in this repo) do not expose an Info.plist merge for NSLocalNetworkUsageDescription, so the OS may show a generic prompt. If upstream adds that hook later, we can set clearer copy; behavior does not depend on it.
Before starting services, the script checks viteRendererBuildNeeded() (scripts/lib/vite-renderer-dist-stale.mjs): compare apps/app/dist/index.html mtime against apps/app/src, vite.config.ts, shared packages (packages/ui, packages/app-core), etc.
Why mtime, not a full dependency graph? It is a cheap, local-first heuristic so restarts do not pay 10–30s for a redundant production build when sources did not change. Override when you need a clean bundle.
On macOS/Linux, long-lived children are spawned with detached: true so they live in a separate session from the orchestrator.
Why: A TTY Ctrl-C is delivered to the foreground process group. Without detached, Electrobun, Vite, and the API all receive SIGINT together. Electrobun then handles the first interrupt (“press Ctrl+C again…”) while Vite and the API keep running; the parent stays alive because stdio pipes are still open — it feels like the first Ctrl-C “did nothing.”
With detached, only the orchestrator gets TTY SIGINT; it runs a single shutdown path: SIGTERM each known subtree, short grace, then SIGKILL, then process.exit.
Second Ctrl-C while shutting down force-exits immediately (exit 1) so you are never stuck behind a grace timer.
Windows: detached is not used the same way (stdio + process model differ); port cleanup uses netstat/taskkill instead of only lsof.
If you Quit from the native menu, Electrobun exits with code 0 while Vite and the API may still be running. The orchestrator watches the electrobun child: on exit, it stops the remaining services and exits.
Why: Otherwise the terminal session hangs after “App quitting…” because the parent process is still holding pipes to Vite/API — same underlying issue as an incomplete Ctrl-C shutdown.
Before binding the UI port, the script tries to kill whatever is already listening (why: stale Vite or a crashed run leaves EADDRINUSE). Implementation: scripts/lib/kill-ui-listen-port.mjs (Unix: lsof; Windows: netstat + taskkill).
Shutdown uses signalSpawnedProcessTree — only the PID tree rooted at each spawned child (why: avoid pkill bun style nukes that would kill unrelated Bun workspaces on the machine).
Expected. You typically have: the orchestrator, bun run vite, bun --watch API, bun run dev under Electrobun (preload build + bunx electrobun dev), plus Bun/Vite/Electrobun internals. Worry if counts grow without bound or processes survive after the dev session fully exits.
Editors and coding agents do not see the native Electrobun window, hear audio, or auto-discover localhost. Milady adds explicit, machine-readable hooks so tools can still reason about “what is running” and approximate “what the user sees.”
Why this exists
- Multi-process truth — Health is not one PID. Vite, the API, and Electrobun can disagree on ports; logs are interleaved. A single JSON endpoint and one log file avoid “grep five terminals.”
- Security vs convenience — Screenshot and log tail endpoints are loopback-only; the screenshot path uses a session token between Electrobun and the API proxy; the log API only tails a file named
desktop-dev-console.log. Why: local-first does not mean “any process on the LAN may pull your screen.” - Opt-out defaults — Screenshot and aggregated logging are on for
dev:desktop/dev:desktop:watchbecause agents and humans debugging together benefit; both disable withMILADY_DESKTOP_SCREENSHOT_SERVER=0andMILADY_DESKTOP_DEV_LOG=0so you can shrink attack surface or disk I/O. - Cursor does not auto-poll — Discovery is documentation +
.cursor/rules(see repo) plus you asking the agent to runcurlor read a file. Why: the product does not silently scan your machine; hooks are there when instructed.
Returns stable JSON (schema: milady.dev.stack/v1): API listen port (from the socket when available), desktop URLs/ports from env (MILADY_RENDERER_URL, MILADY_PORT, …), cursorScreenshot / desktopDevLog availability and paths, and short hints (e.g. Electrobun’s internal RPC port in launcher logs).
Why on the API: agents often already probe /api/health; one extra GET reuses the same host and avoids parsing Electrobun’s ephemeral port.
Script: scripts/desktop-stack-status.mjs (with scripts/lib/desktop-stack-status.mjs). Probes UI/API ports, fetches /api/dev/stack, /api/health, and /api/status.
Why a CLI: agents and CI can run it without loading the dashboard; JSON exit code reflects API health for simple automation.
Loopback only. Proxies Electrobun’s dev server (default 127.0.0.1:31339) which uses the same OS-level capture as ScreenCaptureManager.takeScreenshot() (e.g. macOS screencapture). Not webview-only pixels.
Why proxy through the API: one URL on the familiar API port; token stays in env between orchestrator-spawned children. Why full screen first: window-ID capture is platform-specific; this path reuses existing, tested code.
Prefixed vite / api / electrobun lines are mirrored to .milady/desktop-dev-console.log (session banner on each orchestrator start). GET /api/dev/console-log (loopback) returns a text tail; query maxLines (default 400, cap 5000) and maxBytes (default 256000).
Why a file: agents can read_file the path from desktopDevLog.filePath without HTTP. Why HTTP tail: avoids reading multi-megabyte logs into context; caps prevent OOM. Why basename allow-list: MILADY_DESKTOP_DEV_LOG_PATH could otherwise be pointed at arbitrary files.
| Piece | Role |
|---|---|
.cursor/rules/milady-desktop-dev-observability.mdc |
Cursor: when to use stack / screenshot / console hooks (why: product does not auto-scan localhost) |
scripts/dev-platform.mjs |
Orchestrator; sets env for stack / screenshot / log path |
scripts/lib/vite-renderer-dist-stale.mjs |
When vite build is needed |
scripts/lib/kill-ui-listen-port.mjs |
Free UI port |
scripts/lib/kill-process-tree.mjs |
Scoped tree kill |
scripts/lib/desktop-stack-status.mjs |
Port + HTTP probes for desktop:stack-status |
scripts/desktop-stack-status.mjs |
CLI entry for agents (--json) |
packages/app-core/src/api/dev-stack.ts |
Payload for GET /api/dev/stack |
packages/app-core/src/api/dev-console-log.ts |
Safe tail read for GET /api/dev/console-log |
apps/app/electrobun/src/index.ts |
resolveRendererUrl(); starts screenshot dev server when enabled |
apps/app/electrobun/src/screenshot-dev-server.ts |
Loopback PNG server (proxied as /api/dev/cursor-screenshot) |
- Desktop app (Electrobun) — runtime modes, IPC, downloads
- Electrobun startup and exception handling — why main-process try/catch stays