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
1422 lines
47 KiB
JavaScript
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);
|
|
});
|
|
}
|