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:
@@ -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 })
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user