Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { resolve } from "path";
import { defineConfig } from "electron-vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { waitForDreiPrebundle } from "./scripts/vite-wait-for-drei";

const rendererPort = Number(process.env.HERMES_DESKTOP_RENDERER_PORT || 0);

Expand Down Expand Up @@ -36,11 +37,17 @@ export default defineConfig({
alias: {
"@renderer": resolve("src/renderer/src"),
},
// Ensure a single Three.js instance across our code, @react-three/fiber,
// drei and troika — multiple copies break `instanceof THREE.*` checks in
// the ported office agent renderer.
dedupe: ["three"],
},
plugins: [tailwindcss(), react()],
optimizeDeps: {
include: [
"@react-three/drei",
"@react-three/fiber",
"three",
"troika-three-text",
"three/examples/jsm/utils/SkeletonUtils.js",
],
},
plugins: [waitForDreiPrebundle(), tailwindcss(), react()],
},
});
2 changes: 2 additions & 0 deletions lat.md/chat-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ This matches the reference `apps/desktop`, which has no `/v1` chat path at all (

So `ensureClient` distinguishes two failures: a **genuinely absent** dashboard (`startDashboard` → `running:false`) latches the negative flag and (auto mode) drops to legacy gateway `/v1`; a **transient** WS drop while the dashboard is up (a "socket hang up" from a tunnel blip) instead **retries the connect** (up to 3×, re-running `startDashboard` each time to re-establish the SSH tunnel). If it still can't connect, it throws a `dashboardWasReachable`-tagged error so `sendMessage` **fails the turn for the user to retry** rather than 405-ing on `/v1`.

[[src/main/hermes.ts#isApiServerReady]] also rejects dashboard SPA HTML `/health` responses so a dashboard URL is never mistaken for a gateway with `/v1`. Remote connection tests prefer `/api/status` when chat transport is not `legacy` ([[src/main/hermes.ts#testRemoteConnection]]).

## Completion text reconciliation

On `message.complete` the desktop reconciles the text streamed via `message.delta` with the turn's `final_response`, because a last-turn-only final would otherwise clobber text streamed before a tool call (#746).
Expand Down
10 changes: 8 additions & 2 deletions lat.md/main-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ The packaged renderer keeps its meta CSP aligned with the production response CS

Because electron-vite emits a bundled main file at `out/main/index.js`, packaged renderer loading resolves `../renderer/index.html` from `__dirname` to reach `out/renderer/index.html`.

## Dev Vite loading

Local `npm run dev` can show a blank window when Vite's dependency pre-bundler races Electron startup — especially the Office 3D stack (`@react-three/drei`, ~3.5MB).

[[electron.vite.config.ts]] pre-bundles `@react-three/fiber`, `three`, `troika-three-text`, and `three/examples/jsm/utils/SkeletonUtils.js` (Office rigged GLBs import it; discovering it on first Office visit re-optimizes and can delete `@react-three_drei.js`). [[scripts/vite-wait-for-drei.ts#waitForDreiPrebundle]] blocks the first HTML response until `@react-three_drei.js` exists. `scripts/ensure-vite-deps.mjs` clears a corrupt cache when only the `.map` remains. [[src/renderer/src/screens/Layout/Layout.tsx]] lazy-loads the Office tab so Chat startup never waits on drei. [[src/renderer/src/main.tsx]] reloads on `vite:preloadError` (sessionStorage-throttled to one reload per 15s so persistent failures surface instead of looping); [[src/main/app/start.ts]] reloads once when `#root` is still empty four seconds after load. Use `npm run dev:clean` (cross-platform: `ensure-vite-deps.mjs --force`) when deps look stale.

## App Chrome Helpers

Menu, updater, and context-menu behavior live in focused modules.
Expand Down Expand Up @@ -74,8 +80,8 @@ Direct Remote mode also supports browser-authenticated dashboards through [[remo

The dashboard is **not** a `/v1` superset (a long-standing misconception in earlier comments): `hermes_cli/web_server.py` has no `/v1/chat`, `/v1/responses`, or `/v1/runs` routes and does not proxy `/v1` to the gateway.

- **Gateway api_server** (port 8642, `API_SERVER_KEY` auth) serves `/v1` chat (`/v1/chat/completions`, `/v1/responses`, `/v1/runs`) + `/health`. This is the **no-build** transport — no Node, no web dist — used by `remote` mode and the SSH gateway fallback. See [[main-process#SSH api_server provisioning]].
- **Dashboard** (`hermes dashboard`, port 9119, session-token auth) serves the model library, session list (`/api/*`), and the chat **WebSocket** (`/api/ws`) — surfaces the gateway api_server does not. Local chat uses `/api/ws`; over SSH the renderer's dashboard transport uses it too, when a dashboard is available.
- **Gateway api_server** (port 8642, `API_SERVER_KEY` auth) serves `/v1` chat (`/v1/chat/completions`, `/v1/responses`, `/v1/runs`) + JSON `/health`. This is the **no-build** transport — no Node, no web dist — used by `remote` mode and the SSH gateway fallback. See [[main-process#SSH api_server provisioning]].
- **Dashboard** (`hermes dashboard`, port 9119, session-token auth) serves the model library, session list (`/api/*`), cron at `/api/cron/jobs`, and the chat **WebSocket** (`/api/ws`) — surfaces the gateway api_server does not. Local chat uses `/api/ws`; over SSH the renderer's dashboard transport uses it too, when a dashboard is available. Its SPA answers GET `/health` with HTML 200, which must **not** be treated as gateway readiness ([[src/main/hermes.ts#isApiServerReady]] rejects `text/html`).

[[src/main/ssh-remote.ts#sshEnsureDashboard]] ensures the gateway is up, builds the web dist if missing ([[src/main/ssh-remote.ts#sshEnsureDashboardDist]] resolves the real install root via [[src/main/ssh-remote.ts#sshResolveDashboardRoot]] — a system-wide install lives at `/usr/local/lib/hermes-agent`, NOT under `$HOME`, so a hardcoded `~/.hermes/hermes-agent` path wrongly reported "no web dist" and forced every connection into basic chat; it now detects an already-built dist wherever hermes lives, or builds it with the vendored Node at `~/.hermes/node`, single shared in-flight build), then starts the **unified machine** `hermes dashboard --host 127.0.0.1 --port <port> --no-open --skip-build` ([[src/main/ssh-remote.ts#sshStartDashboard]]) with the session token in its env. **One dashboard serves every profile** (no `--profile`, no `--isolated`): `ensureDashboardInner` is machine-scoped (profile=undefined → default port + default token), and per-profile data is selected per-request via `?profile=` ([[src/main/remote-sessions.ts#RemoteSessionConfig]]`.profile`, applied in `dashboardApiUrl`). This is REQUIRED because the desktop has a single global SSH tunnel that can only point at one remote port: the desktop queries multiple profiles at once (e.g. `default` for the machine view + the active named profile), so per-profile dashboard ports (an earlier `--isolated` attempt) made those concurrent queries resolve different ports and thrash the one tunnel ("SSH tunnel is not active"). Readiness requires both the public `/api/status` probe ([[src/main/ssh-remote.ts#sshWaitDashboardReady]], [[src/main/ssh-remote.ts#sshDashboardRunning]]) and an authenticated `/api/sessions` probe ([[src/main/ssh-remote.ts#sshDashboardAuthenticated]]). If the preferred port belongs to a stale dashboard with another token or an unrelated HTTP service, the desktop leaves that process alone, allocates a free loopback port, and persists it as `HERMES_DESKTOP_DASHBOARD_PORT` (one canonical line, deduped) in the **default** `.env`. [[src/main/dashboard.ts#sshDashboardConnectionFromConfig]] and [[src/main/ipc/register.ts#getSshDashboardSessionConfig]] then `ensureSshTunnel` to that single dashboard port and build the connection (model library, sessions, and the `/api/ws` chat WS), carrying the requested `profile`.

Expand Down
8 changes: 7 additions & 1 deletion lat.md/sidebar-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ The menu is styled light-based and stroke-free (`.sidebar-session-menu`): no 1px

Each action calls an existing desktop API with an optimistic local update and rollback on failure: Rename → `updateSessionTitle` (inline `.sidebar-recent-session-rename` input), Move → [[src/main/session-context-folder-store.ts#setSessionContextFolder]] then a `hermes-session-context-folder-changed` event so other surfaces re-group, Delete → a confirmation dialog (portal overlay) then [[src/main/sessions.ts#deleteSessionRows|deleteSession]]. Deleting the open chat calls `onSessionDeleted`, which [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] uses to drop to a fresh New Chat.

### Rename persistence

Session renames must survive `syncSessionCache`, so the durable `state.db` write happens before the JSON cache is updated.

Title policy lives in [[src/shared/session-title.ts]] (`normalizeSessionTitle`, `MAX_SESSION_TITLE_LENGTH`) so the renderer optimistic path and [[src/main/session-cache.ts#updateSessionTitle]] cannot diverge. Main writes `state.db` first (Hermes `idx_sessions_title_unique`), then mirrors into `sessions.json`; failures throw. Both the sidebar and Sessions modal call [[src/renderer/src/screens/Sessions/confirmSessionRename.ts#confirmSessionRename]] for optimistic update, toast/rollback, and keep-editor-on-failure.

Pinned rows are a desktop-only affordance: their ids live in `localStorage` (`hermes.sidebar.pinnedSessions`), and pinned sessions are pulled out of the normal grouping into a collapsible **Pinned** section at the top of the list.

## Full-list modal
Expand Down Expand Up @@ -76,7 +82,7 @@ The primary path tunnels to the remote **unified machine dashboard** (see [[main

Managed SSH installs can store Hermes outside the SSH user's home or under a different `HERMES_HOME`, so Office/Agents must read profiles from the actual remote runtime rather than a `~/.hermes` filesystem scan.

[[src/main/ssh-remote.ts#buildRemoteHermesCmd]] probes per-user launcher hooks (`$HOME/.config/hermes-desktop/remote-hermes`, `$HOME/.hermes/desktop-remote-hermes`) before the default venv/PATH locations, letting a deployment supply its own wrapper that sets the right command, service user, and `HERMES_HOME`. [[src/main/ssh-remote.ts#sshListProfiles]] detects whether such a launcher actually exists in one round trip, then [[src/main/ssh-remote.ts#selectSshProfiles]] treats a present launcher as authoritative — preferring its profiles over the scan even on an equal count, so a managed `default`-only install shows live gateway state instead of stale home-directory data. Named-profile Schedules route the same way through [[src/main/ssh-remote.ts#sshRunCron]], while the default profile keeps the existing HTTP `/api/jobs` path.
[[src/main/ssh-remote.ts#buildRemoteHermesCmd]] probes per-user launcher hooks (`$HOME/.config/hermes-desktop/remote-hermes`, `$HOME/.hermes/desktop-remote-hermes`) before the default venv/PATH locations, letting a deployment supply its own wrapper that sets the right command, service user, and `HERMES_HOME`. [[src/main/ssh-remote.ts#sshListProfiles]] detects whether such a launcher actually exists in one round trip, then [[src/main/ssh-remote.ts#selectSshProfiles]] treats a present launcher as authoritative — preferring its profiles over the scan even on an equal count, so a managed `default`-only install shows live gateway state instead of stale home-directory data. Named-profile Schedules route the same way through [[src/main/ssh-remote.ts#sshRunCron]], while the default profile uses HTTP cron via [[src/main/cronjobs.ts#listCronJobs]]: prefer dashboard `/api/cron/jobs`, fall back to gateway `/api/jobs` on 404/405.

Every SSH-invoked `hermes` command resolves the CLI through `buildRemoteHermesCmd`, never a bare `hermes` — a non-interactive SSH shell does not source the profile that puts the CLI on PATH, so a bare invocation fails with "command not found" on otherwise-healthy remotes. This covers gateway lifecycle ([[src/main/ssh-remote.ts#buildGatewayStartCommand]] / [[src/main/ssh-remote.ts#buildGatewayStopCommand]] non-systemd branch, for both named and default profiles), skills ([[src/main/ssh-remote.ts#sshInstallSkill]], [[src/main/ssh-remote.ts#sshUninstallSkill]], [[src/main/ssh-remote.ts#sshSearchSkills]]), and profile create/delete ([[src/main/ssh-remote.ts#sshCreateProfile]] / [[src/main/ssh-remote.ts#sshDeleteProfile]], which also use the **singular** `profile` subcommand). The systemd branch still prefers `systemctl` when a `hermes.service` unit exists, and [[src/main/ssh-remote.ts#buildGatewayStatusCommand]] remains a pid-file liveness check. The non-systemd branch launches the gateway with `gateway run` (foreground, backgrounded via `nohup`), **not** `gateway start` — `gateway start` drives the systemd/launchd service and fails with "Gateway service is not installed" on a bare VPS that never ran `hermes gateway install`, whereas `gateway run` launches the gateway and its api_server directly and writes the pid file the status/stop commands read.

Expand Down
Loading