From 2964f25534afd76a87d8f9b3550c1a7a59f8e63e Mon Sep 17 00:00:00 2001 From: emozilla Date: Tue, 5 May 2026 02:08:18 -0400 Subject: [PATCH 1/2] fix(dashboard): resolve @nous-research/ui path under npm workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync-assets prebuild step shelled out to 'cp -r node_modules/@nous-research/ui/dist/fonts ...' with a path relative to apps/dashboard/. That works only when the dep is installed locally in the dashboard workspace, but 'npm install' at the repo root (the documented setup — see apps/desktop/README.md) hoists shared deps to the root node_modules under npm workspaces. The relative cp then fails with 'No such file or directory', sync-assets exits 1, the Vite build aborts, and 'hermes dashboard' surfaces a generic 'Web UI build failed' message. Replace the shell one-liner with scripts/sync-assets.cjs, which walks up from the dashboard directory looking for node_modules/ @nous-research/ui — working in both the hoisted (workspaces) and co-located (standalone) layouts. Also guards against a missing dist/fonts or dist/assets with a clearer error pointing at a rebuild of the UI package rather than silently copying nothing. --- apps/dashboard/package.json | 2 +- apps/dashboard/scripts/sync-assets.cjs | 46 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/scripts/sync-assets.cjs diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index a85c663115c..ae57ab45cc4 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "sync-assets": "rm -rf public/fonts public/ds-assets && cp -r node_modules/@nous-research/ui/dist/fonts public/fonts && cp -r node_modules/@nous-research/ui/dist/assets public/ds-assets", + "sync-assets": "node scripts/sync-assets.cjs", "predev": "npm run sync-assets", "prebuild": "npm run sync-assets", "dev": "vite", diff --git a/apps/dashboard/scripts/sync-assets.cjs b/apps/dashboard/scripts/sync-assets.cjs new file mode 100644 index 00000000000..ad4ba2ee4c6 --- /dev/null +++ b/apps/dashboard/scripts/sync-assets.cjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Copy font and asset folders from @nous-research/ui into public/ for Vite. + * + * Locates @nous-research/ui by walking up from this script looking for + * node_modules/@nous-research/ui — works whether the dep is co-located + * (non-workspace layout) or hoisted to the repo root (npm workspaces). + */ +const fs = require('node:fs') +const path = require('node:path') + +const DASHBOARD_ROOT = path.resolve(__dirname, '..') + +function locateUiPackage() { + let dir = DASHBOARD_ROOT + const { root } = path.parse(dir) + while (true) { + const candidate = path.join(dir, 'node_modules', '@nous-research', 'ui') + if (fs.existsSync(path.join(candidate, 'package.json'))) { + return candidate + } + if (dir === root) break + dir = path.dirname(dir) + } + throw new Error( + '@nous-research/ui not found. Run `npm install` from the repo root.' + ) +} + +const uiRoot = locateUiPackage() +const distRoot = path.join(uiRoot, 'dist') + +const mappings = [ + ['fonts', path.join(DASHBOARD_ROOT, 'public', 'fonts')], + ['assets', path.join(DASHBOARD_ROOT, 'public', 'ds-assets')], +] + +for (const [srcName, destPath] of mappings) { + const srcPath = path.join(distRoot, srcName) + if (!fs.existsSync(srcPath)) { + throw new Error(`Missing ${srcPath} in @nous-research/ui — rebuild that package.`) + } + fs.rmSync(destPath, { recursive: true, force: true }) + fs.cpSync(srcPath, destPath, { recursive: true }) + console.log(`synced ${path.relative(DASHBOARD_ROOT, destPath)}`) +} From 3aabae20ebb99252ae227694664f1ee794defb80 Mon Sep 17 00:00:00 2001 From: emozilla Date: Tue, 5 May 2026 02:08:32 -0400 Subject: [PATCH 2/2] feat(desktop): support connecting to a remote Hermes backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN env vars that, when set, short-circuit the local-child spawn in startHermes() and connect the Electron renderer to an already- running 'hermes dashboard' server reachable over the network. Motivating use case: WSL2 users who want to run the Hermes core (agent loop, tools, filesystem access) inside their WSL distribution while rendering the Electron GUI on native Windows. Before this change, the desktop app always spawned a local Python child on the same host as the renderer, which doesn't cross the WSL/Windows boundary. The remote path reuses waitForHermes() as a liveness probe (/api/status is in the backend's public endpoint allowlist), so the connection is only returned once the backend is actually ready. WebSocket URL derivation picks ws:// or wss:// based on the input scheme. URL validation rejects non-http(s) schemes and requires both env vars together to avoid a half-configured connection that would silently fall through to the spawn path. No behaviour change when the env vars are unset — the default local-spawn flow is untouched. Typical usage: # in WSL2 hermes dashboard --tui --no-open --host 0.0.0.0 --port 9119 --insecure # on Windows set HERMES_DESKTOP_REMOTE_URL=http://localhost:9119 set HERMES_DESKTOP_REMOTE_TOKEN= set HERMES_DESKTOP_IGNORE_EXISTING=1 (launch Hermes desktop) --- apps/desktop/electron/main.cjs | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index f616972ab9b..29ecc1457d1 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1068,9 +1068,56 @@ function installMediaPermissions() { }) } +function resolveRemoteBackend() { + const rawUrl = process.env.HERMES_DESKTOP_REMOTE_URL + const rawToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN + if (!rawUrl) return null + if (!rawToken) { + throw new Error( + 'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' + + 'Both must be provided to connect to a remote Hermes backend.' + ) + } + + let parsed + try { + parsed = new URL(rawUrl) + } catch (error) { + throw new Error(`HERMES_DESKTOP_REMOTE_URL is not a valid URL: ${error.message}`) + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`HERMES_DESKTOP_REMOTE_URL must be http:// or https://, got ${parsed.protocol}`) + } + + const baseUrl = `${parsed.protocol}//${parsed.host}` + const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws' + const wsUrl = `${wsScheme}://${parsed.host}/api/ws?token=${encodeURIComponent(rawToken)}` + + return { baseUrl, token: rawToken, wsUrl } +} + async function startHermes() { if (connectionPromise) return connectionPromise + const remote = resolveRemoteBackend() + if (remote) { + connectionPromise = (async () => { + rememberLog(`Using remote Hermes backend at ${remote.baseUrl}`) + await waitForHermes(remote.baseUrl, remote.token) + return { + baseUrl: remote.baseUrl, + token: remote.token, + wsUrl: remote.wsUrl, + logs: hermesLog.slice(-80), + windowButtonPosition: getWindowButtonPosition() + } + })().catch(error => { + connectionPromise = null + throw error + }) + return connectionPromise + } + connectionPromise = (async () => { const port = await pickPort() const token = crypto.randomBytes(32).toString('base64url')