feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI) (#40355)

* feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI)

Adds a GUI-only uninstall path so people can remove the desktop Chat GUI
while keeping the Hermes agent + their config/sessions/.env, and surfaces
the three CLI uninstall modes inside the desktop app's Settings → About.

CLI:
- New hermes_cli/gui_uninstall.py: cross-platform discovery + removal of the
  desktop GUI's artifacts (source-built dist/release/node_modules + build
  stamp, the packaged app bundle, and the Electron userData dir) on Linux,
  macOS, and Windows. Never touches the agent source, venv, or user data.
- `hermes uninstall --gui` removes only the Chat GUI; `--gui-summary` prints a
  JSON install snapshot (used by the desktop UI to gate options + detect a
  missing agent for a future lite client).
- `hermes uninstall --yes` / `--full --yes` now run non-interactively, sharing
  the destructive sequence via a new _perform_uninstall() helper. The keep-data
  and full flows also sweep the GUI artifacts.

Desktop:
- electron/desktop-uninstall.cjs: pure helpers mapping each mode (gui/lite/full)
  to CLI flags, resolving the running app bundle per OS, and building the
  detached cleanup script that waits for the app to exit, runs the Python
  uninstall, and removes the bundle.
- IPC hermes:uninstall:summary / :run, preload bridge, and types.
- Settings → About "Danger zone" with the three options; agent-removing
  options hide when no local agent is detected.

Tests: tests/hermes_cli/test_gui_uninstall.py (22 pass with the existing
uninstall tests), electron/desktop-uninstall.test.cjs (17 pass, wired into
test:desktop:platforms). Docs: desktop.md "Uninstalling" + cli-commands.md.

* fix(desktop): tear down backend process tree before GUI uninstall (Windows lock safety)

The desktop uninstall cleanup script waited only on the desktop app's own
PID, but a backend grandchild (gateway / pty terminal / hermes REPL) can
outlive it and keep hermes.exe + venv files mandatory-locked on Windows —
making the script's rmdir half-fail and leaving a partial install, the same
failure class as the self-update path's #37532.

- main.cjs: runDesktopUninstall now awaits releaseBackendLock() before
  spawning the cleanup script — tree-kills every backend PID the desktop owns
  (primary + pool) via taskkill /T /F and polls the venv shim until unlocked.
  Extracted the shared core out of releaseBackendLockForUpdate so both the
  update hand-off and the uninstaller use the identical, incident-hardened
  teardown. No-op on macOS/Linux (no mandatory locks).
- desktop-uninstall.cjs: Windows cleanup script removes the bundle via a
  bounded rmdir retry loop (10x, 1s) instead of a single rmdir, since Windows
  releases directory handles lazily even after the holding process exits.
- Dropped a fragile tasklist|findstr reap-by-path attempt; the Electron-side
  tree-kill-by-PID is the reliable mechanism.

Tests: desktop-uninstall.test.cjs updated for the retry-loop output (17 pass).

* fix(desktop): address review on GUI uninstall (venv self-delete, gates, wait-loop)

Resolves @OutThisLife's review on #40355:

1. full mode now gated on agent presence (needsAgent: true). It removes the
   agent + user data, so on a lite client with no local agent it's hidden
   like lite — no more offering to remove an agent that isn't there.

2. (Finding 3, the real bug) lite/full no longer rmtree the venv from the
   venv's OWN python. On Windows a running python.exe is mandatory-locked, so
   that half-fails. New lightweight 'python -m hermes_cli.uninstall --mode X'
   entrypoint (stdlib-only imports) lets the desktop run agent-removing modes
   under the SYSTEM python (findSystemPython) with PYTHONPATH=<agentRoot>, so
   import hermes_cli resolves from source while the venv is torn down. Falls
   back to venv python + logs when no system python (gui-only unaffected).

3. Windows wait-loop is now bounded (60 tries, matching POSIX) and matches the
   PID as a whole space-delimited token via findstr (no substring 99->990
   trap, no redundant bare find). set HERMES_HOME/PID/PYTHONPATH now quoted.

4. Renamed the misleading 'returns null for dev run' test — the dev-run safety
   is shouldRemoveAppBundle(isPackaged=false), which the test now asserts.

