426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
1243 lines
44 KiB
JavaScript
1243 lines
44 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* dev:desktop — orchestrates Eliza desktop local development (Vite, API, Electrobun).
|
|
*
|
|
* ## Why orchestrate instead of "just run electrbun"?
|
|
* Electrobun needs a renderer URL, usually the dashboard API, and (in dev) repo-root `dist/`
|
|
* for the embedded runtime. One script keeps ports and env vars aligned and implements a
|
|
* single shutdown policy so the terminal does not hang with stray children.
|
|
*
|
|
* ## Startup phases
|
|
* 1. **Renderer production build** — Runs `vite build` only when `viteRendererBuildNeeded()`
|
|
* says `packages/app/dist` is missing or older than sources (cheap mtime heuristic). Override:
|
|
* `--force-renderer` or `ELIZA_DESKTOP_RENDERER_BUILD=always`. **Why skip:** redundant
|
|
* production builds on every restart are slow; watch mode users get HMR from `vite dev`.
|
|
* 2. **Root bundle** — `tsdown` at repo root if `dist/entry.js` missing (Electrobun eliza-dist).
|
|
* 3. **Long-lived children** (see `launch()`):
|
|
* - **API** — `bun dev-server` unless `--no-api`; `bun --watch` is opt-in.
|
|
* - **Watch + default** — Vite **dev** server + `ELIZA_RENDERER_URL` for Electrobun (HMR).
|
|
* Stale dep chunks: `--vite-force` or `ELIZA_VITE_FORCE=1` / `ELIZA_VITE_FORCE=1` (passes `vite --force`).
|
|
* - **Watch + Rollup** — `--rollup-watch` or `ELIZA_DESKTOP_VITE_BUILD_WATCH=1` with
|
|
* `ELIZA_DESKTOP_VITE_WATCH=1`: legacy `vite build --watch` (slow on large graphs).
|
|
* - **Electrobun** — `bun run dev` in `packages/app-core/platforms/electrobun`.
|
|
*
|
|
* ## Port allocation (`launch()`) — WHY
|
|
* Before spawning API / Vite / Electrobun, `allocateFirstFreeLoopbackPort()` from
|
|
* `eliza/packages/app-core/scripts/lib/allocate-loopback-port.mjs` resolves **ELIZA_API_PORT** (default
|
|
* 31337) and, in Vite dev mode, **ELIZA_PORT** (default 2138) if something else
|
|
* already listens. **Why:** every child must agree on the same numbers; Vite's
|
|
* proxy is fixed at config load time, so "API picks a port later" desyncs the UI.
|
|
*
|
|
* ## Signals (Unix) — why `detached: true` on children
|
|
* TTY Ctrl-C is sent to the **foreground process group**. Non-detached children share that
|
|
* group, so Electrobun could consume the first SIGINT while Vite/API stayed up; the parent
|
|
* stayed alive on open stdio pipes. **Detached** puts services in their own session so this
|
|
* process alone receives Ctrl-C and runs one coordinated teardown (SIGTERM → brief grace →
|
|
* SIGKILL). Second Ctrl-C force-exits if you are stuck.
|
|
*
|
|
* ## Quit from the app
|
|
* When Electrobun exits (user chose Quit), siblings would otherwise keep the orchestrator
|
|
* alive. We detect electrbun's `exit` and stop Vite/API the same way as signal shutdown.
|
|
*
|
|
* Docs: docs/apps/desktop-local-development.md
|
|
*
|
|
* ## Observability (IDEs / agents) — WHY
|
|
*
|
|
* This script sets env so the Eliza API and Electrobun expose **machine-readable** hooks:
|
|
* - Aggregated child log file + `ELIZA_DESKTOP_DEV_LOG_PATH` → `GET /api/dev/console-log` (loopback tail).
|
|
* - Screenshot token + upstream URL → `GET /api/dev/cursor-screenshot` on the API (proxies Electrobun).
|
|
* **Why:** multiple processes (Vite, API, Electrobun) are opaque to tools that cannot see the native
|
|
* window; loopback + optional token bounds exposure vs. convenience. Defaults are **on** so agents
|
|
* and humans debugging together get signal; opt-out via `ELIZA_DESKTOP_DEV_LOG=0` and
|
|
* `ELIZA_DESKTOP_SCREENSHOT_SERVER=0`.
|
|
*/
|
|
|
|
import { execFileSync, spawn } from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import {
|
|
appendFileSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { createConnection } from "node:net";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
colorizeDevSettingsStartupBanner,
|
|
resolveDesktopApiPort,
|
|
resolveDesktopUiPort,
|
|
} from "@elizaos/shared";
|
|
import chalk from "chalk";
|
|
import { allocateFirstFreeLoopbackPort } from "./lib/allocate-loopback-port.mjs";
|
|
import { createApiSupervisor } from "./lib/api-supervisor.mjs";
|
|
import { resolveMainAppDir } from "./lib/app-dir.mjs";
|
|
import { resolveDesktopStartupEmbeddingWarmupPolicy } from "./lib/desktop-startup-embedding-warmup-policy.mjs";
|
|
import { signalSpawnedProcessTree } from "./lib/kill-process-tree.mjs";
|
|
import { killUiListenPort } from "./lib/kill-ui-listen-port.mjs";
|
|
import { extendNodePathEnv } from "./lib/node-path-env.mjs";
|
|
import { formatOrchestratorDesktopDevBanner } from "./lib/orchestrator-desktop-dev-banner.mjs";
|
|
import { appIdentityEnv } from "./lib/read-app-identity.mjs";
|
|
import { resolveRendererBuildAction } from "./lib/renderer-build-action.mjs";
|
|
import { viteRendererBuildNeeded } from "./lib/vite-renderer-dist-stale.mjs";
|
|
|
|
// Linux WebKitGTK: the dmabuf renderer can emit a benign but noisy
|
|
// "X11 Error: GLXBadWindow (code 168)" at webview creation on common
|
|
// XWayland/GLX driver combos. Apply the documented workaround only to the
|
|
// Electrobun child, before it starts, so Vite/API children do not inherit
|
|
// WebKit-specific renderer policy. An explicit user override still wins.
|
|
const linuxWebkitGtkEnv =
|
|
process.platform === "linux" &&
|
|
process.env.WEBKIT_DISABLE_DMABUF_RENDERER === undefined
|
|
? { WEBKIT_DISABLE_DMABUF_RENDERER: "1" }
|
|
: {};
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
// `_elizaRoot` is the eliza repo root itself (3 levels up from
|
|
// scripts/). `elizaRoot` is the *wrapper* repo root (4 levels up) used
|
|
// when eliza is checked out as a subdir of a parent project. We pick
|
|
// between them by detecting which layout exists.
|
|
const _elizaRoot = path.resolve(here, "../../..");
|
|
const _wrapperRoot = path.resolve(here, "../../../..");
|
|
// Wrapper layout only counts when the wrapper's `eliza/` checkout IS this
|
|
// checkout. A sibling clone/worktree that merely lives inside a wrapper repo
|
|
// directory (e.g. `<wrapper>/some-worktree` next to `<wrapper>/eliza`) must
|
|
// resolve as standalone — otherwise dev:desktop silently boots the wrapper's
|
|
// other eliza checkout (different branch/code) instead of the one running
|
|
// this script.
|
|
const _wrapperEliza = path.join(_wrapperRoot, "eliza");
|
|
const isElizaMonorepo =
|
|
existsSync(path.join(_wrapperRoot, "package.json")) &&
|
|
existsSync(
|
|
path.join(_wrapperEliza, "packages", "app-core", "package.json"),
|
|
) &&
|
|
realpathSync(_wrapperEliza) === realpathSync(_elizaRoot);
|
|
// Standalone eliza checkout — _elizaRoot IS the repo and there's no
|
|
// outer wrapper. dev-platform.mjs originally only handled the wrapper
|
|
// layout; this branch keeps the standalone (and our Windows monorepo
|
|
// dev) build working.
|
|
const isStandaloneEliza =
|
|
!isElizaMonorepo &&
|
|
existsSync(path.join(_elizaRoot, "package.json")) &&
|
|
existsSync(path.join(_elizaRoot, "packages", "app-core", "package.json"));
|
|
const elizaRoot = isStandaloneEliza ? _elizaRoot : _wrapperRoot;
|
|
const bundleRoot = elizaRoot;
|
|
|
|
function resolveRendererAppDir() {
|
|
return resolveMainAppDir(bundleRoot, "app");
|
|
}
|
|
|
|
function resolveElectrobunDir() {
|
|
if (isElizaMonorepo) {
|
|
return path.join(
|
|
elizaRoot,
|
|
"eliza",
|
|
"packages",
|
|
"app-core",
|
|
"platforms",
|
|
"electrobun",
|
|
);
|
|
}
|
|
return path.join(
|
|
elizaRoot,
|
|
"packages",
|
|
"app-core",
|
|
"platforms",
|
|
"electrobun",
|
|
);
|
|
}
|
|
|
|
const devServerEntry = isElizaMonorepo
|
|
? "eliza/packages/app-core/src/runtime/dev-server.ts"
|
|
: "packages/app-core/src/runtime/dev-server.ts";
|
|
|
|
const appDir = resolveRendererAppDir();
|
|
const electrobunDir = resolveElectrobunDir();
|
|
const appIdentity = appIdentityEnv(appDir);
|
|
const defaultElizaNamespace = appIdentity.ELIZA_NAMESPACE || "eliza";
|
|
const API_PROCESS_SPAWNED_AT_ENV = "ELIZA_API_PROCESS_SPAWNED_AT_MS";
|
|
const PROCESS_SPAWNED_AT_ENV = "ELIZA_PROCESS_SPAWNED_AT_MS";
|
|
|
|
if (isElizaMonorepo && process.env.ELIZA_SKIP_LOCAL_UPSTREAMS !== "1") {
|
|
process.env.ELIZA_FORCE_LOCAL_UPSTREAMS ??= "1";
|
|
}
|
|
|
|
function resolveDevStateDir() {
|
|
const explicit = process.env.ELIZA_STATE_DIR?.trim();
|
|
if (explicit)
|
|
return path.resolve(
|
|
explicit.replace(/^~(?=$|[\\/])/, process.env.HOME || process.cwd()),
|
|
);
|
|
const namespace =
|
|
process.env.ELIZA_NAMESPACE?.trim() || defaultElizaNamespace;
|
|
const xdgStateHome = process.env.XDG_STATE_HOME?.trim();
|
|
const base = xdgStateHome
|
|
? path.isAbsolute(xdgStateHome)
|
|
? xdgStateHome
|
|
: path.join(process.env.HOME || process.cwd(), xdgStateHome)
|
|
: path.join(process.env.HOME || process.cwd(), ".local", "state");
|
|
return path.join(base, namespace);
|
|
}
|
|
|
|
const BUN_EXECUTABLE = process.versions?.bun ? process.execPath : "bun";
|
|
|
|
function resolveElizaPackageDir(packageName) {
|
|
return isElizaMonorepo
|
|
? path.join(elizaRoot, "eliza", "packages", packageName)
|
|
: path.join(elizaRoot, "packages", packageName);
|
|
}
|
|
|
|
function buildWorkspacePackageAsync(packageName, packageDir) {
|
|
console.log(`[eliza] Building ${packageName} for desktop startup...`);
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(BUN_EXECUTABLE, ["run", "build"], {
|
|
cwd: packageDir,
|
|
env: { ...process.env },
|
|
stdio: "inherit",
|
|
});
|
|
child.on("error", reject);
|
|
child.on("exit", (code) =>
|
|
code === 0
|
|
? resolve()
|
|
: reject(new Error(`${packageName} build exited with code ${code}`)),
|
|
);
|
|
});
|
|
}
|
|
|
|
// These three runtime packages have no build interdependency among themselves,
|
|
// so any that are missing their dist entry are built concurrently rather than
|
|
// in series. Each existing dist is skipped (fast path), so a warm tree returns
|
|
// immediately with no spawned processes.
|
|
async function ensureDesktopRuntimePackagesBuilt() {
|
|
const targets = [
|
|
["@elizaos/security", resolveElizaPackageDir("security")],
|
|
[
|
|
"@elizaos/plugin-remote-manifest",
|
|
resolveElizaPackageDir("plugin-remote-manifest"),
|
|
],
|
|
[
|
|
"@elizaos/plugin-worker-runtime",
|
|
resolveElizaPackageDir("plugin-worker-runtime"),
|
|
],
|
|
];
|
|
const stale = [];
|
|
for (const [name, dir] of targets) {
|
|
if (!existsSync(path.join(dir, "package.json"))) {
|
|
throw new Error(`Missing ${name} package at ${dir}`);
|
|
}
|
|
if (!existsSync(path.join(dir, "dist/index.js"))) stale.push([name, dir]);
|
|
}
|
|
await Promise.all(
|
|
stale.map(([name, dir]) => buildWorkspacePackageAsync(name, dir)),
|
|
);
|
|
}
|
|
|
|
function syncRendererPublicAssets() {
|
|
const syncScript = path.join(
|
|
bundleRoot,
|
|
isElizaMonorepo
|
|
? "eliza/packages/shared/scripts/sync-to-public.mjs"
|
|
: "packages/shared/scripts/sync-to-public.mjs",
|
|
);
|
|
if (!existsSync(syncScript)) {
|
|
return;
|
|
}
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
syncScript,
|
|
path.join(appDir, "public"),
|
|
"--logos",
|
|
"--favicons",
|
|
"--concepts",
|
|
"--banners",
|
|
"--background",
|
|
"--background-videos",
|
|
],
|
|
{
|
|
stdio: "inherit",
|
|
},
|
|
);
|
|
}
|
|
|
|
// Load worktree-specific env overrides (ports, state dir) before anything reads process.env.
|
|
// Generated by: bash scripts/worktree-env.sh <slot>
|
|
const _worktreeEnvPath = path.join(bundleRoot, ".env.worktree");
|
|
if (existsSync(_worktreeEnvPath)) {
|
|
const { config: dotenvConfig } = await import("dotenv");
|
|
dotenvConfig({ path: _worktreeEnvPath, override: false });
|
|
}
|
|
|
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
console.log(`Usage: bun run dev:desktop [options]
|
|
ELIZA_DESKTOP_VITE_WATCH=1 bun eliza/packages/app-core/scripts/dev-platform.mjs # same as bun run dev
|
|
|
|
Starts Vite (optional), API (optional), and Electrobun with aligned ports and env.
|
|
|
|
Options:
|
|
--no-api Skip the API server (Electrobun + renderer only)
|
|
--force-renderer Force vite build before starting (even if dist is fresh)
|
|
--rollup-watch Use vite build --watch instead of vite dev (requires ELIZA_DESKTOP_VITE_WATCH=1)
|
|
--vite-force Pass --force to Vite (clear dep optimization cache on dev server start)
|
|
-h, --help Show this help
|
|
|
|
At startup, four settings tables print (Unicode frame; cyan on TTY) (orchestrator, Vite, API, Electrobun):
|
|
columns Setting / Effective / Source / Change — Source shows default vs explicitly set.
|
|
Secrets are redacted. Run without --help to see them.
|
|
|
|
Environment (CI / automation; flags override where noted):
|
|
ELIZA_DESKTOP_RENDERER_BUILD=always Same as --force-renderer
|
|
ELIZA_DESKTOP_VITE_BUILD_WATCH=1 Same as --rollup-watch (with ELIZA_DESKTOP_VITE_WATCH=1)
|
|
ELIZA_VITE_FORCE=1 / ELIZA_VITE_FORCE=1 Same as --vite-force
|
|
ELIZA_DESKTOP_SCREENSHOT_SERVER=0 Disable screenshot dev server
|
|
ELIZA_DESKTOP_DEV_LOG=0 Disable aggregated log file
|
|
ELIZA_DESKTOP_API_WATCH=1 Enable bun --watch for the API server
|
|
ELIZA_DESKTOP_PREWARM=0 Disable desktop startup API prewarming
|
|
ELIZA_DESKTOP_PREWARM_BLOCKING=1 Wait for API prewarming before Electrobun launch
|
|
ELIZA_API_PORT / ELIZA_PORT API port (first non-empty wins)
|
|
ELIZA_PORT UI port (Vite dev)
|
|
|
|
Docs: docs/apps/desktop-local-development.md
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// The Electrobun platform package is compiled from the workspace checkout. Its
|
|
// directory can exist (e.g. an empty placeholder) without a package.json when
|
|
// the @elizaos/* workspace packages aren't linked, in which case `bun run dev`
|
|
// there would fail opaquely. Fail fast with an actionable message instead, and
|
|
// before the expensive renderer build below.
|
|
if (!existsSync(path.join(electrobunDir, "package.json"))) {
|
|
console.error(
|
|
`[eliza] Desktop dev requires the Electrobun platform package on disk, but ` +
|
|
`none was found at ${path.join(electrobunDir, "package.json")}.\n` +
|
|
` Run dev:desktop from a workspace checkout where the @elizaos/* packages ` +
|
|
`are present and linked.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const skipApi = process.argv.includes("--no-api");
|
|
const forceRendererCli = process.argv.includes("--force-renderer");
|
|
const forceRenderer =
|
|
forceRendererCli ||
|
|
process.env.ELIZA_DESKTOP_RENDERER_BUILD === "always" ||
|
|
process.env.ELIZA_DESKTOP_RENDERER_BUILD === "1";
|
|
// Opt-in fast inner loop: start against the EXISTING dist even when stale
|
|
// (renderer may be stale until the next build). Prefer dev:desktop:watch (HMR).
|
|
const rendererBuildSkipRequested =
|
|
process.env.ELIZA_DESKTOP_RENDERER_BUILD === "skip";
|
|
const viteWatch = process.env.ELIZA_DESKTOP_VITE_WATCH === "1";
|
|
const viteDepForceCli = process.argv.includes("--vite-force");
|
|
const viteDepForce = viteDepForceCli || process.env.ELIZA_VITE_FORCE === "1";
|
|
const viteRollupWatchCli = process.argv.includes("--rollup-watch");
|
|
/** Legacy: Rollup `vite build --watch` (tens of seconds per edit on large graphs). */
|
|
const viteRollupWatch =
|
|
viteWatch &&
|
|
(viteRollupWatchCli || process.env.ELIZA_DESKTOP_VITE_BUILD_WATCH === "1");
|
|
/** Default when VITE_WATCH: Vite dev server + Electrobun ELIZA_RENDERER_URL (fast HMR). */
|
|
const viteDevServer = viteWatch && !viteRollupWatch;
|
|
/** On by default for `dev:desktop` / `dev:desktop:watch`; set to 0/false/no/off to disable. */
|
|
const screenshotServerOptOut = (() => {
|
|
const v = process.env.ELIZA_DESKTOP_SCREENSHOT_SERVER?.trim().toLowerCase();
|
|
return v === "0" || v === "false" || v === "no" || v === "off";
|
|
})();
|
|
const screenshotServerEnabled = !screenshotServerOptOut;
|
|
const preferredScreenshotPort = Number.parseInt(
|
|
process.env.ELIZA_SCREENSHOT_SERVER_PORT || "31339",
|
|
10,
|
|
);
|
|
const preferredBrowserWorkspacePort = Number.parseInt(
|
|
process.env.ELIZA_BROWSER_WORKSPACE_PORT || "31340",
|
|
10,
|
|
);
|
|
const screenshotToken = screenshotServerEnabled
|
|
? randomBytes(24).toString("hex")
|
|
: "";
|
|
|
|
/** On by default for dev-platform; set ELIZA_DESKTOP_DEV_LOG=0 to disable file + API tail. */
|
|
const desktopDevLogOptOut = (() => {
|
|
const v = process.env.ELIZA_DESKTOP_DEV_LOG?.trim().toLowerCase();
|
|
return v === "0" || v === "false" || v === "no" || v === "off";
|
|
})();
|
|
const desktopDevLogPath = desktopDevLogOptOut
|
|
? null
|
|
: path.join(resolveDevStateDir(), "desktop-dev-console.log");
|
|
const desktopCefWorkaroundEnv = (() => {
|
|
if (process.platform !== "darwin") {
|
|
return null;
|
|
}
|
|
|
|
const explicit = process.env.ELIZA_DESKTOP_FORCE_CEF?.trim();
|
|
if (explicit) {
|
|
return explicit;
|
|
}
|
|
|
|
return null;
|
|
})();
|
|
const desktopUnsafeDevtoolsEnv = (() => {
|
|
if (process.platform !== "darwin") {
|
|
return null;
|
|
}
|
|
|
|
const explicit = process.env.ELIZA_ALLOW_UNSAFE_NATIVE_DEVTOOLS?.trim();
|
|
if (explicit) {
|
|
return explicit;
|
|
}
|
|
|
|
return "1";
|
|
})();
|
|
function ensureBunRootPackageLink(packageName) {
|
|
const rootNodeModules = path.join(bundleRoot, "node_modules");
|
|
const packageLink = path.join(rootNodeModules, packageName);
|
|
if (existsSync(packageLink)) {
|
|
return;
|
|
}
|
|
|
|
const bunModulesDir = path.join(rootNodeModules, ".bun");
|
|
if (!existsSync(bunModulesDir)) {
|
|
return;
|
|
}
|
|
|
|
const candidates = readdirSync(bunModulesDir)
|
|
.filter((entry) => entry.startsWith(`${packageName}@`))
|
|
.map((entry) =>
|
|
path.join(bunModulesDir, entry, "node_modules", packageName),
|
|
)
|
|
.filter((candidate) => existsSync(path.join(candidate, "package.json")));
|
|
|
|
const target = candidates[0];
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
mkdirSync(path.dirname(packageLink), { recursive: true });
|
|
try {
|
|
symlinkSync(path.relative(rootNodeModules, target), packageLink, "dir");
|
|
console.log(
|
|
`[eliza] Restored missing Bun package link: node_modules/${packageName}`,
|
|
);
|
|
} catch (error) {
|
|
if (existsSync(packageLink) && lstatSync(packageLink).isSymbolicLink()) {
|
|
return;
|
|
}
|
|
console.warn(
|
|
`[eliza] Warning: failed to restore node_modules/${packageName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
syncRendererPublicAssets();
|
|
const rendererDistStale = viteRendererBuildNeeded(appDir, bundleRoot);
|
|
const rendererDistExists = existsSync(path.join(appDir, "dist", "index.html"));
|
|
const rendererBuildAction = resolveRendererBuildAction({
|
|
forceRenderer,
|
|
distStale: rendererDistStale,
|
|
distExists: rendererDistExists,
|
|
skipRequested: rendererBuildSkipRequested,
|
|
});
|
|
let ranInitialViteBuild = false;
|
|
|
|
if (rendererBuildAction === "build") {
|
|
ranInitialViteBuild = true;
|
|
console.log("\n[eliza] Building renderer (vite build)…");
|
|
console.log(
|
|
chalk.dim(
|
|
" Tip: `bun dev:desktop:watch` uses Vite HMR and skips this build.",
|
|
),
|
|
);
|
|
execFileSync(BUN_EXECUTABLE, ["--bun", "run", "vite", "build"], {
|
|
cwd: appDir,
|
|
env: { ...process.env },
|
|
stdio: "inherit",
|
|
});
|
|
console.log("[eliza] Renderer ready.\n");
|
|
} else if (rendererBuildAction === "skip-stale") {
|
|
console.warn(
|
|
"\n[eliza] Skipping STALE vite build (ELIZA_DESKTOP_RENDERER_BUILD=skip) —\n" +
|
|
" the renderer may be out of date. Use `bun dev:desktop:watch` for HMR,\n" +
|
|
" or `--force-renderer` to rebuild now.\n",
|
|
);
|
|
} else {
|
|
console.log(
|
|
"\n[eliza] Skipping vite build — renderer dist/ is up to date.\n" +
|
|
" Force: --force-renderer or ELIZA_DESKTOP_RENDERER_BUILD=always\n",
|
|
);
|
|
}
|
|
|
|
const rootDistEntry = path.join(bundleRoot, "dist", "entry.js");
|
|
if (!existsSync(rootDistEntry)) {
|
|
console.log("\n[eliza] Building root bundle for Electrobun eliza-dist…\n");
|
|
// In a standalone-eliza checkout the repo root has no tsdown config /
|
|
// src/index.ts, AND tsdown's plugin chain trips over `@tsdown/css`'s
|
|
// self-resolution under Bun on Windows ("Cannot find module
|
|
// 'tsdown/internal'"). Skip tsdown entirely and emit a minimal entry.js
|
|
// that re-exports the canonical app-core entry. Electrobun's bun
|
|
// runtime can load the .ts via Bun's TS support; this stub just gives
|
|
// the runtime path-resolver something to find.
|
|
mkdirSync(path.join(bundleRoot, "dist"), { recursive: true });
|
|
const entryTs = isStandaloneEliza
|
|
? "../packages/app-core/src/entry.ts"
|
|
: "../eliza/packages/app-core/src/entry.ts";
|
|
const entryStub = `// auto-generated by dev-platform.mjs — re-exports the eliza entry
|
|
// so Electrobun's runtime resolver can find dist/entry.js.
|
|
export * from ${JSON.stringify(entryTs)};
|
|
`;
|
|
writeFileSync(rootDistEntry, entryStub);
|
|
const distPkg = path.join(bundleRoot, "dist", "package.json");
|
|
if (!existsSync(distPkg)) {
|
|
mkdirSync(path.dirname(distPkg), { recursive: true });
|
|
writeFileSync(distPkg, `${JSON.stringify({ type: "module" })}\n`);
|
|
}
|
|
}
|
|
|
|
ensureBunRootPackageLink("jsdom");
|
|
await ensureDesktopRuntimePackagesBuilt();
|
|
|
|
async function allocateDistinctLoopbackPort(preferredPort, reservedPorts) {
|
|
let candidate = preferredPort;
|
|
while (true) {
|
|
const allocated = await allocateFirstFreeLoopbackPort(candidate);
|
|
if (!reservedPorts.has(allocated)) {
|
|
return allocated;
|
|
}
|
|
candidate = allocated + 1;
|
|
}
|
|
}
|
|
|
|
function waitForPort(port, { timeout = 120_000, interval = 400 } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const deadline = Date.now() + timeout;
|
|
function attempt() {
|
|
if (Date.now() > deadline) {
|
|
reject(
|
|
new Error(
|
|
`Timed out waiting for port ${port} after ${timeout / 1000}s`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const socket = createConnection({ port, host: "127.0.0.1" });
|
|
socket.once("connect", () => {
|
|
socket.destroy();
|
|
resolve();
|
|
});
|
|
socket.once("error", () => {
|
|
socket.destroy();
|
|
setTimeout(attempt, interval);
|
|
});
|
|
}
|
|
attempt();
|
|
});
|
|
}
|
|
|
|
function envFlagEnabled(name) {
|
|
const value = process.env[name]?.trim().toLowerCase();
|
|
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
}
|
|
|
|
function envFlagDisabled(name) {
|
|
const value = process.env[name]?.trim().toLowerCase();
|
|
return (
|
|
value === "0" || value === "false" || value === "no" || value === "off"
|
|
);
|
|
}
|
|
|
|
async function waitForApiRoute(
|
|
port,
|
|
pathname,
|
|
{ timeout = 120_000, interval = 400 } = {},
|
|
) {
|
|
const deadline = Date.now() + timeout;
|
|
const url = `http://127.0.0.1:${port}${pathname}`;
|
|
|
|
while (Date.now() <= deadline) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: { Accept: "application/json" },
|
|
signal: AbortSignal.timeout(3_000),
|
|
});
|
|
if (response.ok || response.status === 401 || response.status === 403) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// Keep polling until the API starts serving HTTP.
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
}
|
|
|
|
throw new Error(
|
|
`Timed out waiting for ${pathname} on port ${port} after ${timeout / 1000}s`,
|
|
);
|
|
}
|
|
|
|
async function waitForApiRuntimeReady(
|
|
port,
|
|
{ timeout = 360_000, interval = 750 } = {},
|
|
) {
|
|
const deadline = Date.now() + timeout;
|
|
const url = `http://127.0.0.1:${port}/api/health`;
|
|
|
|
while (Date.now() <= deadline) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: { Accept: "application/json" },
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
if (response.ok) {
|
|
const body = await response.json().catch(() => null);
|
|
if (
|
|
body &&
|
|
typeof body === "object" &&
|
|
body.runtime === "ok" &&
|
|
body.startup?.phase === "running"
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// Keep polling until the runtime finishes bootstrapping.
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
}
|
|
|
|
throw new Error(
|
|
`Timed out waiting for runtime readiness on port ${port} after ${timeout / 1000}s`,
|
|
);
|
|
}
|
|
|
|
async function warmApiRoute(port, pathname, { timeout = 30_000 } = {}) {
|
|
const headers = { Accept: "application/json" };
|
|
const token = process.env.ELIZA_API_TOKEN?.trim();
|
|
if (token) {
|
|
headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
|
|
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
|
|
headers,
|
|
signal: AbortSignal.timeout(timeout),
|
|
});
|
|
await response.arrayBuffer().catch(() => {});
|
|
}
|
|
|
|
async function warmApiRoutes(port) {
|
|
const routes = [
|
|
{ pathname: "/api/apps", timeout: 90_000 },
|
|
{ pathname: "/api/coding-agents/coordinator/status", timeout: 60_000 },
|
|
{ pathname: "/api/apps/installed", timeout: 90_000 },
|
|
{ pathname: "/api/apps/runs", timeout: 30_000 },
|
|
{ pathname: "/api/computer-use/approvals", timeout: 30_000 },
|
|
{ pathname: "/api/drop/status", timeout: 30_000 },
|
|
];
|
|
|
|
if (process.env.ELIZA_DESKTOP_PREWARM_CODING_PREFLIGHT !== "0") {
|
|
routes.push({ pathname: "/api/coding-agents/preflight", timeout: 60_000 });
|
|
}
|
|
|
|
const started = Date.now();
|
|
console.log("[eliza] Prewarming desktop startup API routes...");
|
|
for (const route of routes) {
|
|
try {
|
|
await warmApiRoute(port, route.pathname, { timeout: route.timeout });
|
|
} catch (error) {
|
|
console.warn(
|
|
`[eliza] Warning: failed to prewarm ${route.pathname}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
console.log(
|
|
`[eliza] Desktop startup API routes prewarmed in ${Date.now() - started}ms.`,
|
|
);
|
|
}
|
|
|
|
const children = [];
|
|
|
|
/** First Ctrl-C starts graceful shutdown; second exits immediately (pipes keep the process alive until then). */
|
|
let shuttingDown = false;
|
|
|
|
const namesForLog = [];
|
|
if (!skipApi) namesForLog.push("api");
|
|
if (viteDevServer) namesForLog.push("vite");
|
|
if (viteRollupWatch) namesForLog.push("vite");
|
|
namesForLog.push("electrobun");
|
|
const PREFIX_PAD = Math.max(...namesForLog.map((n) => n.length));
|
|
|
|
const CHILD_COLORS = {
|
|
vite: chalk.cyan,
|
|
api: chalk.green,
|
|
electrobun: chalk.magenta,
|
|
default: chalk.white,
|
|
};
|
|
|
|
function shouldSuppressExpectedDevLine(line) {
|
|
return (
|
|
line.includes("Notification authorization error:") &&
|
|
line.includes("falling back to legacy API")
|
|
);
|
|
}
|
|
|
|
function prefixStream(name, stream) {
|
|
const plainTag = `[${name.padEnd(PREFIX_PAD)}]`;
|
|
const colorFn = CHILD_COLORS[name] ?? CHILD_COLORS.default;
|
|
stream.on("data", (chunk) => {
|
|
for (const line of chunk.toString().split("\n")) {
|
|
if (line.trim()) {
|
|
if (shouldSuppressExpectedDevLine(line)) {
|
|
continue;
|
|
}
|
|
// Use the child color for both streams. Many tools (including Electrobun)
|
|
// write normal startup logs to stderr; red prefixes read as errors.
|
|
const coloredTag = colorFn(plainTag);
|
|
process.stdout.write(`${coloredTag} ${line}\n`);
|
|
if (desktopDevLogPath) {
|
|
try {
|
|
appendFileSync(desktopDevLogPath, `${plainTag} ${line}\n`, "utf8");
|
|
} catch {
|
|
/* ignore disk errors — console remains primary */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function childColorEnv() {
|
|
return process.env.NO_COLOR === undefined ? { FORCE_COLOR: "1" } : {};
|
|
}
|
|
|
|
function pushChild(name, cmd, args, cwd, extraEnv = {}) {
|
|
const resolvedCmd = cmd === "bun" ? BUN_EXECUTABLE : cmd;
|
|
const child = spawn(resolvedCmd, args, {
|
|
cwd,
|
|
env: extendNodePathEnv(
|
|
{ ...process.env, ...extraEnv, ...childColorEnv() },
|
|
bundleRoot,
|
|
),
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
// Without this, macOS/Linux deliver Ctrl-C to the whole process group; Electrobun
|
|
// then handles SIGINT ("press Ctrl+C again…") while Vite/API keep this parent alive.
|
|
...(process.platform !== "win32" ? { detached: true } : {}),
|
|
});
|
|
if (child.stdout) prefixStream(name, child.stdout);
|
|
if (child.stderr) prefixStream(name, child.stderr);
|
|
child.on("exit", (code, signal) => {
|
|
const reason = signal ? `signal ${signal}` : `exit ${code}`;
|
|
console.log(`[${name}] stopped (${reason})`);
|
|
if (name === "electrobun" && !shuttingDown) {
|
|
const exitCode = signal ? 1 : (code ?? 0);
|
|
shutdownDesktopDev({
|
|
exitCode,
|
|
message:
|
|
"\n[eliza] Electrobun exited — stopping Vite/API and closing dev session.",
|
|
});
|
|
}
|
|
});
|
|
children.push(child);
|
|
return child;
|
|
}
|
|
|
|
async function launch() {
|
|
const preferredApi = resolveDesktopApiPort(process.env);
|
|
const resolvedApiPort = await allocateFirstFreeLoopbackPort(preferredApi);
|
|
if (resolvedApiPort !== preferredApi) {
|
|
console.log(
|
|
`[eliza] API port ${preferredApi} in use — using ${resolvedApiPort} (Vite proxy + Electrobun env updated)`,
|
|
);
|
|
}
|
|
const apiPort = String(resolvedApiPort);
|
|
|
|
const preferredUi = resolveDesktopUiPort(process.env);
|
|
let uiDevPort = preferredUi;
|
|
if (viteDevServer) {
|
|
uiDevPort = await allocateFirstFreeLoopbackPort(preferredUi);
|
|
if (uiDevPort !== preferredUi) {
|
|
console.log(
|
|
`[eliza] UI port ${preferredUi} in use — Vite dev server using ${uiDevPort}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const rendererUrlForShell = viteDevServer
|
|
? `http://127.0.0.1:${uiDevPort}/`
|
|
: "";
|
|
const browserWorkspacePort = await allocateDistinctLoopbackPort(
|
|
preferredBrowserWorkspacePort,
|
|
new Set([resolvedApiPort, uiDevPort]),
|
|
);
|
|
if (browserWorkspacePort !== preferredBrowserWorkspacePort) {
|
|
console.log(
|
|
`[eliza] Browser workspace port ${preferredBrowserWorkspacePort} in use — using ${browserWorkspacePort}`,
|
|
);
|
|
}
|
|
const screenshotPort = screenshotServerEnabled
|
|
? await allocateDistinctLoopbackPort(
|
|
preferredScreenshotPort,
|
|
new Set([resolvedApiPort, uiDevPort, browserWorkspacePort]),
|
|
)
|
|
: preferredScreenshotPort;
|
|
if (screenshotServerEnabled && screenshotPort !== preferredScreenshotPort) {
|
|
console.log(
|
|
`[eliza] Screenshot port ${preferredScreenshotPort} in use — using ${screenshotPort}`,
|
|
);
|
|
}
|
|
const screenshotEnvElectrobun = screenshotServerEnabled
|
|
? {
|
|
ELIZA_DESKTOP_SCREENSHOT_SERVER: "1",
|
|
ELIZA_SCREENSHOT_SERVER_PORT: String(screenshotPort),
|
|
ELIZA_SCREENSHOT_SERVER_TOKEN: screenshotToken,
|
|
}
|
|
: {};
|
|
const screenshotEnvApi = screenshotServerEnabled
|
|
? {
|
|
ELIZA_ELECTROBUN_SCREENSHOT_URL: `http://127.0.0.1:${screenshotPort}`,
|
|
ELIZA_SCREENSHOT_SERVER_TOKEN: screenshotToken,
|
|
}
|
|
: {};
|
|
|
|
if (desktopDevLogPath) {
|
|
mkdirSync(path.dirname(desktopDevLogPath), { recursive: true });
|
|
writeFileSync(
|
|
desktopDevLogPath,
|
|
`--- eliza desktop dev ${new Date().toISOString()} ---\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
const serviceLine = namesForLog.join(", ");
|
|
const apiEmbeddingWarmupPolicy = resolveDesktopStartupEmbeddingWarmupPolicy(
|
|
process.env,
|
|
);
|
|
const orchestratorBanner = formatOrchestratorDesktopDevBanner({
|
|
worktreePath: _worktreeEnvPath,
|
|
worktreeLoaded: existsSync(_worktreeEnvPath),
|
|
skipApi,
|
|
forceRenderer,
|
|
forceRendererCli,
|
|
viteWatch,
|
|
viteRollupWatch,
|
|
viteDevServer,
|
|
viteDepForce,
|
|
viteDepForceCli,
|
|
viteRollupWatchCli,
|
|
ranInitialViteBuild,
|
|
rendererStaleReason: rendererDistStale
|
|
? "dist missing or older than renderer sources"
|
|
: null,
|
|
preferredApiPort: preferredApi,
|
|
allocatedApiPort: resolvedApiPort,
|
|
preferredUiPort: preferredUi,
|
|
allocatedUiPort: uiDevPort,
|
|
screenshotServerEnabled,
|
|
screenshotPort: String(screenshotPort),
|
|
screenshotTokenRedacted: screenshotServerEnabled ? "set (redacted)" : "—",
|
|
screenshotProxyUrl: `http://127.0.0.1:${screenshotPort}`,
|
|
desktopDevLogPath,
|
|
desktopDevLogOptOut,
|
|
childrenList: serviceLine,
|
|
apiEmbeddingWarmupPolicy,
|
|
elizaNamespace:
|
|
process.env.ELIZA_NAMESPACE?.trim() || defaultElizaNamespace,
|
|
elizaNamespaceUnset: !process.env.ELIZA_NAMESPACE?.trim(),
|
|
});
|
|
console.log(
|
|
`${chalk.bold(`Eliza desktop dev${skipApi ? " (no API)" : ""}`)}\n`,
|
|
);
|
|
console.log(colorizeDevSettingsStartupBanner(orchestratorBanner));
|
|
if (screenshotServerEnabled && !skipApi) {
|
|
console.log(
|
|
chalk.dim(
|
|
`[eliza] Screenshot: GET http://127.0.0.1:${apiPort}/api/dev/cursor-screenshot → Electrobun :${screenshotPort}`,
|
|
),
|
|
);
|
|
}
|
|
if (desktopDevLogPath && !skipApi) {
|
|
console.log(
|
|
chalk.dim(
|
|
`[eliza] Console log tail: GET http://127.0.0.1:${apiPort}/api/dev/console-log | file: ${desktopDevLogPath}`,
|
|
),
|
|
);
|
|
} else if (desktopDevLogPath && skipApi) {
|
|
console.log(
|
|
chalk.dim(
|
|
`[eliza] Dev console log file: ${desktopDevLogPath} (no API proxy — --no-api)`,
|
|
),
|
|
);
|
|
}
|
|
console.log("");
|
|
|
|
const apiWatchEnabled = envFlagEnabled("ELIZA_DESKTOP_API_WATCH");
|
|
const apiEnv = {
|
|
NODE_ENV: "development",
|
|
ELIZA_API_PORT: apiPort,
|
|
ELIZA_HEADLESS: "1",
|
|
// Defer the post-ready boot tail (app-route plugins, training hooks,
|
|
// sensitive-request adapters, trigger bridge, connector catalog, voice
|
|
// warmup) so /api/health flips ready:true and the UI reaches first paint
|
|
// before they finish. Brief feature-route 404 window after "ready" is the
|
|
// trade-off; acceptable for dev/desktop. Explicit env always wins.
|
|
ELIZA_DEFER_APP_ROUTES: process.env.ELIZA_DEFER_APP_ROUTES?.trim() || "1",
|
|
ELIZA_PORT: String(uiDevPort),
|
|
ELIZA_UI_PORT: String(uiDevPort),
|
|
ELIZA_NAMESPACE: process.env.ELIZA_NAMESPACE ?? defaultElizaNamespace,
|
|
...(rendererUrlForShell ? { ELIZA_RENDERER_URL: rendererUrlForShell } : {}),
|
|
ELIZA_DESKTOP_API_BASE: `http://127.0.0.1:${apiPort}`,
|
|
ELIZA_DESKTOP_API_WATCH: apiWatchEnabled ? "1" : "0",
|
|
...screenshotEnvApi,
|
|
...(desktopDevLogPath
|
|
? { ELIZA_DESKTOP_DEV_LOG_PATH: desktopDevLogPath }
|
|
: {}),
|
|
...apiEmbeddingWarmupPolicy.env,
|
|
};
|
|
// Runtime startup must never mutate dependencies. Optional plugin imports
|
|
// should fail fast when a package is absent instead of letting Bun auto-install
|
|
// transitive native packages inside the desktop app process.
|
|
const apiSourceConditionArgs = ["--no-install", "--conditions=eliza-source"];
|
|
const apiArgs = apiWatchEnabled
|
|
? [...apiSourceConditionArgs, "--watch", devServerEntry]
|
|
: [...apiSourceConditionArgs, devServerEntry];
|
|
if (!apiWatchEnabled) {
|
|
console.log(
|
|
"[eliza] API file watcher disabled (set ELIZA_DESKTOP_API_WATCH=1 to enable).",
|
|
);
|
|
}
|
|
|
|
const apiSupervisor = createApiSupervisor({
|
|
spawnChild: () => {
|
|
const apiProcessSpawnedAtMs = String(Date.now());
|
|
return spawn(BUN_EXECUTABLE, apiArgs, {
|
|
cwd: bundleRoot,
|
|
env: extendNodePathEnv(
|
|
{
|
|
...process.env,
|
|
...apiEnv,
|
|
[API_PROCESS_SPAWNED_AT_ENV]: apiProcessSpawnedAtMs,
|
|
[PROCESS_SPAWNED_AT_ENV]: apiProcessSpawnedAtMs,
|
|
...childColorEnv(),
|
|
},
|
|
bundleRoot,
|
|
),
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
...(process.platform !== "win32" ? { detached: true } : {}),
|
|
});
|
|
},
|
|
onSpawn: (child) => {
|
|
if (child.stdout) prefixStream("api", child.stdout);
|
|
if (child.stderr) prefixStream("api", child.stderr);
|
|
children.push(child);
|
|
child.on("exit", (code, signal) => {
|
|
const reason = signal ? `signal ${signal}` : `exit ${code}`;
|
|
console.log(`[api] stopped (${reason})`);
|
|
});
|
|
},
|
|
onExit: (child) => {
|
|
const idx = children.indexOf(child);
|
|
if (idx !== -1) children.splice(idx, 1);
|
|
},
|
|
onGiveUp: (code) => {
|
|
shutdownDesktopDev({
|
|
exitCode: code ?? 1,
|
|
message:
|
|
"\n[eliza] API restart backoff exceeded — stopping Vite/Electrobun and closing dev session.",
|
|
});
|
|
},
|
|
isShuttingDown: () => shuttingDown,
|
|
log: (message) => console.log(`\n[eliza] ${message}`),
|
|
warn: (message) => console.error(`\n[eliza] ${message}`),
|
|
});
|
|
|
|
if (!skipApi) {
|
|
apiSupervisor.start();
|
|
await waitForPort(Number(apiPort));
|
|
await waitForApiRoute(Number(apiPort), "/api/status");
|
|
if (envFlagEnabled("ELIZA_DESKTOP_WAIT_FOR_RUNTIME")) {
|
|
console.log(
|
|
"[eliza] Waiting for runtime readiness before opening desktop renderer…",
|
|
);
|
|
await waitForApiRuntimeReady(Number(apiPort));
|
|
console.log("[eliza] Runtime ready.");
|
|
if (envFlagEnabled("ELIZA_DESKTOP_PREWARM_BLOCKING")) {
|
|
await warmApiRoutes(Number(apiPort));
|
|
} else if (!envFlagDisabled("ELIZA_DESKTOP_PREWARM")) {
|
|
void warmApiRoutes(Number(apiPort)).catch((error) => {
|
|
console.warn(
|
|
`[eliza] Warning: desktop startup API prewarm failed: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (viteDevServer) {
|
|
killUiListenPort(uiDevPort);
|
|
console.log(
|
|
"\n[eliza] Vite dev server (HMR) for desktop — Electrobun loads ELIZA_RENDERER_URL.\n" +
|
|
` (Slow Rollup watch: ELIZA_DESKTOP_VITE_BUILD_WATCH=1 with ELIZA_DESKTOP_VITE_WATCH=1)\n`,
|
|
);
|
|
if (viteDepForce) {
|
|
console.log(
|
|
"[eliza] Vite --force (ELIZA_VITE_FORCE=1): re-optimizing dependencies.\n",
|
|
);
|
|
}
|
|
pushChild(
|
|
"vite",
|
|
"bun",
|
|
viteDepForce
|
|
? ["--bun", "run", "vite", "--", "--force"]
|
|
: ["--bun", "run", "vite"],
|
|
appDir,
|
|
{
|
|
NODE_ENV: "development",
|
|
ELIZA_VITE_LOOPBACK_ORIGIN: "1",
|
|
ELIZA_PORT: String(uiDevPort),
|
|
ELIZA_UI_PORT: String(uiDevPort),
|
|
ELIZA_API_PORT: apiPort,
|
|
ELIZA_NAMESPACE: process.env.ELIZA_NAMESPACE ?? defaultElizaNamespace,
|
|
},
|
|
);
|
|
await waitForPort(uiDevPort);
|
|
console.log(`[eliza] Vite ready on ${rendererUrlForShell}\n`);
|
|
}
|
|
|
|
if (viteRollupWatch) {
|
|
pushChild(
|
|
"vite",
|
|
"bun",
|
|
["--bun", "run", "vite", "build", "--watch"],
|
|
appDir,
|
|
{
|
|
ELIZA_DESKTOP_VITE_FAST_DIST: "1",
|
|
},
|
|
);
|
|
}
|
|
|
|
const electrobunChild = pushChild(
|
|
"electrobun",
|
|
"bun",
|
|
["run", "dev"],
|
|
electrobunDir,
|
|
{
|
|
NODE_ENV: "development",
|
|
ELECTROBUN_SKIP_CODESIGN: "1",
|
|
ELIZA_ELECTROBUN_REPO_ROOT: bundleRoot,
|
|
...appIdentity,
|
|
...(desktopCefWorkaroundEnv
|
|
? { ELIZA_DESKTOP_FORCE_CEF: desktopCefWorkaroundEnv }
|
|
: {}),
|
|
...(desktopUnsafeDevtoolsEnv
|
|
? {
|
|
ELIZA_ALLOW_UNSAFE_NATIVE_DEVTOOLS: desktopUnsafeDevtoolsEnv,
|
|
}
|
|
: {}),
|
|
...linuxWebkitGtkEnv,
|
|
...(rendererUrlForShell
|
|
? { ELIZA_RENDERER_URL: rendererUrlForShell }
|
|
: {}),
|
|
...(skipApi
|
|
? { ELIZA_DESKTOP_SKIP_EMBEDDED_AGENT: "1" }
|
|
: {
|
|
ELIZA_API_PORT: apiPort,
|
|
ELIZA_UI_PORT: String(uiDevPort),
|
|
ELIZA_NAMESPACE:
|
|
process.env.ELIZA_NAMESPACE ?? defaultElizaNamespace,
|
|
ELIZA_DESKTOP_API_BASE: `http://127.0.0.1:${apiPort}`,
|
|
}),
|
|
ELIZA_BROWSER_WORKSPACE_PORT: String(browserWorkspacePort),
|
|
...screenshotEnvElectrobun,
|
|
},
|
|
);
|
|
|
|
// macOS-only safety net: on some setups, `bunx electrobun dev` finishes
|
|
// building (copying the WGPU library, packaging app/bun/index.js into the
|
|
// bundle, etc.) but the native launcher it forks never registers a window
|
|
// because it was started via POSIX fork-exec instead of LaunchServices.
|
|
// Symptom: electrobun child stays alive, no `.app` window appears, the
|
|
// screenshot server port never starts listening.
|
|
//
|
|
// The fix is to also `open` the .app explicitly — LaunchServices then
|
|
// registers it as a GUI app with window-creation rights. If electrobun
|
|
// already launched it, `open` is a no-op (LaunchServices won't
|
|
// double-launch a registered bundle).
|
|
//
|
|
// **Crucially**, we wait until electrobun's build phase is _done_ before
|
|
// opening — otherwise the running app would hold files in the bundle and
|
|
// electrobun's bun-bundler would fail with PermissionDenied trying to write
|
|
// app/bun/index.js. We watch for the screenshot server port (preferred
|
|
// signal, only set after electrobun launches the launcher) and use a
|
|
// bundle-mtime stability check as a fallback. Honors
|
|
// `ELIZA_DESKTOP_AUTO_OPEN=0` to opt out.
|
|
if (
|
|
process.platform === "darwin" &&
|
|
process.env.ELIZA_DESKTOP_AUTO_OPEN !== "0"
|
|
) {
|
|
let scheduledOpen = false;
|
|
const resolveDevMacAppPath = () => {
|
|
const arch = process.arch === "arm64" ? "arm64" : "x86_64";
|
|
const buildDir = path.join(electrobunDir, "build", `dev-macos-${arch}`);
|
|
if (!existsSync(buildDir)) return null;
|
|
try {
|
|
const entries = readdirSync(buildDir);
|
|
const appBundle = entries.find((e) => e.endsWith(".app"));
|
|
return appBundle ? path.join(buildDir, appBundle) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const triggerOpen = (reason) => {
|
|
if (scheduledOpen) return;
|
|
scheduledOpen = true;
|
|
const macAppPath = resolveDevMacAppPath();
|
|
if (!macAppPath) {
|
|
console.log(
|
|
"[eliza] LaunchServices auto-open skipped — no .app bundle found in dev build dir",
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
const opener = spawn("open", [macAppPath], {
|
|
stdio: "ignore",
|
|
detached: true,
|
|
});
|
|
opener.unref();
|
|
opener.on("error", (err) => {
|
|
console.log(
|
|
`[eliza] LaunchServices auto-open failed: ${err.message}`,
|
|
);
|
|
});
|
|
console.log(
|
|
`[eliza] LaunchServices auto-open (${reason}): open ${path.basename(macAppPath)}`,
|
|
);
|
|
} catch (err) {
|
|
console.log(
|
|
`[eliza] LaunchServices auto-open threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
// Watch for the screenshot server port — if it comes up on its own, the
|
|
// electrobun launcher launched the app properly and we don't need to.
|
|
// If it doesn't come up within the deadline, fall back to `open`.
|
|
const screenshotPortStr =
|
|
screenshotEnvElectrobun.ELIZA_SCREENSHOT_SERVER_PORT;
|
|
const fallbackDeadlineMs = 45000;
|
|
const startedAt = Date.now();
|
|
const checkAndFallback = async () => {
|
|
if (scheduledOpen) return;
|
|
if (!electrobunChild || electrobunChild.exitCode != null) return;
|
|
const port = screenshotPortStr ? Number(screenshotPortStr) : 0;
|
|
if (port > 0) {
|
|
try {
|
|
// Try to connect — if it succeeds, the launcher's screenshot
|
|
// server is listening and the app is up. Done.
|
|
await new Promise((resolve, reject) => {
|
|
const sock = createConnection({ host: "127.0.0.1", port }, () => {
|
|
sock.end();
|
|
resolve();
|
|
});
|
|
sock.on("error", reject);
|
|
sock.setTimeout(500, () => {
|
|
sock.destroy();
|
|
reject(new Error("timeout"));
|
|
});
|
|
});
|
|
return; // screenshot server is up, nothing to do
|
|
} catch {
|
|
// not listening yet — keep waiting unless past deadline
|
|
}
|
|
}
|
|
if (Date.now() - startedAt >= fallbackDeadlineMs) {
|
|
triggerOpen("fallback after 45s with no screenshot server");
|
|
return;
|
|
}
|
|
setTimeout(checkAndFallback, 3000);
|
|
};
|
|
setTimeout(checkAndFallback, 8000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SIGTERM still-running children, wait for `exit` (or force SIGKILL), then `process.exit`.
|
|
*
|
|
* Skips PIDs that already exited so we do not signal stale trees after app Quit.
|
|
* `checkAllExited` + short timeout **why:** piped stdio keeps the event loop alive until
|
|
* every child is gone; exiting early avoids staring at a hung terminal after children die.
|
|
*/
|
|
function shutdownDesktopDev({
|
|
exitCode = 0,
|
|
message = "\n[eliza] Shutting down desktop dev environment...",
|
|
} = {}) {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
|
|
console.log(message);
|
|
|
|
let exitScheduled = false;
|
|
const finish = () => {
|
|
if (exitScheduled) return;
|
|
exitScheduled = true;
|
|
process.exit(exitCode);
|
|
};
|
|
|
|
const checkAllExited = () => {
|
|
const anyRunning = children.some(
|
|
(c) => c.exitCode === null && c.signalCode === null,
|
|
);
|
|
if (!anyRunning) {
|
|
finish();
|
|
}
|
|
};
|
|
|
|
for (const child of children) {
|
|
child.once("exit", checkAllExited);
|
|
child.once("error", checkAllExited);
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
signalSpawnedProcessTree(child, "SIGTERM");
|
|
}
|
|
}
|
|
checkAllExited();
|
|
|
|
setTimeout(() => {
|
|
for (const child of children) {
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
signalSpawnedProcessTree(child, "SIGKILL");
|
|
}
|
|
}
|
|
finish();
|
|
}, 1500).unref();
|
|
}
|
|
|
|
function cleanup() {
|
|
if (shuttingDown) {
|
|
console.log("\n[eliza] Force exit.");
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
shutdownDesktopDev({
|
|
exitCode: 0,
|
|
message: "\n[eliza] Shutting down desktop dev environment...",
|
|
});
|
|
}
|
|
|
|
process.on("SIGINT", cleanup);
|
|
process.on("SIGTERM", cleanup);
|
|
if (process.platform !== "win32") {
|
|
process.on("SIGHUP", cleanup);
|
|
}
|
|
|
|
launch().catch((err) => {
|
|
console.error("[eliza] dev-platform failed:", err);
|
|
for (const child of children) {
|
|
signalSpawnedProcessTree(child, "SIGKILL");
|
|
}
|
|
process.exit(1);
|
|
});
|