Phase 004: remove the desktop shell, website tooling, and bundled hermes CLI

- Drop all desktop/Electron branches, scripts, and UI (title bar, pets,
  desktop bridge); LAN endpoint kind is now web/custom only
- Replace the bundled hermes CLI with user-side installation: add
  GET /api/hermes/cli-status plus a HermesMissingView onboarding guide
- Remove the version-management UI (snapshots handled by git) and prune
  dead website/mcu/claude scripts and lockfile entries
This commit is contained in:
2026-10-16 15:33:56 +08:00
parent 8c1ddf415f
commit 4701bf0ad0
49 changed files with 354 additions and 2403 deletions
@@ -317,10 +317,6 @@ function mergeStartFailureReadinessError(readiness: AgentBridgeReadiness, err: u
return readinessError || startError
}
function isDesktopRuntime(): boolean {
return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true'
}
function shouldKillStaleIpcBridgeProcesses(): boolean {
const raw = String(process.env.HERMES_AGENT_BRIDGE_KILL_STALE_IPC || '').trim().toLowerCase()
return ['1', 'true', 'yes', 'on'].includes(raw)
@@ -1,8 +1,6 @@
import { execFile } from 'child_process'
import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import YAML from 'js-yaml'
import { join } from 'path'
import { promisify } from 'util'
import { readAppConfig, type GatewayAutoStartConfig } from '../app-config'
import { logger } from '../logger'
import { getHermesBaseDir, getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
@@ -10,9 +8,6 @@ import { retireManagedGatewayForProfile, startGatewayRunManaged } from './gatewa
import { parseGatewayStatusesFromProfileList } from './profile-list-parser'
import { execHermesWithBin } from './hermes-process'
const execFileAsync = promisify(execFile)
const GATEWAY_RUNTIME_FILES = ['gateway.pid', 'gateway.lock', 'gateway_state.json'] as const
export type GatewayManagementMode = 'auto' | 'per_profile' | 'unified'
export interface GatewayRuntimeTarget {
@@ -148,162 +143,6 @@ export function gatewayAutostartDisabledByEnv(env: NodeJS.ProcessEnv = process.e
return envFlagEnabledIn(env, 'HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART')
}
function envFlagDisabledIn(env: NodeJS.ProcessEnv, name: string): boolean {
const value = String(env[name] || '').trim().toLowerCase()
return ['1', 'true', 'yes', 'on'].includes(value)
}
export function shouldRecoverWindowsDesktopGatewayOrphans(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (platform !== 'win32') return false
if (!envFlagEnabledIn(env, 'HERMES_DESKTOP')) return false
return !envFlagDisabledIn(env, 'HERMES_WEB_UI_DISABLE_GATEWAY_STARTUP_RECOVERY')
}
function listGatewayRuntimeProfileDirs(hermesHome?: string): string[] {
const base = hermesHome || getProfileDir('default')
const dirs = new Set<string>([base])
const profilesRoot = join(base, 'profiles')
try {
for (const entry of readdirSync(profilesRoot, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.trim()) {
dirs.add(join(profilesRoot, entry.name))
}
}
} catch {}
return [...dirs]
}
function readGatewayRuntimePid(path: string, fileName: string): number | null {
if (!existsSync(path)) return null
try {
const data = JSON.parse(readFileSync(path, 'utf-8'))
if (fileName === 'gateway_state.json') {
const state = String(data?.gateway_state || '').toLowerCase()
if (state && state !== 'running' && state !== 'starting') return null
}
const pid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10)
return Number.isFinite(pid) && pid > 0 ? pid : null
} catch {
return null
}
}
async function taskkillWindowsProcessTree(pid: number): Promise<void> {
await execFileAsync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
timeout: 5000,
windowsHide: true,
})
}
export interface GatewayStartupRecoveryResult {
attempted: boolean
profileDirs: string[]
stoppedProfileDirs: string[]
killedPids: number[]
deletedFiles: string[]
errors: number
}
export async function recoverWindowsDesktopGatewayOrphans(opts: {
platform?: NodeJS.Platform
env?: NodeJS.ProcessEnv
hermesHome?: string
isAlive?: (pid: number) => boolean
stopGateway?: (profileDir: string) => Promise<void>
execTaskkill?: (pid: number) => Promise<void>
unlinkFile?: (path: string) => void
} = {}): Promise<GatewayStartupRecoveryResult> {
const platform = opts.platform || process.platform
const env = opts.env || process.env
if (!shouldRecoverWindowsDesktopGatewayOrphans(platform, env)) {
return { attempted: false, profileDirs: [], stoppedProfileDirs: [], killedPids: [], deletedFiles: [], errors: 0 }
}
const isAlive = opts.isAlive || isProcessAlive
const stopGateway = opts.stopGateway || (async (profileDir: string) => {
await execHermesWithBin(resolveHermesBin(), ['gateway', 'stop'], {
timeout: 10000,
windowsHide: true,
env: {
...process.env,
HERMES_HOME: profileDir,
},
})
})
const execTaskkill = opts.execTaskkill || taskkillWindowsProcessTree
const unlinkFile = opts.unlinkFile || unlinkSync
const profileDirs = listGatewayRuntimeProfileDirs(opts.hermesHome)
const pids = new Set<number>()
for (const profileDir of profileDirs) {
for (const fileName of GATEWAY_RUNTIME_FILES) {
const pid = readGatewayRuntimePid(join(profileDir, fileName), fileName)
if (pid !== null) pids.add(pid)
}
}
const stoppedProfileDirs: string[] = []
const killedPids: number[] = []
const deletedFiles: string[] = []
let errors = 0
for (const profileDir of profileDirs) {
try {
await stopGateway(profileDir)
stoppedProfileDirs.push(profileDir)
} catch (err) {
errors += 1
logger.warn(err, '[gateway-autostart] Hermes gateway stop failed during Windows desktop startup recovery profileDir=%s', profileDir)
}
}
for (const pid of pids) {
if (!isAlive(pid)) continue
try {
await execTaskkill(pid)
killedPids.push(pid)
} catch (err) {
errors += 1
logger.warn(err, '[gateway-autostart] failed to recover orphan Windows gateway PID %d', pid)
}
}
for (const profileDir of profileDirs) {
for (const fileName of GATEWAY_RUNTIME_FILES) {
const filePath = join(profileDir, fileName)
if (!existsSync(filePath)) continue
try {
unlinkFile(filePath)
deletedFiles.push(filePath)
} catch (err) {
errors += 1
logger.warn(err, '[gateway-autostart] failed to remove gateway runtime file %s', filePath)
}
}
}
if (killedPids.length || deletedFiles.length) {
logger.warn(
'[gateway-autostart] recovered Windows desktop gateway runtime before startup killedPids=%s deletedFiles=%s',
killedPids.join(',') || 'none',
deletedFiles.length,
)
}
return { attempted: true, profileDirs, stoppedProfileDirs, killedPids, deletedFiles, errors }
}
let windowsDesktopGatewayRecoveryAttempted = false
async function recoverWindowsDesktopGatewayOrphansOnce(): Promise<void> {
if (windowsDesktopGatewayRecoveryAttempted) return
windowsDesktopGatewayRecoveryAttempted = true
await recoverWindowsDesktopGatewayOrphans()
}
export function shouldUseManagedGatewayRun(): boolean {
return !envFlagDisabled('HERMES_WEB_UI_MANAGED_GATEWAY')
}
@@ -678,8 +517,6 @@ export async function restartGatewayForProfile(profile: string): Promise<{
}
export async function ensureProfileGatewaysRunning(): Promise<void> {
await recoverWindowsDesktopGatewayOrphansOnce()
const hermesBin = resolveHermesBin()
const discoveredProfiles = listProfileNamesFromDisk()
const { gatewayAutoStart } = await readAppConfig()
@@ -23,8 +23,11 @@ function bundledCliPythonForWindows(hermesBin: string): string | null {
const launcher = basename(hermesBin).toLowerCase()
if (launcher !== 'hermes.exe' && launcher !== 'hermes.cmd') return null
const python = resolve(dirname(hermesBin), '..', 'python.exe')
return existsSync(python) ? python : null
const dir = dirname(hermesBin)
// Self-installed layouts vary: python.exe may sit next to the launcher
// (pip --target) or one level up (embedded distribution).
const candidates = [resolve(dir, 'python.exe'), resolve(dir, '..', 'python.exe')]
return candidates.find((candidate) => existsSync(candidate)) ?? null
}
function withWindowsHide<T extends ExecFileOptions | SpawnOptions>(options?: T): T {
@@ -67,6 +70,20 @@ export function execHermes(args: readonly string[], options?: ExecFileOptions) {
return execHermesWithBin(resolveHermesBin(), args, options)
}
/**
* Probe whether the user-installed hermes CLI is reachable.
* hermes-ui no longer bundles the CLI, so startup/UI can guide the
* user to install it instead of failing on every spawn.
*/
export async function isHermesCliAvailable(hermesBin = resolveHermesBin()): Promise<boolean> {
try {
await execHermesWithBin(hermesBin, ['--version'], { timeout: 5000 })
return true
} catch {
return false
}
}
export function spawnHermesWithBin(
hermesBin: string,
args: readonly string[],
@@ -68,10 +68,6 @@ function shouldSkipTransientAutoinject(): boolean {
return isTransientAppHome(config.appHome) && !allowTransientAutoinject()
}
function isDesktopRuntime(): boolean {
return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true'
}
function candidateBundledMcpScripts(): string[] {
return [
process.env.HERMES_WEB_UI_MCP_BIN,
@@ -95,18 +91,13 @@ function runtimeNodePath(): string | null {
function managedCommandConfig(toolset: string): Record<string, unknown> {
// Prefer the bundled script with an absolute path over a bare command name.
// On Windows (especially desktop builds), `hermes-studio-mcp` may not be in
// PATH even though the bundled .mjs script exists on disk. Desktop provides
// HERMES_AGENT_NODE for the packaged runtime node; fall back to process.execPath.
// On Windows the bare `hermes-studio-mcp` command may not be in PATH even
// though the bundled .mjs script exists on disk.
const bundledScript = bundledMcpScriptPath()
if (bundledScript) {
return { command: runtimeNodePath() || process.execPath, args: [bundledScript, toolset] }
}
if (isDesktopRuntime()) {
return { command: 'hermes-studio-mcp', args: [toolset] }
}
logger.warn({ candidates: candidateBundledMcpScripts() }, '[mcp-autoinject] bundled MCP script not found; falling back to PATH command')
return { command: 'hermes-studio-mcp', args: [toolset] }
}
@@ -7,7 +7,7 @@ import { deviceIdFromPublicKey, getPublicSystemInfo, type PublicSystemInfo } fro
const DISCOVERY_VERSION = 1
export const HERMES_DISCOVERY_PORT = 48640
const DISCOVERY_PORT_OFFSET = 40_000
const DEFAULT_HTTP_PORTS = [8648, 8748]
const DEFAULT_HTTP_PORTS = [8648]
const DEFAULT_SCAN_TIMEOUT_MS = 1000
type DiscoverySocket = dgram.Socket
@@ -22,7 +22,7 @@ export type LanDeviceInfo = PublicSystemInfo & {
last_seen_at: string
}
export type LanEndpointKind = 'web' | 'desktop' | 'custom'
export type LanEndpointKind = 'web' | 'custom'
export type LanDiscoveryState = {
scanning: boolean
@@ -101,7 +101,6 @@ function discoveryPortsForHttpPorts(httpPorts: number[]): number[] {
}
export function getLanEndpointKind(httpPort: number, currentPort = config.port): LanEndpointKind {
if (httpPort === 8748) return 'desktop'
if (httpPort === 8648 || httpPort === currentPort) return 'web'
return 'custom'
}
@@ -278,7 +277,7 @@ function normalizeDevice(data: any, sourceAddress: string, responseMs: number, s
const deviceId = typeof data.device_id === 'string' && data.device_id ? data.device_id : ''
const devicePublicKey = typeof data.device_public_key === 'string' ? data.device_public_key : ''
if (!deviceId || !devicePublicKey || deviceIdFromPublicKey(devicePublicKey) !== deviceId) return null
const endpointKind = data.endpoint_kind === 'web' || data.endpoint_kind === 'desktop' || data.endpoint_kind === 'custom'
const endpointKind = data.endpoint_kind === 'web' || data.endpoint_kind === 'custom'
? data.endpoint_kind
: getLanEndpointKind(httpPort)
const url = `http://${sourceAddress}:${httpPort}`
@@ -110,12 +110,12 @@ function runtimePlatformKey(platformName = process.platform, archName = process.
return `${osLabel}-${archName}`
}
function desktopRuntimeRoot(): string {
return join(config.appHome, 'desktop-runtime')
function runtimeVersionRoot(): string {
return join(config.appHome, 'versions')
}
function activeVersionPath(): string {
return join(desktopRuntimeRoot(), ACTIVE_VERSION_FILE)
return join(runtimeVersionRoot(), ACTIVE_VERSION_FILE)
}
function downloadBaseUrl(): string {
@@ -179,7 +179,7 @@ function missingRuntimeFiles(root: string): string[] {
}
export function listInstalledRuntimeVersions(active = readActiveVersionManifest()): InstalledRuntimeVersion[] {
const root = join(desktopRuntimeRoot(), 'hermes')
const root = join(runtimeVersionRoot(), 'hermes')
if (!existsSync(root)) return []
const currentPlatform = runtimePlatformKey()
@@ -356,11 +356,11 @@ export async function downloadRuntimeVersion(version: string, source: VersionDow
const assetName = asset.name
const assetUrl = downloadAssetUrl(assetName, releaseTag, source)
const targetRoot = join(desktopRuntimeRoot(), 'hermes', cleanVersion, platform)
const archive = join(desktopRuntimeRoot(), `${basename(assetName)}.download`)
const tempRoot = join(desktopRuntimeRoot(), `.runtime-download-${process.pid}-${Date.now()}`)
const targetRoot = join(runtimeVersionRoot(), 'hermes', cleanVersion, platform)
const archive = join(runtimeVersionRoot(), `${basename(assetName)}.download`)
const tempRoot = join(runtimeVersionRoot(), `.runtime-download-${process.pid}-${Date.now()}`)
mkdirSync(desktopRuntimeRoot(), { recursive: true })
mkdirSync(runtimeVersionRoot(), { recursive: true })
rmSync(tempRoot, { recursive: true, force: true })
mkdirSync(tempRoot, { recursive: true })
@@ -405,12 +405,12 @@ export async function downloadWebUiVersion(version: string, source: VersionDownl
const manifestUrl = downloadAssetUrl(manifestName, releaseTag, source)
onProgress?.({ stage: 'resolve', message: 'runtimeVersions.jobStage.resolveWebUi' })
const manifest = await fetchJson<{ asset?: { sha256?: string; size?: number } }>(manifestUrl)
const archive = join(desktopRuntimeRoot(), `${assetName}.download`)
const tempRoot = join(desktopRuntimeRoot(), `.webui-download-${process.pid}-${Date.now()}`)
const archive = join(runtimeVersionRoot(), `${assetName}.download`)
const tempRoot = join(runtimeVersionRoot(), `.webui-download-${process.pid}-${Date.now()}`)
const targetRoot = join(config.appHome, 'webui', cleanVersion)
const assetUrl = downloadAssetUrl(assetName, releaseTag, source)
mkdirSync(desktopRuntimeRoot(), { recursive: true })
mkdirSync(runtimeVersionRoot(), { recursive: true })
rmSync(tempRoot, { recursive: true, force: true })
mkdirSync(tempRoot, { recursive: true })
+1 -3
View File
@@ -5,7 +5,6 @@ import { shutdownManagedGateways } from './hermes/gateway-runner'
import { stopOutboundRelayClient } from './global-agent/outbound-relay-client'
const DEFAULT_SHUTDOWN_FORCE_EXIT_MS = 15_000
const DEFAULT_DESKTOP_SHUTDOWN_FORCE_EXIT_MS = 15_000
function envPositiveInt(name: string): number | undefined {
const value = Number(process.env[name])
@@ -15,8 +14,7 @@ function envPositiveInt(name: string): number | undefined {
export function getShutdownForceExitMs(): number {
const override = envPositiveInt('HERMES_WEB_UI_SHUTDOWN_FORCE_EXIT_MS')
if (override) return override
const desktop = String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true'
return desktop ? DEFAULT_DESKTOP_SHUTDOWN_FORCE_EXIT_MS : DEFAULT_SHUTDOWN_FORCE_EXIT_MS
return DEFAULT_SHUTDOWN_FORCE_EXIT_MS
}
export function shouldStopAgentBridgeOnShutdown(_signal: string): boolean {