Docs: note that --gui on a source checkout also sweeps node_modules/build
output. Tests: 18 python + 19 desktop pass.
This commit is contained in:
Teknium
2026-06-06 18:22:38 -07:00
committed by GitHub
parent f2e8234307
commit 5b43bf7d02
14 changed files with 1784 additions and 10 deletions
@@ -17,6 +17,7 @@ import {
} from '@/store/updates'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
import { UninstallSection } from './uninstall-section'
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
@@ -167,6 +168,8 @@ export function AboutSettings() {
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
<UninstallSection />
</div>
</SettingsContent>
)
@@ -0,0 +1,185 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, Loader2, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { DesktopUninstallMode, DesktopUninstallSummary } from '@/global'
import { SectionHeading } from './primitives'
interface ModeOption {
mode: DesktopUninstallMode
title: string
description: string
/** Shown in the confirm step so people know exactly what disappears. */
consequence: string
/** True when the option removes the Python agent (hidden if no agent). */
needsAgent: boolean
}
const OPTIONS: ModeOption[] = [
{
mode: 'gui',
title: 'Uninstall Chat GUI only',
description: 'Remove this desktop app. The Hermes agent, your config, and chats all stay.',
consequence: 'the desktop Chat GUI (this app and its data)',
needsAgent: false
},
{
mode: 'lite',
title: 'Uninstall GUI + agent, keep my data',
description: 'Remove the app and the Hermes agent, but keep config, chats, and secrets for a future reinstall.',
consequence: 'the Chat GUI and the Hermes agent (config, chats, and secrets are kept)',
needsAgent: true
},
{
mode: 'full',
title: 'Uninstall everything',
description: 'Remove the app, the agent, and all user data — config, chats, scheduled jobs, secrets, logs.',
consequence: 'EVERYTHING — the Chat GUI, the Hermes agent, and all of your config, chats, secrets, and logs',
// full removes the agent (and user data), so it's an agent-removing option:
// hide it on a lite client with no local agent, same as lite. A lite client
// connecting to a remote backend has no local agent OR local user data the
// GUI installed, so gui-only is the correct (and only) option there.
needsAgent: true
}
]
export function UninstallSection() {
const [summary, setSummary] = useState<DesktopUninstallSummary | null>(null)
const [loading, setLoading] = useState(true)
const [pending, setPending] = useState<DesktopUninstallMode | null>(null)
const [running, setRunning] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let alive = true
const bridge = window.hermesDesktop?.uninstall
if (!bridge) {
setLoading(false)
return
}
void bridge
.summary()
.then(result => {
if (alive) {
setSummary(result)
}
})
.catch(() => {
// Non-fatal — we degrade to offering the GUI-only option.
})
.finally(() => {
if (alive) {
setLoading(false)
}
})
return () => {
alive = false
}
}, [])
const bridge = window.hermesDesktop?.uninstall
if (!bridge) {
return null
}
// Gate the agent-removing options on whether an agent is actually present.
// A future lite client that ships without the bundled agent shows GUI-only.
const agentInstalled = summary?.agent_installed ?? false
const visibleOptions = OPTIONS.filter(opt => agentInstalled || !opt.needsAgent)
const handleConfirm = async () => {
if (!pending) {
return
}
setRunning(true)
setError(null)
try {
const result = await bridge.run(pending)
if (!result.ok) {
setError(result.message || result.error || 'Uninstall could not start.')
setRunning(false)
setPending(null)
}
// On success the app quits shortly; keep the spinner up until it does.
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setRunning(false)
setPending(null)
}
}
const pendingOption = OPTIONS.find(opt => opt.mode === pending) ?? null
return (
<div className="mx-auto mt-8 w-full max-w-2xl">
<SectionHeading icon={AlertTriangle} title="Danger zone" />
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3">
{loading ? (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Checking what&apos;s installed
</div>
) : pendingOption ? (
<div>
<p className="text-sm font-medium text-destructive">Confirm uninstall</p>
<p className="mt-1 text-xs text-muted-foreground">
This removes {pendingOption.consequence}. This can&apos;t be undone.
</p>
{summary?.running_app_path && (
<p className="mt-1 font-mono text-[0.68rem] text-muted-foreground/60">
App: {summary.running_app_path}
</p>
)}
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
<div className="mt-3 flex flex-wrap items-center gap-3">
<Button
disabled={running}
onClick={() => void handleConfirm()}
size="sm"
variant="destructive"
>
{running && <Loader2 className="size-3 animate-spin" />}
{running ? 'Uninstalling…' : 'Yes, uninstall'}
</Button>
<Button disabled={running} onClick={() => setPending(null)} size="sm" variant="text">
Cancel
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Uninstall Hermes</p>
<p className="text-xs text-muted-foreground">
Choose how much to remove. The app closes to finish the job; reopen the installer any time to come back.
</p>
<div className="mt-1 flex flex-col gap-2">
{visibleOptions.map(opt => (
<button
className={cn(
'flex items-start gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 text-left transition',
'hover:border-destructive/40 hover:bg-destructive/5'
)}
key={opt.mode}
onClick={() => {
setError(null)
setPending(opt.mode)
}}
type="button"
>
<Trash2 className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{opt.title}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{opt.description}</span>
</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
)
}
+28
View File
@@ -81,6 +81,10 @@ declare global {
setBranch: (name: string) => Promise<{ branch: string }>
onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void
}
uninstall: {
summary: () => Promise<DesktopUninstallSummary>
run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
}
}
}
}
@@ -104,6 +108,30 @@ export interface DesktopVersionInfo {
hermesRoot: string
}
export type DesktopUninstallMode = 'full' | 'gui' | 'lite'
export interface DesktopUninstallSummary {
hermes_home: string
agent_installed: boolean
gui_installed: boolean
source_built_artifacts: string[]
packaged_app_paths: string[]
userdata_dir: string
userdata_exists: boolean
platform: string
running_app_path?: null | string
probe?: string
}
export interface DesktopUninstallResult {
ok: boolean
mode?: DesktopUninstallMode
willRemoveAppBundle?: boolean
scriptPath?: string
error?: string
message?: string
}
export interface DesktopUpdateCommit {
sha: string
summary: string