refactor(desktop): align install layout with install.ps1 / install.sh

Make the desktop app's runtime layout match what scripts/install.ps1 and
scripts/install.sh produce, so a desktop-only user and a CLI-only user end
up with the same files in the same places and can share one install.

Layout
- ACTIVE_HERMES_ROOT = HERMES_HOME/hermes-agent  (was: process.resourcesPath/hermes-agent, read-only)
- VENV_ROOT          = HERMES_HOME/hermes-agent/venv  (was: userData/hermes-runtime)
- desktop.log        = HERMES_HOME/logs/desktop.log  (was: userData/desktop.log)
- HERMES_HOME default: %LOCALAPPDATA%\hermes on Windows, ~/.hermes elsewhere

The packaged .app/.exe still ships a read-only payload at
process.resourcesPath/hermes-agent (FACTORY_HERMES_ROOT). On first launch
or after an installer-driven upgrade we sync factory -> active, then
provision the venv and run pip install -e . against the active root.

Key behaviors
- Pin HERMES_HOME in the spawned Python's env so get_hermes_home() resolves
  to the same path resolveHermesHome() picked. Without this, Python falls
  back to ~/.hermes on every platform - fine on mac/linux, a split-state
  bug on Windows where our default is %LOCALAPPDATA%\hermes.
- Detect developer installs by .git presence at ACTIVE; never overwrite
  a user's checkout via factory sync.
- Marker at ACTIVE/.hermes-desktop-runtime.json (schema v4) tracks
  pyproject hash + factory version + runtime schema version. depsFresh
  fast-paths when nothing changed.
- Dev (npm run dev) prefers SOURCE_REPO_ROOT over ACTIVE so devs run
  their local edits, not whatever's under HERMES_HOME.
- Better error messages distinguish "no payload" from "no Python".
- Preserve a legacy ~/.hermes on Windows when no %LOCALAPPDATA%\hermes
  exists, so users with prior pip/manual installs aren't orphaned.

pyproject.toml
- Promote fastapi, uvicorn[standard], ptyprocess (non-Windows), and
  pywinpty (Windows) to main dependencies. The dashboard backend
  (hermes dashboard) needs them at runtime; the previous lazy-import
  fallback was a footgun for fresh installs.
- Empty the [pty] optional-extra; kept as a no-op back-compat alias for
  any existing pip install hermes-agent[pty] invocations.

Drops the hardcoded BUNDLED_RUNTIME_REQUIREMENTS list in main.cjs - the
desktop now installs whatever pyproject.toml says, single source of truth.

Files
- apps/desktop/electron/main.cjs:    runtime layout, HERMES_HOME pin,
                                      factory->active sync, marker v4
- apps/desktop/scripts/test-desktop.mjs:  track new venv location
- apps/desktop/README.md:            new Setup, Runtime Bootstrap, and
                                      Debugging sections
- pyproject.toml:                    fastapi/uvicorn/pty backends in main
                                      dependencies; [pty] extra emptied

Tested locally on Windows: npm run dev boots cleanly, sessions land at
the new location, type-check + lint + test:desktop:platforms all pass.
Verified end-to-end on a fresh Win11 VM via dist:win installer.

Known gaps (filed as follow-ups, not in this PR):
- Skills not seeded on packaged installs (sync_skills only runs in
  cmd_chat, not cmd_dashboard). Need to move to shared pre-dispatch.
- Git Bash not bundled or detected; agent's terminal tool errors out
  with a useful message but desktop bootstrapper should pre-flight it.
- install.ps1 / install.sh should be decomposed into composable phase
  libraries so the desktop bootstrapper can reuse them as a single
  source of truth across all install surfaces.
This commit is contained in:
emozilla 2026-05-11 00:43:46 -04:00
parent cb7f1d7e0e
commit 61fb5a48b7
4 changed files with 357 additions and 127 deletions

View file

@ -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 users 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:

View file

@ -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 <ACTIVE_HERMES_ROOT>/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\<u>\.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',

View file

@ -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}`)

View file

@ -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"]