Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

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);
});