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

1422 lines
47 KiB
JavaScript

#!/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 <slot>
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=<name> 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);
});
}