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
+1 -1
View File
@@ -31,7 +31,7 @@ import { homedir } from 'os'
* - HERMES_WEB_UI_DISABLE_MCP_AUTOINJECT: Disable Hermes Studio MCP config injection.
* - HERMES_WEB_UI_ALLOW_TRANSIENT_MCP_AUTOINJECT: Allow MCP injection when HERMES_WEB_UI_HOME is under a temp dir.
* - HERMES_LAN_DISCOVERY_ENABLED: Set false/0/off to disable UDP LAN discovery responder.
* - HERMES_LAN_DISCOVERY_HTTP_PORTS: HTTP ports to probe during UDP discovery scans. Default: 8648,8748 plus current PORT.
* - HERMES_LAN_DISCOVERY_HTTP_PORTS: HTTP ports to probe during UDP discovery scans. Default: 8648 plus current PORT.
* Discovery probes are sent to the fixed UDP port 48640 plus legacy mapped ports for compatibility.
* - WORKSPACE_BASE: Base directory for workspace browsing. Default: current user's home directory.
*
+1 -3
View File
@@ -62,9 +62,7 @@ export async function currentUser(ctx: Context) {
updated_at: user.updated_at,
last_login_at: user.last_login_at,
avatar: user.avatar || '',
requiresCredentialChange: process.env.HERMES_DESKTOP === 'true'
? false
: user.username === DEFAULT_USERNAME && verifyPassword(DEFAULT_PASSWORD, user.password_hash),
requiresCredentialChange: user.username === DEFAULT_USERNAME && verifyPassword(DEFAULT_PASSWORD, user.password_hash),
},
}
}
+2 -2
View File
@@ -268,7 +268,7 @@ function deviceFromLinkInfo(baseUrl: URL, info: LinkInfoResponse, latencyMs: num
if (deviceIdFromPublicKey(publicKey) !== deviceId) return null
const httpPort = Number(info.http_port) || Number(baseUrl.port) || (baseUrl.protocol === 'https:' ? 443 : 80)
if (!Number.isInteger(httpPort) || httpPort <= 0 || httpPort > 65535) return null
const endpointKind = info.endpoint_kind === 'web' || info.endpoint_kind === 'desktop' || info.endpoint_kind === 'custom'
const endpointKind = info.endpoint_kind === 'web' || info.endpoint_kind === 'custom'
? info.endpoint_kind
: getLanEndpointKind(httpPort)
@@ -419,7 +419,7 @@ function bodyToDevice(ctx: any, body: any): LanDeviceInfo | null {
const httpPort = Number(body?.http_port)
if (!deviceId || !publicKey || !Number.isInteger(httpPort) || httpPort <= 0 || httpPort > 65535) return null
const ip = normalizeIp(ctx)
const endpointKind = body.endpoint_kind === 'web' || body.endpoint_kind === 'desktop' || body.endpoint_kind === 'custom'
const endpointKind = body.endpoint_kind === 'web' || body.endpoint_kind === 'custom'
? body.endpoint_kind
: getLanEndpointKind(httpPort)
+19 -1
View File
@@ -4,6 +4,7 @@ import * as hermesCli from '../services/hermes/hermes-cli'
import { getAgentBridgeManager } from '../services/hermes/agent-bridge/manager'
import { redactAgentBridgeError } from '../services/hermes/agent-bridge/redact'
import { isDockerContainer } from '../services/runtime-environment'
import { isHermesCliAvailable, resolveHermesBin } from '../services/hermes/hermes-process'
declare const __APP_VERSION__: string
@@ -72,7 +73,7 @@ let pendingAgentBridgeHealthRefresh: Promise<AgentBridgeHealthPayload> | null =
* Whether the periodic npm-registry version check is disabled.
*
* Useful when hermes-ui is bundled inside a packaged distribution
* (e.g. a desktop app) where the user can't `npm install -g hermes-ui@latest`
* where the user can't `npm install -g hermes-ui@latest`
* to upgrade — the "update available" prompt would be misleading and
* the periodic outbound HTTP request to the npm registry is unnecessary.
*
@@ -207,3 +208,20 @@ export async function healthCheck(ctx: any) {
is_docker: isDockerContainer(),
}
}
let cachedCliStatus: { value: boolean; expiresAt: number } | null = null
const CLI_STATUS_CACHE_TTL_MS = 30_000
/**
* Whether the user-installed hermes CLI can be resolved and executed.
* hermes-ui no longer bundles the CLI, so the web UI probes this
* (public, pre-login) to show an install guide when it is missing.
*/
export async function hermesCliStatus(ctx: any) {
const now = Date.now()
if (!cachedCliStatus || cachedCliStatus.expiresAt <= now) {
const available = await isHermesCliAvailable()
cachedCliStatus = { value: available, expiresAt: now + CLI_STATUS_CACHE_TTL_MS }
}
ctx.body = { available: cachedCliStatus.value, bin: resolveHermesBin() }
}
@@ -78,7 +78,7 @@ function rowToRecord(row: StoredDeviceRow | Record<string, any>): DeviceRelation
outbound_status: normalizeOutboundStatus(row.outbound_status),
device_public_key: String(row.device_public_key || ''),
computer_name: String(row.computer_name || ''),
endpoint_kind: row.endpoint_kind === 'web' || row.endpoint_kind === 'desktop' ? row.endpoint_kind : 'custom',
endpoint_kind: row.endpoint_kind === 'web' ? 'web' : 'custom',
ip: String(row.ip || ''),
http_port: Number(row.http_port || 0),
url: String(row.url || ''),
+3 -101
View File
@@ -1,5 +1,4 @@
import Koa from 'koa'
import type { Context } from 'koa'
import cors from '@koa/cors'
import bodyParser from '@koa/bodyparser'
import serve from 'koa-static'
@@ -19,7 +18,7 @@ import { setGroupChatServer } from './routes/hermes/group-chat'
import { setChatRunServer } from './routes/hermes/chat-run'
import { GroupChatServer } from './services/hermes/group-chat'
import { ChatRunSocket } from './services/hermes/run-chat'
import { getAgentBridgeManager, startAgentBridgeManager } from './services/hermes/agent-bridge'
import { startAgentBridgeManager } from './services/hermes/agent-bridge'
import { HermesSkillInjector } from './services/hermes/skill-injector'
import { injectBundledMcpServer } from './services/hermes/studio-mcp-autoinject'
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
@@ -33,7 +32,6 @@ import { logger } from './services/logger'
import { createStaticCompressionMiddleware } from './middleware/static-compression'
import { requireUserJwt, resolveUserProfile } from './middleware/user-auth'
import { createCorsOriginResolver, securityHeaders } from './security'
import type { ShutdownHandler } from './services/shutdown'
// Injected by esbuild at build time; fallback to reading package.json in dev mode
declare const __APP_VERSION__: string
@@ -61,7 +59,6 @@ let chatRunServer: any = null
let workflowSocketServer: WorkflowSocketServer | null = null
let petStateSocketServer: PetStateSocketServer | null = null
let agentBridgeManager: any = null
let desktopShutdownHandler: ShutdownHandler | null = null
interface ListenResult {
primary: any
@@ -101,65 +98,6 @@ function safeNetworkInterfaces() {
}
}
function isDesktopRuntime(): boolean {
return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true'
}
function isLoopbackAddress(address?: string | null): boolean {
if (!address) return false
return address === '127.0.0.1'
|| address === '::1'
|| address === '::ffff:127.0.0.1'
|| address.startsWith('::ffff:127.')
}
function bearerToken(ctx: Context): string {
const header = ctx.get('authorization')
const match = header.match(/^Bearer\s+(.+)$/i)
return match?.[1]?.trim() || ''
}
function registerDesktopShutdownRoute(app: Koa): void {
app.use(async (ctx, next) => {
if (ctx.method !== 'POST' || ctx.path !== '/api/desktop/shutdown') {
await next()
return
}
if (!isDesktopRuntime()) {
ctx.status = 404
ctx.body = { error: 'not_found' }
return
}
const remoteAddress = ctx.req.socket.remoteAddress
if (!isLoopbackAddress(remoteAddress)) {
ctx.status = 403
ctx.body = { error: 'forbidden' }
return
}
const expectedToken = String(process.env.AUTH_TOKEN || '').trim()
if (!expectedToken || bearerToken(ctx) !== expectedToken) {
ctx.status = 401
ctx.body = { error: 'unauthorized' }
return
}
if (!desktopShutdownHandler) {
ctx.status = 503
ctx.body = { error: 'shutdown_not_ready' }
return
}
ctx.status = 202
ctx.body = { ok: true }
setTimeout(() => {
void desktopShutdownHandler?.('desktop-api')
}, 50).unref?.()
})
}
function envFlagEnabled(name: string): boolean {
const value = String(process.env[name] || '').trim().toLowerCase()
return ['1', 'true', 'yes', 'on'].includes(value)
@@ -194,33 +132,6 @@ async function startRuntimeServicesBeforeListen(): Promise<void> {
}
}
function startRuntimeServicesAfterListen(): void {
if (gatewayAutostartDisabled()) {
console.log('[bootstrap] profile gateway check disabled by HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART')
} else {
void (async () => {
try {
await ensureProfileGatewaysRunning()
console.log('[bootstrap] profile gateways checked')
} catch (err) {
logger.warn(err, '[bootstrap] failed to ensure profile gateways')
console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err)
}
})()
}
void (async () => {
try {
agentBridgeManager = await startAgentBridgeManager()
console.log('[bootstrap] agent bridge started')
} catch (err) {
logger.warn(err, '[bootstrap] agent bridge failed to start')
console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err)
return
}
})()
}
function startLanDiscovery(): void {
const discoverySocket = startLanDiscoveryResponder({ httpPort: config.port })
let initialScanStarted = false
@@ -278,9 +189,7 @@ export async function bootstrap() {
console.warn('[bootstrap] failed to inject bundled MCP server:', err instanceof Error ? err.message : err)
}
if (!isDesktopRuntime()) {
await startRuntimeServicesBeforeListen()
}
await startRuntimeServicesBeforeListen()
const app = new Koa()
// Initialize all web-ui SQLite tables
@@ -301,8 +210,6 @@ export async function bootstrap() {
}))
console.log('[bootstrap] cors + bodyParser registered')
registerDesktopShutdownRoute(app)
// Register all routes (handles auth internally)
registerRoutes(app, [requireUserJwt, resolveUserProfile])
console.log('[bootstrap] routes registered')
@@ -379,11 +286,6 @@ export async function bootstrap() {
startLanDiscovery()
refreshConfiguredProviderModelCatalogsInBackground('bootstrap')
if (isDesktopRuntime()) {
agentBridgeManager = getAgentBridgeManager()
startRuntimeServicesAfterListen()
}
// Restore group chat agents after server is ready.
groupChatServer.restoreWhenReady()
@@ -394,7 +296,7 @@ export async function bootstrap() {
})
})
desktopShutdownHandler = bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
startVersionCheck()
}
+2
View File
@@ -4,5 +4,7 @@ import * as ctrl from '../controllers/health'
export const healthRoutes = new Router()
healthRoutes.get('/health', ctrl.healthCheck)
// Public (pre-login): the router guard probes this before showing the app.
healthRoutes.get('/api/hermes/cli-status', ctrl.hermesCliStatus)
export { startVersionCheck } from '../controllers/health'
@@ -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 {