#!/usr/bin/env node /** * Development script that starts: * 1. The Eliza dev server (\[(eliza|eliza)(?:-api)?\]|runtime + API on port 31337) with restart support * 2. The vite app dev server (port 2138, proxies /api and /ws to 31337) * * Automatically kills zombie processes on both ports before starting. * Waits for the API port to be open before launching vite so the proxy * doesn't flood the terminal with ECONNREFUSED errors. * * Usage: * node eliza/packages/app-core/scripts/dev-ui.mjs # from Eliza repo root — API + UI * node packages/app-core/scripts/dev-ui.mjs # from eliza repo root — same * node …/dev-ui.mjs --ui-only # Vite only (API assumed running) */ import { execSync, spawn } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; import { createConnection } from "node:net"; import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { resolveDesktopApiPort, resolveDesktopUiPort } from "@elizaos/shared"; import * as JSON5Module from "json5"; import { startAgentSourceWatcher } from "./lib/agent-source-watcher.mjs"; import { createApiSupervisor } from "./lib/api-supervisor.mjs"; import { relativeAppDir, resolveMainAppDir } from "./lib/app-dir.mjs"; import { getBunVersionAdvisory } from "./lib/bun-version-guard.mjs"; import { capacitorPluginsBuildNeeded } from "./lib/capacitor-plugin-build-needed.mjs"; import { coerceBoolean } from "./lib/dev-ui-onchain.mjs"; import { buildVisionDepsFailureMessage } from "./lib/dev-ui-vision.mjs"; import { signalSpawnedProcessTree } from "./lib/kill-process-tree.mjs"; import { extendNodePathEnv } from "./lib/node-path-env.mjs"; import { syncElizaEnvAliases } from "./lib/sync-eliza-env-aliases.mjs"; import { chooseElizaRuntime, resolveNodeExecPathFromCandidates, } from "./run-node-runtime.mjs"; // Load worktree-specific env overrides (ports, state dir) before port resolution. // Generated by: bash scripts/worktree-env.sh const _worktreeEnvPath = path.join(process.cwd(), ".env.worktree"); if (existsSync(_worktreeEnvPath)) { const { config: dotenvConfig } = await import("dotenv"); dotenvConfig({ path: _worktreeEnvPath, override: false }); } function resolveCapacitorPluginNamesPath(devCwd) { const rootPackagesApp = path.join( devCwd, "packages", "app", "scripts", "capacitor-plugin-names.mjs", ); const rootAppsApp = path.join( devCwd, "apps", "app", "scripts", "capacitor-plugin-names.mjs", ); const nestedElizaPackagesApp = path.join( devCwd, "eliza", "packages", "app", "scripts", "capacitor-plugin-names.mjs", ); const nestedElizaAppsApp = path.join( devCwd, "eliza", "apps", "app", "scripts", "capacitor-plugin-names.mjs", ); const elizaSubmodulePresent = existsSync( path.join(devCwd, "eliza", "packages", "app-core", "package.json"), ); if (existsSync(rootAppsApp) && elizaSubmodulePresent) { return rootAppsApp; } if (existsSync(nestedElizaPackagesApp)) { return nestedElizaPackagesApp; } if (existsSync(nestedElizaAppsApp)) { return nestedElizaAppsApp; } if (existsSync(rootPackagesApp)) { return rootPackagesApp; } if (existsSync(rootAppsApp)) { return rootAppsApp; } const packagedAppCorePluginNames = fileURLToPath( new URL("./lib/capacitor-plugin-names.mjs", import.meta.url), ); if (existsSync(packagedAppCorePluginNames)) { return packagedAppCorePluginNames; } throw new Error( `[dev-ui] capacitor-plugin-names.mjs not found. Tried:\n ${rootPackagesApp}\n ${rootAppsApp}\n ${nestedElizaPackagesApp}\n ${nestedElizaAppsApp}\n ${packagedAppCorePluginNames}`, ); } /** Relative to `cwd` for `spawn(..., { cwd })` — Eliza monorepo vs eliza repo root. */ function resolveDevServerEntryRelativePath(devCwd) { const elizaMonorepoEntry = path.join( devCwd, "eliza", "packages", "app-core", "src", "runtime", "dev-server.ts", ); if (existsSync(elizaMonorepoEntry)) { return "eliza/packages/app-core/src/runtime/dev-server.ts"; } const elizaRepoEntry = path.join( devCwd, "packages", "app-core", "src", "runtime", "dev-server.ts", ); if (existsSync(elizaRepoEntry)) { return "packages/app-core/src/runtime/dev-server.ts"; } const packagedAppCoreEntry = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", "runtime", "dev-server.js", ); if (existsSync(packagedAppCoreEntry)) { return packagedAppCoreEntry; } throw new Error( `[dev-ui] dev-server entry not found under ${devCwd}. Expected eliza/packages/app-core/... (Eliza-style checkout), packages/app-core/... (eliza repo root), or a packaged @elizaos/app-core runtime entry.`, ); } const _capacitorPluginNamesPath = resolveCapacitorPluginNamesPath( process.cwd(), ); const { CAPACITOR_PLUGIN_NAMES, NATIVE_PLUGINS_ROOT } = await import( pathToFileURL(_capacitorPluginNamesPath).href ); syncElizaEnvAliases(); const API_PORT = resolveDesktopApiPort(process.env); const JSON5 = JSON5Module.default ?? JSON5Module; const cwd = process.cwd(); // --app= selects which app to serve (default: "app" → packages/app) const appArgMatch = process.argv.find((a) => a.startsWith("--app=")); const appName = appArgMatch ? appArgMatch.split("=")[1] : "app"; const APP_UI_PORTS = { app: resolveDesktopUiPort(process.env), home: Number(process.env.ELIZA_HOME_PORT) || 2142, }; const UI_PORT = APP_UI_PORTS[appName] ?? 2138; const appDir = relativeAppDir(cwd, resolveMainAppDir(cwd, appName)); function getCliName() { const nameArgMatch = process.argv.find((a) => a.startsWith("--name=")); if (nameArgMatch) return nameArgMatch.split("=")[1]; try { const pkgPath = path.join(process.cwd(), "package.json"); if (existsSync(pkgPath)) { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); if (pkg.name) { let name = pkg.name; if (name.startsWith("@")) name = name.split("/")[1]; if (name === "elizaai" || name === "eliza-ai" || name.includes("eliza")) return "eliza"; if (name === "elizaos" || name.includes("eliza")) return "eliza"; return name; } } } catch (_e) { // Ignore parsing errors } // Fallbacks based on directory structure if ( process.cwd().includes("eliza-workspace") || process.cwd().includes("eliza") ) { return "eliza"; } return "eliza"; } const cliName = getCliName(); const logPrefix = `[${cliName}]`; const API_PROCESS_SPAWNED_AT_ENV = "ELIZA_API_PROCESS_SPAWNED_AT_MS"; const PROCESS_SPAWNED_AT_ENV = "ELIZA_PROCESS_SPAWNED_AT_MS"; const bunAdvisory = getBunVersionAdvisory(); if (bunAdvisory) console.warn(`${logPrefix} ${bunAdvisory}`); const visionDepsScriptPath = fileURLToPath( new URL("./ensure-vision-deps.mjs", import.meta.url), ); const uiOnly = process.argv.includes("--ui-only"); const devLogLevel = (process.env.ELIZA_DEV_LOG_LEVEL ?? process.env.LOG_LEVEL ?? "info") .trim() .toLowerCase() || "info"; const quietApiLogs = process.env.ELIZA_DEV_QUIET_LOGS === "1"; const verboseApiLogs = process.env.ELIZA_DEV_VERBOSE_LOGS !== "0"; // Agent hot-reload is ON by default: a source-only watcher (see // startAgentSourceWatcher) bounces the API child through the supervisor when // backend `*/src` changes. Unlike the old `node --watch`, it never watches // `dist/`, so concurrent package builds can't trigger a reload loop — which is // why this no longer needs the opt-in / concurrent-build auto-disable dance. // Opt out with ELIZA_DEV_NO_WATCH=1. const skipSourceWatch = process.env.ELIZA_DEV_NO_WATCH === "1"; // More distinct source files than this changing in one debounce window is a // git reset / checkout / build (churn), not a hand edit — skip the reload. // Override with ELIZA_DEV_HOT_RELOAD_BULK_LIMIT. const HOT_RELOAD_BULK_CHANGE_LIMIT = Number(process.env.ELIZA_DEV_HOT_RELOAD_BULK_LIMIT) || 4; const DEV_TEST_MOCK_ENV_KEYS = [ "ELIZA_MOCK_GOOGLE_BASE", "ELIZA_MOCK_TWILIO_BASE", "ELIZA_MOCK_WHATSAPP_BASE", "ELIZA_MOCK_X_BASE", "ELIZA_MOCK_CALENDLY_BASE", ]; let warnedAboutStrippedDevMocks = false; function createDevChildEnv(baseEnv) { const nextEnv = { ...baseEnv }; if (nextEnv.ELIZA_DEV_ALLOW_TEST_MOCKS === "1") { return nextEnv; } const strippedKeys = []; for (const key of DEV_TEST_MOCK_ENV_KEYS) { if (key in nextEnv) { delete nextEnv[key]; strippedKeys.push(key); } } if (!warnedAboutStrippedDevMocks && strippedKeys.length > 0) { warnedAboutStrippedDevMocks = true; const cleanupHint = strippedKeys.includes("ELIZA_MOCK_GOOGLE_BASE") ? " If cached mock Google events already landed in LifeOps, disconnect and reconnect Google once in the app to resync real data." : ""; console.warn( `${logPrefix} Ignoring test-only mock env vars for dev: ${strippedKeys.join(", ")}.${cleanupHint}`, ); } return nextEnv; } function shellQuoteArg(value) { return /^[A-Za-z0-9_./:=+-]+$/.test(value) ? value : JSON.stringify(value); } function appendNodeOption(value, option) { const current = value?.trim(); if (!current) return option; if (current.split(/\s+/).includes(option)) return current; return `${current} ${option}`; } function resolveApiRuntimeCommand(env) { const requestedRuntime = env.ELIZA_RUNTIME?.trim().toLowerCase(); const { runtime, warning } = chooseElizaRuntime({ requestedRuntime, platform: process.platform, bunVersion: process.versions?.bun, hasBun, hasNode, }); if (warning) { console.warn( `${logPrefix} ${warning} Set ELIZA_RUNTIME=bun to force Bun runtime.`, ); } if (runtime === "bun") { if (!hasBun) { throw new Error( requestedRuntime === "bun" ? "ELIZA_RUNTIME=bun was requested, but bun/bunx was not found in PATH." : "Bun runtime was selected, but bun/bunx was not found in PATH.", ); } return { runtime: "bun", command: which("bun") ?? "bun" }; } if (!hasNode) { throw new Error( requestedRuntime === "node" ? "ELIZA_RUNTIME=node was requested, but Node.js was not found in PATH." : "Node.js was selected for the API runtime, but Node.js was not found in PATH.", ); } const pathCandidates = (env.PATH ?? "") .split(path.delimiter) .filter(Boolean) .map((dir) => path.join(dir, process.platform === "win32" ? "node.exe" : "node"), ); const candidates = [ env.ELIZA_NODE_PATH, env.npm_node_execpath, ...pathCandidates, "/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node", ].filter(Boolean); return { runtime: "node", command: resolveNodeExecPathFromCandidates({ candidates, explicitNodePath: env.ELIZA_NODE_PATH, platform: process.platform, }), }; } const visionDepsRetryCommand = [ "node", path.relative(cwd, visionDepsScriptPath) || visionDepsScriptPath, `--name=${cliName}`, ] .map(shellQuoteArg) .join(" "); // --------------------------------------------------------------------------- // ANSI colors — raw escape sequences so we don't need chalk in this .mjs file. // --------------------------------------------------------------------------- const supportsColor = process.env.FORCE_COLOR !== "0" && process.env.NO_COLOR === undefined && process.stdout.isTTY; const GREEN = supportsColor ? "\x1b[38;2;0;255;65m" : ""; const ORANGE = supportsColor ? "\x1b[38;2;255;165;0m" : ""; const DIM = supportsColor ? "\x1b[2m" : ""; const RESET = supportsColor ? "\x1b[0m" : ""; function green(text) { return `${GREEN}${text}${RESET}`; } function orange(text) { return `${ORANGE}${text}${RESET}`; } function dim(text) { return `${DIM}${text}${RESET}`; } // --------------------------------------------------------------------------- // Runtime detection — prefer bun when available, fall back to node/npx. // --------------------------------------------------------------------------- function which(cmd) { const pathEnv = process.env.PATH ?? ""; if (!pathEnv) return null; const dirs = pathEnv.split(path.delimiter).filter(Boolean); const isWindows = process.platform === "win32"; const pathext = isWindows ? process.env.PATHEXT : ""; const exts = isWindows ? pathext?.length ? pathext.split(";").filter(Boolean) : [".EXE", ".CMD", ".BAT", ".COM"] : [""]; for (const dir of dirs) { const candidates = [cmd]; if (isWindows) { const lowerCmd = cmd.toLowerCase(); for (const ext of exts) { const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`; if (!lowerCmd.endsWith(normalizedExt.toLowerCase())) { candidates.push(cmd + normalizedExt); } } } for (const name of candidates) { const candidate = path.join(dir, name); if (existsSync(candidate)) return candidate; } } return null; } const forceNodeRuntime = process.env.ELIZA_FORCE_NODE === "1"; const hasNode = !!which("node"); const hasBun = !forceNodeRuntime && !!which("bun") && !!which("bunx"); if (!hasBun && !which("npx")) { console.error( 'Neither "bun" nor "npx" was found in your PATH. ' + "Install Bun or Node.js with npx to run this dev script.", ); process.exit(1); } // --------------------------------------------------------------------------- // Stealth import config // --------------------------------------------------------------------------- // coerceBoolean — imported from ./lib/dev-ui-onchain.mjs function resolveElizaStateDir() { const explicitStateDir = process.env.ELIZA_STATE_DIR?.trim(); if (explicitStateDir) return path.resolve(explicitStateDir); const xdgStateHome = process.env.XDG_STATE_HOME?.trim(); const stateHome = xdgStateHome ? path.isAbsolute(xdgStateHome) ? xdgStateHome : path.join(os.homedir(), xdgStateHome) : path.join(os.homedir(), ".local", "state"); return path.join(stateHome, resolveElizaNamespace()); } function resolveElizaNamespace() { return process.env.ELIZA_NAMESPACE?.trim() || cliName || "eliza"; } function resolveElizaConfigPath() { const explicitConfigPath = process.env.ELIZA_CONFIG_PATH?.trim(); if (explicitConfigPath) { return path.resolve(explicitConfigPath); } return path.join(resolveElizaStateDir(), `${resolveElizaNamespace()}.json`); } function loadElizaConfigForDev() { const configPath = resolveElizaConfigPath(); if (!existsSync(configPath)) return null; try { const raw = readFileSync(configPath, "utf-8"); return JSON5.parse(raw); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.warn( `${green(logPrefix)} Failed to parse config at ${configPath}: ${msg}`, ); return null; } } function readPluginStealthFlag(entries, ids) { if (!entries || typeof entries !== "object") return null; for (const id of ids) { const entry = entries[id]; if (!entry || typeof entry !== "object") continue; const config = entry.config; if (!config || typeof config !== "object") continue; const stealthFlag = config.stealthImport ?? config.enableStealthImport ?? config.enableStealth; const parsed = coerceBoolean(stealthFlag); if (parsed !== null) return parsed; } return null; } function _formatRelativeImportPath(relativePath) { const normalized = relativePath.split(path.sep).join("/"); return normalized.startsWith("./") ? normalized : `./${normalized}`; } function resolveStealthImportPath(devCwd, candidatePaths) { for (const candidatePath of candidatePaths) { const absPath = path.join(devCwd, candidatePath); if (existsSync(absPath)) { // Return the absolute path so the value stays valid regardless of // which cwd the API child gets spawned in. The API child is anchored // at the eliza/ submodule (see apiSpawnCwd below), so a path computed // relative to the outer eliza cwd would resolve to a non-existent // `eliza/eliza/...` from the child's perspective. return absPath; } } return null; } function addStealthImport(imports, label, candidatePaths) { const resolvedPath = resolveStealthImportPath(cwd, candidatePaths); if (resolvedPath) { imports.push(resolvedPath); return; } console.warn( ` ${green(logPrefix)} ${orange( `${label} stealth requested but no preload file was found. Tried: ${candidatePaths.join(", ")}`, )}`, ); } function resolveStealthImportFlags() { let openaiFlag = coerceBoolean(process.env.ELIZA_ENABLE_OPENAI_STEALTH); let claudeFlag = coerceBoolean(process.env.ELIZA_ENABLE_CLAUDE_STEALTH); const globalFlag = coerceBoolean(process.env.ELIZA_ENABLE_STEALTH_IMPORTS); if (globalFlag !== null) { openaiFlag = globalFlag; claudeFlag = globalFlag; } const config = loadElizaConfigForDev(); if (config && typeof config === "object") { const feature = config.features?.stealthImports; if (typeof feature === "boolean") { if (openaiFlag === null) openaiFlag = feature; if (claudeFlag === null) claudeFlag = feature; } else if (feature && typeof feature === "object") { const enabled = coerceBoolean(feature.enabled); if (enabled !== null) { if (openaiFlag === null) openaiFlag = enabled; if (claudeFlag === null) claudeFlag = enabled; } const openaiFeature = coerceBoolean(feature.openai) ?? coerceBoolean(feature.codex); const claudeFeature = coerceBoolean(feature.claude) ?? coerceBoolean(feature.anthropic); if (openaiFeature !== null && openaiFlag === null) { openaiFlag = openaiFeature; } if (claudeFeature !== null && claudeFlag === null) { claudeFlag = claudeFeature; } } const pluginEntries = config.plugins?.entries; const openaiPluginStealth = readPluginStealthFlag(pluginEntries, [ "openai", "@elizaos/plugin-openai", "openai-codex-stealth", ]); const claudePluginStealth = readPluginStealthFlag(pluginEntries, [ "anthropic", "@elizaos/plugin-anthropic", "claude-code-stealth", ]); if (openaiPluginStealth !== null && openaiFlag === null) { openaiFlag = openaiPluginStealth; } if (claudePluginStealth !== null && claudeFlag === null) { claudeFlag = claudePluginStealth; } } // Auto-detect subscription credentials: if the user has logged in via // a subscription provider, enable the corresponding stealth interceptor // automatically (unless explicitly disabled above). const stateDir = resolveElizaStateDir(); if (openaiFlag === null) { const codexAuthPath = path.join(stateDir, "auth", "openai-codex.json"); if (existsSync(codexAuthPath)) { openaiFlag = true; } } if (claudeFlag === null) { const anthropicAuthPath = path.join( stateDir, "auth", "anthropic-subscription.json", ); if (existsSync(anthropicAuthPath)) { claudeFlag = true; } } return { openai: openaiFlag === true, claude: claudeFlag === true, }; } // --------------------------------------------------------------------------- // Output filters for API server logs. // --------------------------------------------------------------------------- const SUPPRESS_RE = /^\s*(Info|Warn|Debug|Trace)\s/; const SUPPRESS_UNSTRUCTURED_RE = /^\[dotenv[@\d]/; const STARTUP_RE = /\[eliza(?:-api)?\]|\[(eliza|eliza)(?:-api)?\]|runtime bootstrap|\[(eliza|eliza)(?:-api)?\]|runtime ready|\[(eliza|eliza)(?:-api)?\]|runtime created|api server ready|plugin.*load|startup.*complete|\d+ms|\[PTYService|\[SwarmCoordinator\]|Triage:/i; function createErrorFilter(dest) { let buf = ""; return (chunk) => { buf += chunk.toString(); const lines = buf.split("\n"); buf = lines.pop(); for (const line of lines) { if ( line.trim() && !SUPPRESS_RE.test(line) && !SUPPRESS_UNSTRUCTURED_RE.test(line) ) { dest.write(`${line}\n`); } } }; } function createStartupFilter(dest) { let buf = ""; let lastLine = ""; let repeatCount = 0; return (chunk) => { buf += chunk.toString(); const lines = buf.split("\n"); buf = lines.pop(); for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; if (SUPPRESS_UNSTRUCTURED_RE.test(trimmed)) continue; if (/embedding dimensions mismatch/i.test(trimmed)) continue; if (trimmed === lastLine) { repeatCount += 1; if (repeatCount > 2) continue; } else { lastLine = trimmed; repeatCount = 0; } const isWarnOrError = /^\s*(Warn|Error)\s/.test(trimmed); const isStartupLine = STARTUP_RE.test(trimmed); if (isWarnOrError || isStartupLine) { dest.write(`${line}\n`); } } }; } // --------------------------------------------------------------------------- // Port cleanup — force-kill zombie processes on our dev ports // --------------------------------------------------------------------------- function killPort(port) { try { if (process.platform === "win32") { const out = execSync( `netstat -ano | findstr :${port} | findstr LISTENING`, { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }, ); const pids = new Set( out .split("\n") .map((l) => l.trim().split(/\s+/).pop()) .filter(Boolean), ); for (const pid of pids) { try { execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" }); } catch { /* already dead */ } } } else { execSync(`lsof -ti :${port} | xargs kill -9 2>/dev/null`, { stdio: "ignore", }); } } catch { // No process found — port is clean } } // --------------------------------------------------------------------------- // Wait for a TCP port to accept connections // --------------------------------------------------------------------------- function waitForPort(port, { timeout = 300_000, interval = 500 } = {}) { return new Promise((resolve, reject) => { const deadline = Date.now() + timeout; let activeSocket = null; function attempt() { if (Date.now() > deadline) { if (activeSocket) { activeSocket.destroy(); activeSocket = null; } reject( new Error( `Timed out waiting for port ${port} after ${timeout / 1000}s`, ), ); return; } activeSocket = createConnection({ port, host: "127.0.0.1" }); activeSocket.once("connect", () => { activeSocket.destroy(); activeSocket = null; resolve(); }); activeSocket.once("error", () => { activeSocket.destroy(); activeSocket = null; setTimeout(attempt, interval); }); } attempt(); }); } function isPortListening(port) { return new Promise((resolve) => { const socket = createConnection({ port, host: "127.0.0.1" }); let settled = false; const done = (value) => { if (settled) return; settled = true; socket.destroy(); resolve(value); }; socket.once("connect", () => done(true)); socket.once("error", () => done(false)); socket.setTimeout(2000, () => done(false)); }); } // --------------------------------------------------------------------------- // Wait for the agent runtime to be ready (not just the TCP port). // Polls GET /api/health and resolves when { ready: true }. // Handles supervised hot-reload restarts gracefully (connection resets → retry). // --------------------------------------------------------------------------- async function waitForAgentReady( port, { timeout = 300_000, interval = 400 } = {}, ) { const deadline = Date.now() + timeout; const url = `http://127.0.0.1:${port}/api/health`; while (Date.now() < deadline) { try { const res = await fetch(url, { signal: AbortSignal.timeout(3000) }); if (res.ok) { const data = await res.json(); if (data.ready) return; } } catch { // API not up yet or restarting — keep polling. } await new Promise((r) => setTimeout(r, interval)); } throw new Error( `Agent runtime not ready after ${timeout / 1000}s (port ${port} is up but /api/health never reported ready)`, ); } // Single quick health probe — true only when the agent reports ready. Used to // gate hot-reload restarts so the watcher only ever bounces a HEALTHY agent, // never one that is still booting (a booting agent already picks up the latest // source, and killing it mid-boot would loop). async function isAgentReadyNow(port) { try { const resp = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1500), }); if (!resp.ok) return false; const body = await resp.json().catch(() => null); return body?.ready === true; } catch { return false; } } // --------------------------------------------------------------------------- // Orphan cleanup (startup only) — never kills arbitrary Bun; PID/name-wide pkill is avoided. // Only processes whose command line ties them to this repo or Eliza workspace dirs. // --------------------------------------------------------------------------- function killOrphanedWorkspaceProcesses() { if (process.platform === "win32") return; // spawn-helper is Unix only let repoRoot; try { repoRoot = realpathSync(cwd); } catch { repoRoot = cwd; } let psOut; try { psOut = execSync("ps axo pid=,command=", { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["pipe", "pipe", "ignore"], }); } catch { return; } const workspaceDirRe = /\.eliza\/workspaces|\.elizaai\/workspaces|\.eliza\/workspaces|\.elizaai\/workspaces/i; const ptyWorkerRe = /pty-worker\.js/i; const workspacePids = []; const ptyPids = []; for (const line of psOut.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; const sp = trimmed.indexOf(" "); if (sp < 1) continue; const pidStr = trimmed.slice(0, sp).trim(); const cmd = trimmed.slice(sp + 1); const pid = Number.parseInt(pidStr, 10); if (!Number.isFinite(pid) || pid <= 0) continue; if (workspaceDirRe.test(cmd)) { workspacePids.push(pid); continue; } if (ptyWorkerRe.test(cmd) && cmd.includes(repoRoot)) { ptyPids.push(pid); } } const killPids = (label, pids) => { if (!pids.length) return; console.log(`[dev-ui] Killing ${pids.length} orphaned ${label}…`); try { execSync(`kill -9 ${pids.join(" ")} 2>/dev/null`, { stdio: "ignore" }); } catch { /* ignore */ } }; killPids("workspace process(es)", workspacePids); killPids("repo-scoped pty-worker(s)", ptyPids); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- killOrphanedWorkspaceProcesses(); killPort(UI_PORT); // Ensure vision dependencies are installed (non-blocking — just probes/installs // an optional camera binary; nothing in the boot path depends on its result, so // it must not gate API/Vite startup). const visionDepsChild = spawn( "node", [visionDepsScriptPath, `--name=${cliName}`], { stdio: "inherit" }, ); visionDepsChild.on("error", (error) => { process.env.ELIZA_VISION_DEPS_STATUS = "degraded"; console.warn(buildVisionDepsFailureMessage(error, visionDepsRetryCommand)); }); visionDepsChild.on("exit", (code) => { if (code !== 0) { process.env.ELIZA_VISION_DEPS_STATUS = "degraded"; console.warn( buildVisionDepsFailureMessage( new Error(`vision deps check exited with code ${code}`), visionDepsRetryCommand, ), ); } }); if (!uiOnly) { killPort(API_PORT); } let apiProcess = null; let viteProcess = null; /** @type {{ count: number, close: () => void } | null} */ let sourceWatcher = null; let shuttingDown = false; let vitePluginBuildAttempted = false; let viteRestartCount = 0; let viteRestartTimer = null; let viteHealthTimer = null; let viteStartedAt = 0; // Vite cold-start of the full raw-source module graph can exceed 60s on slow // shared CI runners (2-4 cores). Allow CI to widen the health-check kill window // via env; dev machines keep the 60s default. const VITE_READY_BUDGET_MS = Number(process.env.ELIZA_DEV_VITE_READY_BUDGET_MS) || 60_000; function terminateChild(proc, signal = "SIGTERM") { if (!proc) return; const sig = signal === "SIGKILL" ? "SIGKILL" : "SIGTERM"; signalSpawnedProcessTree(proc, sig); } function cleanup(exitCode = 0) { if (shuttingDown) { console.log("\n[eliza] Force exit."); process.exit(exitCode === 0 ? 1 : exitCode); return; } shuttingDown = true; if (sourceWatcher) { sourceWatcher.close(); sourceWatcher = null; } terminateChild(viteProcess, "SIGTERM"); terminateChild(apiProcess, "SIGTERM"); if (viteRestartTimer) { clearTimeout(viteRestartTimer); viteRestartTimer = null; } if (viteHealthTimer) { clearTimeout(viteHealthTimer); viteHealthTimer = null; } setTimeout(() => { terminateChild(viteProcess, "SIGKILL"); terminateChild(apiProcess, "SIGKILL"); }, 1500).unref(); setTimeout(() => { process.exit(exitCode); }, 1800).unref(); } process.on("SIGINT", () => cleanup(0)); process.on("SIGTERM", () => cleanup(0)); if (process.platform !== "win32") { process.on("SIGHUP", () => cleanup(0)); } function buildCapacitorPluginsIfNeeded(childEnv) { const pkgPath = path.join(cwd, appDir, "package.json"); if (vitePluginBuildAttempted || !existsSync(pkgPath)) { return; } vitePluginBuildAttempted = true; try { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); if (pkg.scripts?.["plugin:build"]) { const appAbs = path.join(cwd, appDir); const pluginsDir = NATIVE_PLUGINS_ROOT; const forcePlugins = process.env.ELIZA_DEV_PLUGIN_BUILD === "1" || process.env.ELIZA_DEV_PLUGIN_BUILD === "always"; const skipPlugins = process.env.ELIZA_DEV_PLUGIN_BUILD === "0" || process.env.ELIZA_SKIP_PLUGIN_BUILD === "1" || (process.env.ELIZA_DEV_SOURCE === "1" && !forcePlugins); if (skipPlugins) { const skipReason = process.env.ELIZA_DEV_SOURCE === "1" && !forcePlugins ? "ELIZA_DEV_SOURCE=1" : "ELIZA_SKIP_PLUGIN_BUILD=1"; console.log( ` ${green(logPrefix)} ${dim(`Skipping Capacitor plugin build (${skipReason}).`)}`, ); } else if ( forcePlugins || capacitorPluginsBuildNeeded(pluginsDir, CAPACITOR_PLUGIN_NAMES) ) { console.log(` ${green(logPrefix)} Building plugins for ${appDir}...`); execSync(hasBun ? "bun run plugin:build" : "npm run plugin:build", { cwd: appAbs, stdio: "inherit", env: childEnv, }); } else { console.log( ` ${green(logPrefix)} ${dim("Capacitor plugins dist up to date (skip rimraf/tsc/rollup). Set ELIZA_DEV_PLUGIN_BUILD=1 to force.")}`, ); } } } catch (err) { console.error( ` ${green(logPrefix)} Failed to build plugins for ${appDir}:`, err.message, ); } } function startVite() { const childEnv = createDevChildEnv(process.env); const viteCmd = hasBun ? "bun" : "npx"; const viteForce = process.env.ELIZA_VITE_FORCE === "1" || process.env.ELIZA_VITE_FORCE === "1"; const viteArgs = hasBun ? viteForce ? ["--bun", "vite", "--force", "--port", String(UI_PORT)] : ["--bun", "vite", "--port", String(UI_PORT)] : viteForce ? ["vite", "--force", "--port", String(UI_PORT)] : ["vite", "--port", String(UI_PORT)]; if (viteForce) { console.log( ` ${green(logPrefix)} ${dim("Vite --force (ELIZA_VITE_FORCE=1): re-optimizing deps.")}`, ); } viteStartedAt = Date.now(); viteProcess = spawn(viteCmd, viteArgs, { cwd: path.join(cwd, appDir), env: { ...childEnv, NODE_ENV: "development", ELIZA_NAMESPACE: cliName, ELIZA_UI_PORT: String(UI_PORT), ELIZA_API_PORT: String(API_PORT), ELIZA_PORT: String(UI_PORT), ELIZA_HOME_API_PORT: String(API_PORT), }, stdio: ["inherit", "pipe", "pipe"], }); viteProcess.on("error", (err) => { console.error(` ${green(logPrefix)} Failed to start vite: ${err.message}`); cleanup(1); }); viteProcess.stdout.on("data", (data) => { const text = data.toString(); if (text.includes("ready")) { const securitySettingsUrl = `http://localhost:${UI_PORT}/settings#security`; console.log( `\n ${green(logPrefix)} ${orange(`http://localhost:${UI_PORT}/`)}\n ${green(logPrefix)} ${dim("Local access: no password required on this machine")}\n ${green(logPrefix)} ${dim(`Security settings: ${securitySettingsUrl}`)}\n`, ); } }); viteProcess.stderr.on("data", (data) => { process.stderr.write(data); }); // Capacitor plugin build runs AFTER vite is spawned, not before. The // recursive native-plugin source walk (capacitorPluginsBuildNeeded) is a // synchronous ~1s parent-side cost that previously delayed first paint by // blocking before spawn. Capacitor plugins are not in the web SPA module // graph, so the dist appearing slightly later is safe for the dashboard this // vite instance serves — the vite child now warms up in parallel while the // walk (and any rebuild) runs. buildCapacitorPluginsIfNeeded(childEnv); viteProcess.on("exit", (code, signal) => { if (shuttingDown) return; viteProcess = null; if (viteHealthTimer) { clearTimeout(viteHealthTimer); viteHealthTimer = null; } const exitLabel = signal ? `signal ${signal}` : `code ${code === null ? "null" : code}`; viteRestartCount += 1; if (viteRestartCount > 5) { console.error( `${green(logPrefix)} vite exited with ${exitLabel} ${viteRestartCount} times — giving up`, ); cleanup(code ?? 1); return; } console.error( `${green(logPrefix)} vite exited with ${exitLabel} — restarting UI (attempt ${viteRestartCount}/5)`, ); viteRestartTimer = setTimeout(() => { viteRestartTimer = null; if (!shuttingDown) startVite(); }, 400); }); scheduleViteHealthCheck(); } function scheduleViteHealthCheck(delayMs = 15_000) { if (viteHealthTimer) clearTimeout(viteHealthTimer); viteHealthTimer = setTimeout(async () => { viteHealthTimer = null; if (shuttingDown || !viteProcess) return; const listening = await isPortListening(UI_PORT); if (!listening) { const ageMs = Date.now() - viteStartedAt; if (ageMs > VITE_READY_BUDGET_MS) { console.log( ` ${green(logPrefix)} ${dim( `Vite process is running but port ${UI_PORT} is not accepting connections — restarting UI.`, )}`, ); terminateChild(viteProcess, "SIGTERM"); return; } } scheduleViteHealthCheck(); }, delayMs); viteHealthTimer.unref(); } if (uiOnly) { startVite(); } else { console.log(`${orange(`\n${cliName} dev mode`)}\n`); console.log(` ${green(logPrefix)} ${green("Starting dev server...")}\n`); console.log( ` ${green(logPrefix)} ${dim( `API log level=${devLogLevel}${ quietApiLogs ? " (errors only)" : verboseApiLogs ? " (verbose)" : " (startup + warnings/errors)" }`, )}`, ); // Security default: stealth shims are disabled unless explicitly enabled // via env vars or plugin config in eliza.json. const stealth = resolveStealthImportFlags(); const nodeStealthImports = []; if (stealth.openai) { addStealthImport(nodeStealthImports, "OpenAI Codex", [ "packages/app-core/scripts/openai-codex-stealth.mjs", "eliza/packages/app-core/scripts/openai-codex-stealth.mjs", "openai-codex-stealth.mjs", ]); } if (stealth.claude) { addStealthImport(nodeStealthImports, "Claude Code", [ "packages/agent/src/auth/claude-code-stealth-preload.ts", "eliza/packages/agent/src/auth/claude-code-stealth-preload.ts", "packages/app-core/scripts/claude-code-stealth.mjs", "eliza/packages/app-core/scripts/claude-code-stealth.mjs", "claude-code-stealth.mjs", ]); } if (nodeStealthImports.length > 0) { console.log( ` ${green(logPrefix)} ${dim(`Stealth imports enabled: ${nodeStealthImports.join(", ")}`)}`, ); } let devServerEntry = resolveDevServerEntryRelativePath(cwd); // Resolve to absolute so it stays valid when we anchor the API child cwd // at eliza/ for workspace lookup (see apiSpawnCwd below). if (!path.isAbsolute(devServerEntry)) { devServerEntry = path.resolve(cwd, devServerEntry); } // Agent hot-reload is driven by a source-only watcher (started after the // supervisor below), not by `node --watch`. The watcher never sees `dist/`, // so it is immune to concurrent-build churn. const useWatch = !skipSourceWatch; const { runtime: apiRuntime, command: apiRuntimeCmd } = resolveApiRuntimeCommand(process.env); const apiRuntimeIsBun = apiRuntime === "bun"; const apiCmd = [ apiRuntimeCmd, "--conditions=eliza-source", ...(apiRuntimeIsBun ? [] : ["--import", "tsx"]), // Bun preloads modules with `--preload`; Node uses `--import`. Passing // `--import` to Bun breaks startup whenever a stealth shim is enabled // (OpenAI Codex / Claude Code). ...nodeStealthImports.flatMap((filePath) => [ apiRuntimeIsBun ? "--preload" : "--import", filePath, ]), devServerEntry, ]; // The API server resolves @elizaos/* deps via Bun workspace lookup, which // walks up from cwd looking for a package.json with a `workspaces` field. // When running from the eliza-style outer repo (cwd contains an `eliza/` // submodule), the outer package.json's `workspaces: ["apps/*"]` excludes // eliza's workspace packages — so plugin-x402, plugin-streaming, etc. fail // to resolve. Spawn the API child with cwd anchored at eliza/ so its // workspaces ([packages/*, plugins/*]) are visible. const elizaSubmoduleRoot = path.join(cwd, "eliza"); const apiSpawnCwd = existsSync(path.join(elizaSubmoduleRoot, "package.json")) ? elizaSubmoduleRoot : cwd; const childEnv = createDevChildEnv(process.env); // V8 bytecode cache for the Node API runtime. The runtime is deliberately // Node (not Bun) for node: built-ins, so this persists compiled module // bytecode across boots and hot-reload restarts, trimming plugin-import cost. // Node 22.8+ honors it; older node and Bun ignore the var (safe no-op). // Pinned under the state dir (not os.tmpdir()) so an OS temp reap doesn't // wipe the warm ~100MB cache and force a multi-second cold recompile on the // next boot. Content-hash-keyed, so a stale entry self-invalidates. const apiCompileCacheDir = childEnv.NODE_COMPILE_CACHE?.trim() || path.join( process.env.ELIZA_STATE_DIR?.trim() || path.join(os.homedir(), ".local", "state", cliName), "cache", "node-compile", ); // Opt-in (ELIZA_PIN_TSX_CACHE=1): pin tsx's content-hashed transpile cache // under the state dir. tsx 4.x has no env to relocate its disk cache; the dir // derives from os.tmpdir(), which honors TMPDIR. Same rationale as the // NODE_COMPILE_CACHE pin above — an OS temp reap (>3-day idle, /tmp sweep) // would otherwise wipe the warm transpile cache and force a multi-second cold // recompile. Default (flag unset) leaves TMPDIR untouched, so this only // changes WHERE transpile output caches, never WHAT runs. const pinTsxCache = process.env.ELIZA_PIN_TSX_CACHE?.trim() === "1"; let apiTmpDir; if (pinTsxCache) { apiTmpDir = path.join(path.dirname(apiCompileCacheDir), "tmp"); mkdirSync(apiTmpDir, { recursive: true }); } const apiSpawnEnv = extendNodePathEnv( { ...childEnv, NODE_ENV: "development", NODE_COMPILE_CACHE: apiCompileCacheDir, ...(pinTsxCache ? { TMPDIR: apiTmpDir } : {}), NODE_OPTIONS: [ "--disable-warning=ExperimentalWarning", // Bound the dev API child's V8 old-space so a runaway JS module/state // graph fails fast (the api-supervisor restarts it) instead of slowly // swap-dying. Healthy boots sit ~230-440MB; only a stuck/looping child // exceeds this. Most of a stuck child's RSS is native/WASM (PGlite, // llama) living OUTSIDE old-space, so this is a backstop, not the // primary bound. Override with ELIZA_DEV_API_MAX_OLD_SPACE_MB. `--max-old-space-size=${process.env.ELIZA_DEV_API_MAX_OLD_SPACE_MB?.trim() || "4096"}`, // Let the dev heap-report timer force a GC for an accurate heapUsed. "--expose-gc", ].reduce(appendNodeOption, childEnv.NODE_OPTIONS), ELIZA_NAMESPACE: cliName, ELIZA_API_PORT: String(API_PORT), ELIZA_UI_PORT: String(UI_PORT), ELIZA_PORT: String(UI_PORT), ELIZA_DEV_SOURCE_WATCH: skipSourceWatch ? "0" : "1", ELIZA_HEADLESS: "1", ELIZA_DEV_AUTH_BYPASS: "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 dashboard reaches first // paint before they finish. Brief feature-route 404 window after "ready" // is the trade-off; acceptable for dev. Explicit env always wins. ELIZA_DEFER_APP_ROUTES: process.env.ELIZA_DEFER_APP_ROUTES?.trim() || "1", LOG_LEVEL: devLogLevel, // Dev boots happen on developer machines that may be busy (concurrent // builds, test suites, other agents). plugin-sql pulls in native pglite // bindings whose dynamic import can exceed the 30s production default // under CPU contention, false-failing the whole boot into a retry loop. // Give dev generous headroom; an explicit env value still wins. ELIZA_PLUGIN_BOOT_TIMEOUT_MS: process.env.ELIZA_PLUGIN_BOOT_TIMEOUT_MS ?? "90000", }, apiSpawnCwd, ); if (apiRuntimeCmd !== "node" && path.isAbsolute(apiRuntimeCmd)) { apiSpawnEnv.PATH = `${path.dirname(apiRuntimeCmd)}${path.delimiter}${apiSpawnEnv.PATH ?? ""}`; } // The first launch is a cold boot; every later spawn is a hot-reload respawn // (the source watcher bounced the child). Marking respawns lets the agent skip // boot-tail work that only matters once — e.g. voice warmup, which otherwise // re-fires a cloud TTS call and fully reloads the native whisper model on // every edit, flooding the dev log. let apiLaunchCount = 0; const apiSupervisor = createApiSupervisor({ spawnChild: () => { const apiProcessSpawnedAtMs = String(Date.now()); const isHotReload = apiLaunchCount++ > 0; return spawn(apiCmd[0], apiCmd.slice(1), { cwd: apiSpawnCwd, env: { ...apiSpawnEnv, [API_PROCESS_SPAWNED_AT_ENV]: apiProcessSpawnedAtMs, [PROCESS_SPAWNED_AT_ENV]: apiProcessSpawnedAtMs, ...(isHotReload ? { ELIZA_DEV_IS_HOT_RELOAD: "1" } : {}), }, stdio: ["inherit", "pipe", "pipe"], }); }, onSpawn: (child) => { apiProcess = child; child.on("error", (err) => { console.error( ` ${green(logPrefix)} Failed to start API server: ${err.message}`, ); cleanup(1); }); if (quietApiLogs) { child.stderr.on("data", createErrorFilter(process.stderr)); child.stdout.on("data", () => {}); } else if (verboseApiLogs) { child.stderr.on("data", (data) => { process.stderr.write(data); }); child.stdout.on("data", (data) => { process.stdout.write(data); }); } else { child.stderr.on("data", createStartupFilter(process.stderr)); child.stdout.on("data", createStartupFilter(process.stdout)); } }, onExit: () => { apiProcess = null; }, onGiveUp: (code) => cleanup(code ?? 1), isShuttingDown: () => shuttingDown, // Reuse the dev process-tree killer so a hot-reload restart takes the // API child's descendants (coding sub-agents, native helpers) with it. terminate: (child, signal) => terminateChild(child, signal), log: (message) => console.log(`\n ${green(logPrefix)} ${message}`), warn: (message) => console.error(`\n ${green(logPrefix)} ${message}`), }); apiSupervisor.start(); // Agent hot-reload: bounce the API child when backend source changes. The // watcher only sees `*/src` (never `dist/`), and a restart fires only when the // agent is currently healthy — so a build, or a change during boot, can never // kill an in-progress boot. A booting agent already loads the latest source. if (useWatch) { sourceWatcher = startAgentSourceWatcher({ root: apiSpawnCwd, onChange: (relPath, changedCount) => { // A git reset/checkout/branch-switch or a build rewrites many files at // once — that is churn, not a developer edit. Don't bounce the agent // for it (the boots/502s would storm); a single restart can't track a // moving tree anyway. A real edit touches one or a few files. if (changedCount > HOT_RELOAD_BULK_CHANGE_LIMIT) { console.log( `\n ${green(logPrefix)} ${dim(`Bulk change (${changedCount} files, e.g. ${relPath}) — skipping agent reload (looks like a reset/build).`)}`, ); return; } void (async () => { if (!(await isAgentReadyNow(API_PORT))) return; console.log( `\n ${green(logPrefix)} ${dim(`Source change (${relPath}) — reloading agent…`)}`, ); apiSupervisor.restart(); })(); }, onError: (dir, err) => { console.log( ` ${green(logPrefix)} ${dim(`Hot-reload watch skipped for ${path.relative(apiSpawnCwd, dir)}: ${err.message}`)}`, ); }, }); console.log( ` ${green(logPrefix)} ${dim(`Agent hot-reload on: watching ${sourceWatcher.count} source dirs (set ELIZA_DEV_NO_WATCH=1 to disable).`)}`, ); } // Start Vite immediately, in parallel with the API boot. The dev server only // proxies to the API at request time — it has no startup dependency on the // API being up — so gating it behind waitForPort(API_PORT) just stacked the // full runtime+plugin boot in front of first paint. Booting both at once // gives the browser a UI as soon as Vite is ready, and requests resolve once // the API comes up moments later. startVite(); const startTime = Date.now(); let phase = "port"; const dots = setInterval(() => { const elapsed = ((Date.now() - startTime) / 1000).toFixed(0); const msg = phase === "port" ? "Waiting for API server..." : "Waiting for agent to be ready..."; process.stdout.write( `\r ${green(logPrefix)} ${green(`${msg} ${dim(`${elapsed}s`)}`)}`, ); }, 1000); waitForPort(API_PORT) .then(() => { const portElapsed = ((Date.now() - startTime) / 1000).toFixed(1); process.stdout.write( `\r ${green(logPrefix)} ${green(`API port open`)} ${dim(`(${portElapsed}s)`)} \n`, ); phase = "agent"; return waitForAgentReady(API_PORT); }) .then(() => { clearInterval(dots); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log( `\r ${green(logPrefix)} ${green(`Agent ready`)} ${dim(`(${elapsed}s)`)} `, ); }) .catch((err) => { clearInterval(dots); console.error(`\n ${green(logPrefix)} ${err.message}`); cleanup(1); }); }