diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2f3dc4e0791..00a1c5f8044 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,11 +10,16 @@ Install workspace dependencies from the repo root so `apps/desktop`, `apps/dashb npm install ``` -Use the normal Hermes Python environment for local runs: +For Python, you have two options: + +**Option A — let the desktop provision it for you (recommended for first-time setup):** just run `npm run dev`. On first launch the desktop creates a venv at `HERMES_HOME/hermes-agent/venv` and runs `pip install -e .` against the resolved Hermes source automatically. Requires Python 3.11+ on `PATH`. + +**Option B — share an existing CLI install:** if you already ran `scripts/install.ps1` / `scripts/install.sh`, that's the same layout the desktop uses. The desktop reuses your existing venv and editable install — no extra steps. See [Runtime Bootstrap](#runtime-bootstrap) below for details. + +If you're hacking on Hermes from a clone outside `HERMES_HOME/hermes-agent`, point the desktop at it explicitly: ```bash -source .venv/bin/activate # or: source venv/bin/activate -python -m pip install -e . +HERMES_DESKTOP_HERMES_ROOT=/path/to/your/clone npm run dev ``` ## Development @@ -33,11 +38,14 @@ HERMES_DESKTOP_HERMES_ROOT=/path/to/hermes-agent npm run dev HERMES_DESKTOP_PYTHON=/path/to/python npm run dev HERMES_DESKTOP_CWD=/path/to/project npm run dev HERMES_DESKTOP_IGNORE_EXISTING=1 npm run dev +HERMES_HOME=/tmp/throwaway-hermes-home npm run dev HERMES_DESKTOP_BOOT_FAKE=1 npm run dev HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=900 npm run dev ``` -`HERMES_DESKTOP_IGNORE_EXISTING=1` skips any `hermes` CLI already on `PATH`, which is useful when testing the bundled/runtime bootstrap path. +`HERMES_DESKTOP_IGNORE_EXISTING=1` skips any `hermes` CLI already on `PATH`, which is useful when testing the factory-image bootstrap path. + +`HERMES_HOME` overrides the install root (default: `%LOCALAPPDATA%\hermes` on Windows, `~/.hermes` elsewhere) — handy for sandboxed dev runs that shouldn't touch your real config. `HERMES_DESKTOP_BOOT_FAKE=1` adds deterministic per-phase delays to desktop startup so you can validate the startup overlay and progress bar. For convenience, `npm run dev:fake-boot` enables fake mode with defaults. @@ -126,14 +134,14 @@ npm run test:desktop:platforms `test:desktop:existing` builds the packaged app and opens it normally. It should use an existing `hermes` CLI if one is on `PATH`, preserving the user’s real `~/.hermes` config. -`test:desktop:fresh` builds the packaged app and launches it in a throwaway fresh-install sandbox. It sets `HERMES_DESKTOP_IGNORE_EXISTING=1`, points Electron `userData` at a temp dir, points `HERMES_HOME` at a temp dir, and launches through the bundled payload path without touching your real desktop runtime or `~/.hermes`. +`test:desktop:fresh` builds the packaged app and launches it in a throwaway fresh-install sandbox. It sets `HERMES_DESKTOP_IGNORE_EXISTING=1`, points Electron `userData` at a temp dir, points `HERMES_HOME` at a temp dir, and launches through the factory-image bootstrap path without touching your real desktop runtime or `~/.hermes`. `test:desktop:dmg` builds and opens the DMG. `test:desktop:platforms` runs platform bootstrap-path assertions, including: -- existing vs bundled runtime path selection semantics +- existing-CLI vs factory-image runtime path selection semantics - WSL2 protection against Windows `.exe/.cmd/.bat/.ps1` overrides -- platform-specific bundled runtime import checks (`winpty` vs `ptyprocess`) +- platform-specific runtime import checks (`winpty` vs `ptyprocess`) For fast reruns without rebuilding: @@ -154,36 +162,85 @@ Drag `Hermes` to Applications. If testing repeated installs, replace the existin ## Runtime Bootstrap -Packaged desktop startup resolves Hermes in this order: +Hermes Desktop shares its install layout with the CLI installers (`scripts/install.ps1`, `scripts/install.sh`) so a desktop-only user and a CLI-only user end up with the same files in the same places. -1. `HERMES_DESKTOP_HERMES_ROOT` -2. existing `hermes` CLI, unless `HERMES_DESKTOP_IGNORE_EXISTING=1` -3. bundled `Contents/Resources/hermes-agent` -4. dev repo source -5. installed `python -m hermes_cli.main` - -When the bundled path is used, Electron creates or reuses: +### Where things live ```text -~/Library/Application Support/Hermes/hermes-runtime +HERMES_HOME/ # %LOCALAPPDATA%\hermes (Windows) + # ~/.hermes (macOS / Linux) +├── hermes-agent/ # ACTIVE_HERMES_ROOT — the canonical install +│ ├── hermes_cli/, agent/, ... # Python source +│ ├── pyproject.toml # source of truth for deps +│ ├── venv/ # virtualenv (Scripts\python.exe on Windows, +│ │ # bin/python elsewhere) +│ └── .hermes-desktop-runtime.json # marker: schema version + pyproject hash +├── config.yaml # user config +├── .env # API keys +└── logs/ + ├── desktop.log # Electron-side boot log + ├── agent.log + ├── errors.log + └── gateway.log ``` -The runtime is validated before use. If required dashboard imports are missing, it reinstalls the desktop runtime dependencies and retries. +The factory image (`Contents/Resources/hermes-agent` on macOS, `resources\hermes-agent` on Windows) ships inside the `.app` / `.exe` and seeds `HERMES_HOME/hermes-agent` on first launch. + +### Resolution order + +The desktop resolves a Hermes backend in this order: + +1. `HERMES_DESKTOP_HERMES_ROOT` — explicit dev override. +2. Existing `hermes` CLI on PATH (skipped when `HERMES_DESKTOP_IGNORE_EXISTING=1`). +3. Repo source root — only when running `npm run dev` from a checkout. Takes precedence over `HERMES_HOME/hermes-agent` so devs always run their local edits. +4. `HERMES_HOME/hermes-agent` if it already exists (CLI installer or prior desktop launch). +5. Packaged + factory image present → sync factory → `HERMES_HOME/hermes-agent`, then use it. +6. Pip-installed `hermes_cli` module via system Python. + +### First-launch flow on a packaged install + +1. Sync factory image → `HERMES_HOME/hermes-agent`. Skipped if a `.git` directory exists at the destination (developer install) — never overwrites a user's local repo. +2. Create venv at `HERMES_HOME/hermes-agent/venv` using system Python (errors out with a Python-install hint if no Python 3.11+ is found). +3. `pip install -e HERMES_HOME/hermes-agent` — `pyproject.toml` is the single source of truth for dependencies. +4. Stamp `.hermes-desktop-runtime.json` with the schema version + pyproject hash + factory version. + +Subsequent launches compare the marker against the active `pyproject.toml` and skip steps 2-4 when nothing has changed. + +### Upgrades + +A new installer drops a new factory image. On next launch the marker mismatches → factory contents are copied over `HERMES_HOME/hermes-agent` (excluding `venv/`, `.git`, `__pycache__`, etc.), `pip install -e` re-runs to pick up new deps, the marker is re-stamped. The venv is preserved across upgrades to keep the upgrade fast when deps haven't moved. + +A user who installed via `scripts/install.ps1` / `scripts/install.sh` (so `HERMES_HOME/hermes-agent/.git` exists) is detected as a developer install and the desktop never overwrites their checkout — they keep using `hermes update` / `git pull` to update. ## Debugging Desktop boot logs are written to: ```text -~/Library/Application Support/Hermes/desktop.log +HERMES_HOME/logs/desktop.log # %LOCALAPPDATA%\hermes\logs\desktop.log on Windows + # ~/.hermes/logs/desktop.log on macOS / Linux ``` If the UI reports `Desktop boot failed`, check that log first. It includes the backend command output and recent Python traceback context. -To reset bundled runtime state: +To reset desktop runtime state (forces re-sync from the factory image and re-`pip install -e .` on next launch): ```bash -rm -rf "$HOME/Library/Application Support/Hermes/hermes-runtime" +# macOS / Linux +rm "$HOME/.hermes/hermes-agent/.hermes-desktop-runtime.json" + +# Windows (PowerShell) +Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-desktop-runtime.json" +``` + +For a full reset of just the Python venv (rare — usually only needed if the venv is broken): + +```bash +# macOS / Linux +rm -rf "$HOME/.hermes/hermes-agent/venv" + +# Windows (PowerShell) +Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes\hermes-agent\venv" ``` To reset stale macOS microphone permission prompts: diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 022cc842c0b..ef79a500db5 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -42,11 +42,55 @@ const IS_WINDOWS = process.platform === 'win32' const IS_WSL = isWslEnvironment() const APP_ROOT = app.getAppPath() const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') -const BUNDLED_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent') -const BUNDLED_VENV_ROOT = path.join(app.getPath('userData'), 'hermes-runtime') -const BUNDLED_VENV_MARKER = path.join(BUNDLED_VENV_ROOT, '.hermes-desktop-runtime.json') + +// HERMES_HOME — the user-facing root for everything Hermes-related. Mirrors +// scripts/install.ps1's $HermesHome and scripts/install.sh's $HERMES_HOME. +// +// Defaults: +// Windows: %LOCALAPPDATA%\hermes (matches install.ps1) +// macOS / Linux: ~/.hermes (matches install.sh) +// +// Special case for Windows: if the user has a legacy ~/.hermes directory +// (e.g., from a prior pip install or a manual setup) AND no +// %LOCALAPPDATA%\hermes yet, prefer the legacy path so we don't orphan their +// existing config / sessions / .env. New installs go to %LOCALAPPDATA%. +// +// HERMES_DESKTOP_USER_DATA_DIR (used by test:desktop:fresh) puts the sandbox +// HERMES_HOME beneath the throwaway userData dir so a fresh-install run never +// touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes. +function resolveHermesHome() { + if (process.env.HERMES_HOME) return path.resolve(process.env.HERMES_HOME) + if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') + if (IS_WINDOWS && process.env.LOCALAPPDATA) { + const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes') + const legacy = path.join(app.getPath('home'), '.hermes') + // Migrate transparently to LOCALAPPDATA, but honour an existing legacy + // ~/.hermes setup (no LOCALAPPDATA install yet) so users don't lose state. + if (!directoryExists(localappdata) && directoryExists(legacy)) return legacy + return localappdata + } + return path.join(app.getPath('home'), '.hermes') +} + +const HERMES_HOME = resolveHermesHome() +// ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path +// install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end +// up with identical layouts and can share one install. +const ACTIVE_HERMES_ROOT = path.join(HERMES_HOME, 'hermes-agent') +// VENV_ROOT — venv lives inside the repo, exactly like install.ps1 does it. +const VENV_ROOT = path.join(ACTIVE_HERMES_ROOT, 'venv') +const RUNTIME_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-desktop-runtime.json') +// FACTORY_HERMES_ROOT — read-only payload that ships inside the .app/.exe. +// On first run (or after an installer-driven upgrade) we sync it into +// ACTIVE_HERMES_ROOT, unless ACTIVE is a git checkout (developer install via +// install.ps1) in which case we leave it alone. +const FACTORY_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent') + const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json') -const DESKTOP_LOG_PATH = path.join(app.getPath('userData'), 'desktop.log') +// desktop.log lives under HERMES_HOME/logs/ so it sits next to agent.log, +// errors.log, gateway.log produced by hermes_logging.setup_logging — one log +// directory per user, regardless of which UI surface produced the line. +const DESKTOP_LOG_PATH = path.join(HERMES_HOME, 'logs', 'desktop.log') const DESKTOP_LOG_FLUSH_MS = 120 const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024 const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' @@ -55,32 +99,8 @@ const BOOT_FAKE_STEP_MS = (() => { if (!Number.isFinite(raw) || raw <= 0) return 650 return Math.max(120, raw) })() -const RUNTIME_SCHEMA_VERSION = 3 -const BUNDLED_RUNTIME_REQUIREMENTS = [ - 'openai>=2.21.0,<3', - 'anthropic>=0.39.0,<1', - 'python-dotenv>=1.2.1,<2', - 'fire>=0.7.1,<1', - 'httpx[socks]>=0.28.1,<1', - 'rich>=14.3.3,<15', - 'tenacity>=9.1.4,<10', - 'pyyaml>=6.0.2,<7', - 'requests>=2.32.0,<3', - 'jinja2>=3.1.5,<4', - 'pydantic>=2.12.5,<3', - 'prompt_toolkit>=3.0.52,<4', - 'exa-py>=2.9.0,<3', - 'firecrawl-py>=4.16.0,<5', - 'parallel-web>=0.4.2,<1', - 'fal-client>=0.13.1,<1', - 'croniter>=6.0.0,<7', - 'edge-tts>=7.2.7,<8', - 'PyJWT[crypto]>=2.12.0,<3', - 'fastapi>=0.104.0,<1', - 'uvicorn[standard]>=0.24.0,<1', - IS_WINDOWS ? 'pywinpty>=2.0.0,<3' : 'ptyprocess>=0.7.0,<1' -] -const BUNDLED_RUNTIME_IMPORT_CHECK = bundledRuntimeImportCheck() +const RUNTIME_SCHEMA_VERSION = 4 +const RUNTIME_IMPORT_CHECK = bundledRuntimeImportCheck() const APP_NAME = 'Hermes' const TITLEBAR_HEIGHT = 34 const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14 @@ -508,30 +528,39 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { } } -function createBundledBackend(root, dashboardArgs) { - const python = getVenvPython(BUNDLED_VENV_ROOT) +// createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the +// canonical install location shared with the CLI installer. The venv at +// VENV_ROOT may not exist yet on first run; bootstrap=true tells +// ensureRuntime() to create / refresh it before launch. +function createActiveBackend(dashboardArgs) { + const venvPython = getVenvPython(VENV_ROOT) return { kind: 'python', - label: 'bundled Hermes', - command: fileExists(python) ? python : findSystemPython(), + label: `Hermes at ${ACTIVE_HERMES_ROOT}`, + command: fileExists(venvPython) ? venvPython : findSystemPython(), args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: { - PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) + PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) }, - root, + root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false } } function resolveHermesBackend(dashboardArgs) { + // 1. Explicit override — HERMES_DESKTOP_HERMES_ROOT points at a developer + // checkout. Honour it as-is (no bootstrap; the user is driving). const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) if (overrideRoot && isHermesSourceRoot(overrideRoot)) { const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, dashboardArgs) if (backend) return backend } + // 2. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or + // pip-installed system-wide. Skip when HERMES_DESKTOP_IGNORE_EXISTING=1 + // (used by test:desktop:fresh to force the factory-image bootstrap path). if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') { let hermesCommand = null const hermesOverride = process.env.HERMES_DESKTOP_HERMES @@ -562,16 +591,31 @@ function resolveHermesBackend(dashboardArgs) { } } - if (IS_PACKAGED && isHermesSourceRoot(BUNDLED_HERMES_ROOT)) { - const backend = createBundledBackend(BUNDLED_HERMES_ROOT, dashboardArgs) - if (backend.command) return backend - } - + // 3. Development source — when running `npm run dev` from a checkout, the + // cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE so the + // desktop uses the dev's local edits, not whatever's under HERMES_HOME. + // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.) if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs) if (backend) return backend } + // 4. ACTIVE_HERMES_ROOT — the canonical mutable install at + // %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent. + // On packaged installs this is populated from FACTORY_HERMES_ROOT during + // ensureRuntime(). On install.ps1 / install.sh setups it's already there. + if (isHermesSourceRoot(ACTIVE_HERMES_ROOT)) { + return createActiveBackend(dashboardArgs) + } + + // 5. Packaged: FACTORY_HERMES_ROOT exists but ACTIVE doesn't yet. Return a + // bootstrap-flagged backend; ensureRuntime() will sync factory → active + // and provision the venv before launch. + if (IS_PACKAGED && isHermesSourceRoot(FACTORY_HERMES_ROOT)) { + return createActiveBackend(dashboardArgs) + } + + // 6. Last-ditch: pip-installed hermes_cli module via system Python. const python = findSystemPython() if (python) { return { @@ -585,78 +629,134 @@ function resolveHermesBackend(dashboardArgs) { } } - throw new Error('Could not find Hermes. Install the Hermes CLI or set HERMES_DESKTOP_HERMES_ROOT.') + // Nothing worked. Distinguish the "no payload" and "no Python" cases so the + // user gets actionable guidance instead of "install the Hermes CLI". + const factoryPresent = isHermesSourceRoot(FACTORY_HERMES_ROOT) + const activePresent = isHermesSourceRoot(ACTIVE_HERMES_ROOT) + if (factoryPresent || activePresent) { + throw new Error( + 'Hermes payload is present but no Python 3.11+ interpreter could be found. ' + + 'Install Python 3.11+ from https://www.python.org/downloads/ or the Microsoft Store, ' + + 'then relaunch Hermes.' + ) + } + throw new Error( + 'Could not find Hermes. Install the Hermes CLI ' + + '(https://github.com/NousResearch/hermes-agent#install) or set HERMES_DESKTOP_HERMES_ROOT.' + ) } -async function ensureBundledRuntime(backend) { +async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) return backend } - const sourceVersion = readJson(path.join(backend.root, 'package.json'))?.version || app.getVersion() - const marker = readJson(BUNDLED_VENV_MARKER) - const venvPython = getVenvPython(BUNDLED_VENV_ROOT) + // Step 1: Ensure ACTIVE_HERMES_ROOT is populated. On packaged installs we + // sync from FACTORY_HERMES_ROOT (the read-only payload bundled into the + // .app/.exe). We DON'T overwrite a developer install: presence of a .git + // dir or a Hermes-managed venv at the same place means the user set this + // up via install.ps1 / install.sh / git clone, and that install owns the + // updates (via `hermes update`). + const isGitCheckout = directoryExists(path.join(ACTIVE_HERMES_ROOT, '.git')) + const factoryAvailable = IS_PACKAGED && isHermesSourceRoot(FACTORY_HERMES_ROOT) - const runtimeReady = - fileExists(venvPython) && - marker?.sourceVersion === sourceVersion && - marker?.runtimeSchemaVersion === RUNTIME_SCHEMA_VERSION && - (await hasBundledRuntimeImports(venvPython)) + if (factoryAvailable && !isGitCheckout) { + const factoryVersion = + readPyprojectVersion(FACTORY_HERMES_ROOT) ?? + readJson(path.join(FACTORY_HERMES_ROOT, 'package.json'))?.version ?? + app.getVersion() + const marker = readJson(RUNTIME_MARKER) + const pyprojectHash = sha256OfFile(path.join(FACTORY_HERMES_ROOT, 'pyproject.toml')) - if (runtimeReady) { - await advanceBootProgress('runtime.ready', 'Reusing bundled Hermes runtime', 58) - backend.command = venvPython - backend.label = `${backend.label} runtime at ${BUNDLED_VENV_ROOT}` - return backend + const activeFresh = + isHermesSourceRoot(ACTIVE_HERMES_ROOT) && + marker?.runtimeSchemaVersion === RUNTIME_SCHEMA_VERSION && + marker?.factoryVersion === factoryVersion && + marker?.pyprojectHash === pyprojectHash + + if (!activeFresh) { + await advanceBootProgress('runtime.sync', 'Installing Hermes', 30) + rememberLog(`Syncing Hermes payload ${FACTORY_HERMES_ROOT} → ${ACTIVE_HERMES_ROOT}`) + fs.mkdirSync(ACTIVE_HERMES_ROOT, { recursive: true }) + // Copy in factory contents. We do NOT delete venv/ — preserving it + // across upgrades skips re-install when deps haven't moved. + await syncTreeExcludingVenv(FACTORY_HERMES_ROOT, ACTIVE_HERMES_ROOT) + } } - const systemPython = findSystemPython() - if (!systemPython) { - throw new Error('Python 3.11+ is required to bootstrap the bundled Hermes runtime.') - } - - await advanceBootProgress('runtime.prepare', 'Preparing bundled Hermes runtime', 42) - rememberLog(`Preparing bundled Hermes runtime in ${BUNDLED_VENV_ROOT}`) - fs.mkdirSync(BUNDLED_VENV_ROOT, { recursive: true }) - - if (!fileExists(venvPython)) { - await advanceBootProgress('runtime.venv', 'Creating desktop runtime virtual environment', 50) - await runProcess(systemPython, ['-m', 'venv', BUNDLED_VENV_ROOT]) - } - - await advanceBootProgress('runtime.dependencies', 'Installing desktop runtime dependencies', 66) - await runProcess(venvPython, [ - '-m', - 'pip', - 'install', - '--disable-pip-version-check', - '--no-warn-script-location', - '--upgrade', - ...BUNDLED_RUNTIME_REQUIREMENTS - ]) - - await advanceBootProgress('runtime.verify', 'Validating bundled runtime dependencies', 78) - await runProcess(venvPython, ['-c', BUNDLED_RUNTIME_IMPORT_CHECK]) - - fs.writeFileSync( - BUNDLED_VENV_MARKER, - JSON.stringify( - { - runtimeSchemaVersion: RUNTIME_SCHEMA_VERSION, - sourceVersion, - installedAt: new Date().toISOString() - }, - null, - 2 + if (!isHermesSourceRoot(ACTIVE_HERMES_ROOT)) { + throw new Error( + `Hermes install at ${ACTIVE_HERMES_ROOT} is missing or incomplete. ` + + 'Reinstall via the desktop installer or scripts/install.ps1.' ) - ) + } + + // Step 2: Ensure venv exists at /venv — same place + // install.ps1 / install.sh put it. A user who installed via the CLI script + // already has this; we reuse it as-is. + const venvPython = getVenvPython(VENV_ROOT) + if (!fileExists(venvPython)) { + const systemPython = findSystemPython() + if (!systemPython) { + throw new Error( + 'Python 3.11+ is required to bootstrap Hermes. Install Python from ' + + 'https://www.python.org/downloads/ (or the Microsoft Store on Windows), then relaunch Hermes.' + ) + } + await advanceBootProgress('runtime.venv', 'Creating Hermes virtual environment', 50) + await runProcess(systemPython, ['-m', 'venv', VENV_ROOT]) + } + + // Step 3: Ensure deps are installed. We compare a marker against the + // active pyproject.toml's hash and only run pip when something changed — + // keeps `npm run dev` boots fast on a stable repo. + const expectedMarker = { + runtimeSchemaVersion: RUNTIME_SCHEMA_VERSION, + pyprojectHash: sha256OfFile(path.join(ACTIVE_HERMES_ROOT, 'pyproject.toml')), + factoryVersion: factoryAvailable + ? readPyprojectVersion(FACTORY_HERMES_ROOT) ?? app.getVersion() + : null + } + const currentMarker = readJson(RUNTIME_MARKER) + const depsFresh = + currentMarker?.runtimeSchemaVersion === expectedMarker.runtimeSchemaVersion && + currentMarker?.pyprojectHash === expectedMarker.pyprojectHash && + (await hasRuntimeImports(venvPython)) + + if (!depsFresh) { + await advanceBootProgress('runtime.dependencies', 'Installing Hermes dependencies', 66) + await runProcess(venvPython, [ + '-m', + 'pip', + 'install', + '--disable-pip-version-check', + '--no-warn-script-location', + '--upgrade', + '-e', + ACTIVE_HERMES_ROOT + ]) + + await advanceBootProgress('runtime.verify', 'Validating Hermes dependencies', 78) + await runProcess(venvPython, ['-c', RUNTIME_IMPORT_CHECK]) + + fs.writeFileSync( + RUNTIME_MARKER, + JSON.stringify( + { ...expectedMarker, installedAt: new Date().toISOString() }, + null, + 2 + ) + ) + } else { + await advanceBootProgress('runtime.ready', 'Reusing existing Hermes runtime', 78) + } backend.command = venvPython - backend.label = `${backend.label} runtime at ${BUNDLED_VENV_ROOT}` + backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})` updateBootProgress({ phase: 'runtime.ready', - message: 'Bundled runtime is ready', + message: 'Hermes runtime is ready', progress: 82, running: true, error: null @@ -664,16 +764,71 @@ async function ensureBundledRuntime(backend) { return backend } -async function hasBundledRuntimeImports(python) { +async function hasRuntimeImports(python) { try { - await runProcess(python, ['-c', BUNDLED_RUNTIME_IMPORT_CHECK]) + await runProcess(python, ['-c', RUNTIME_IMPORT_CHECK]) return true } catch { - rememberLog('Bundled Hermes runtime is missing required dashboard dependencies; reinstalling.') + rememberLog('Hermes runtime is missing required imports; reinstalling.') return false } } +// Read pyproject.toml's [project].version with a regex — avoids pulling in a +// TOML parser for one field. Returns null if the file is missing or the +// version line can't be matched. +function readPyprojectVersion(root) { + try { + const text = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8') + const match = text.match(/^version\s*=\s*"([^"]+)"/m) + return match ? match[1] : null + } catch { + return null + } +} + +function sha256OfFile(filePath) { + try { + const buf = fs.readFileSync(filePath) + return crypto.createHash('sha256').update(buf).digest('hex') + } catch { + return null + } +} + +// Copy from src → dst, preserving any existing venv/ at dst. +// +// In practice src (FACTORY_HERMES_ROOT) never contains a venv — +// stage-hermes-payload.mjs explicitly excludes venvs from the bundled +// payload. The venv-preservation filter below is defensive: if a future +// payload ever does include a venv directory, we still won't clobber the +// user's existing one at ACTIVE_HERMES_ROOT/venv. +// +// Excludes .git, __pycache__, .pyc/.pyo, etc. — same set +// stage-hermes-payload.mjs uses on the build side. +async function syncTreeExcludingVenv(src, dst) { + const EXCLUDED = new Set(['.git', '.mypy_cache', '.pytest_cache', '.ruff_cache', '__pycache__', 'node_modules', '.DS_Store']) + const srcVenv = path.join(src, 'venv') + const venvPreserved = directoryExists(path.join(dst, 'venv')) + + await fs.promises.cp(src, dst, { + recursive: true, + force: true, + filter: source => { + const name = path.basename(source) + if (EXCLUDED.has(name)) return false + if (name.endsWith('.pyc') || name.endsWith('.pyo')) return false + // Defensive: skip any venv/ inside src so we never clobber dst's venv. + // (The source path the filter receives is rooted at src; that's why we + // check srcVenv here, not dstVenv.) + if (venvPreserved && (source === srcVenv || source.startsWith(srcVenv + path.sep))) { + return false + } + return true + } + }) +} + function isPortAvailable(port) { return new Promise(resolve => { const server = net.createServer() @@ -1519,7 +1674,7 @@ async function startHermes() { const token = crypto.randomBytes(32).toString('base64url') const dashboardArgs = ['dashboard', '--no-open', '--tui', '--host', '127.0.0.1', '--port', String(port)] await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) - const backend = await ensureBundledRuntime(resolveHermesBackend(dashboardArgs)) + const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() @@ -1530,6 +1685,15 @@ async function startHermes() { cwd: hermesCwd, env: { ...process.env, + // Explicitly pin HERMES_HOME for the child so Python's get_hermes_home() + // resolves to the SAME location our resolveHermesHome() picked. Without + // this pin, Python falls back to ~/.hermes on every platform — fine on + // mac/linux (where our default matches), but on Windows our default is + // %LOCALAPPDATA%\hermes, which differs from C:\Users\\.hermes. + // Mismatch would split config / sessions / .env / logs across two + // directories. install.ps1 sets HERMES_HOME via setx; the desktop + // can't reliably do that, so we set it inline for every spawn. + HERMES_HOME, ...backend.env, HERMES_DASHBOARD_SESSION_TOKEN: token, HERMES_DASHBOARD_TUI: '1', diff --git a/apps/desktop/scripts/test-desktop.mjs b/apps/desktop/scripts/test-desktop.mjs index a6154f1fb3b..029cbf3d2bb 100644 --- a/apps/desktop/scripts/test-desktop.mjs +++ b/apps/desktop/scripts/test-desktop.mjs @@ -12,8 +12,11 @@ const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64' const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') const APP_PATH = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app') const APP_BIN = path.join(APP_PATH, 'Contents', 'MacOS', 'Hermes') -const USER_DATA = path.join(os.homedir(), 'Library', 'Application Support', 'Hermes') -const RUNTIME_ROOT = path.join(USER_DATA, 'hermes-runtime') +// Default HERMES_HOME for non-sandboxed mac runs — matches main.cjs's +// resolveHermesHome(). The fresh-install sandbox launchFresh() sets its own +// HERMES_HOME and never touches this. +const DEFAULT_HERMES_HOME = path.join(os.homedir(), '.hermes') +const VENV_ROOT = path.join(DEFAULT_HERMES_HOME, 'hermes-agent', 'venv') const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install') function die(message) { @@ -192,7 +195,7 @@ function launchFresh() { console.log(` HERMES_HOME: ${hermesHome}`) console.log(` cwd: ${cwd}`) - return { runtimeRoot: path.join(userDataDir, 'hermes-runtime') } + return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') } } function validateBundle() { @@ -224,7 +227,7 @@ function validateBundle() { } function printArtifacts(options = {}) { - const runtimeRoot = options.runtimeRoot || RUNTIME_ROOT + const runtimeRoot = options.runtimeRoot || VENV_ROOT console.log('\nDesktop artifacts:') console.log(` app: ${APP_PATH}`) diff --git a/pyproject.toml b/pyproject.toml index 15362c2df42..d32aa528d2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,10 @@ dependencies = [ # (which is a silent killer on Windows — see CONTRIBUTING.md) and # `os.killpg` (which doesn't exist on Windows). "psutil>=5.9.0,<8", + "fastapi>=0.104.0,<1", + "uvicorn[standard]>=0.24.0,<1", + "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", + "pywinpty>=2.0.0,<3; sys_platform == 'win32'", ] [project.optional-dependencies] @@ -70,8 +74,10 @@ voice = [ "numpy>=1.24.0,<3", ] pty = [ - "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", - "pywinpty>=2.0.0,<3; sys_platform == 'win32'", + # Kept as a no-op back-compat alias — `ptyprocess` and `pywinpty` are now + # in the main `dependencies` list (with the same platform markers), so + # any existing `pip install hermes-agent[pty]` invocations resolve cleanly + # without pulling in extra packages. ] honcho = ["honcho-ai>=2.0.1,<3"] mcp = ["mcp>=1.2.0,<2"]