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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 || ''),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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