mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
Adds a first-run Desktop choice between installing Hermes locally and connecting to an existing remote Hermes gateway. The choice appears after backend resolution but before ensureRuntime(), so selecting remote cannot accidentally trigger local bootstrap. New modules: - first-run-setup-gate: concurrent first-run decision gate and reset semantics - primary-backend-startup: Electron-free orchestration seam (saved remote resolution, gate decision, remote re-resolution, local continuation) - primary-connection-rehome: prevents dual-owner race where both cold boot() and renderer softSwitch() could connect simultaneously - first-run-remote-form: extracted remote form with stale-result guards Reuses existing connection-config IPC, encrypted token storage, OAuth session partition, and primary backend resolution. Fixes #38602 Fixes #36970
146 lines
3.6 KiB
TypeScript
146 lines
3.6 KiB
TypeScript
interface FirstRunSetupBackend {
|
|
activeRoot?: string
|
|
kind?: string
|
|
platform?: string
|
|
}
|
|
|
|
interface FirstRunSetupGateOptions {
|
|
hideChoice?: () => void
|
|
log?: (message: string) => void
|
|
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
|
|
promptChoice?: (backend: FirstRunSetupBackend) => void
|
|
stuckAfterMs?: number
|
|
}
|
|
|
|
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset'
|
|
|
|
export function createFirstRunSetupGate({
|
|
hideChoice,
|
|
log,
|
|
onStuck,
|
|
promptChoice,
|
|
stuckAfterMs = 120000
|
|
}: FirstRunSetupGateOptions = {}) {
|
|
let localBootstrapConfirmed = false
|
|
|
|
let waiter: {
|
|
promise: Promise<FirstRunSetupDecision>
|
|
resolve: (decision: FirstRunSetupDecision) => void
|
|
} | null = null
|
|
|
|
let stuckTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const clearStuckTimer = () => {
|
|
if (stuckTimer) {
|
|
clearTimeout(stuckTimer)
|
|
stuckTimer = null
|
|
}
|
|
}
|
|
|
|
const armStuckTimer = (backend: FirstRunSetupBackend) => {
|
|
clearStuckTimer()
|
|
|
|
if (!Number.isFinite(stuckAfterMs) || stuckAfterMs <= 0 || typeof log !== 'function') {
|
|
return
|
|
}
|
|
|
|
stuckTimer = setTimeout(() => {
|
|
onStuck?.(backend, stuckAfterMs)
|
|
log(
|
|
`[bootstrap] still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)}s ` +
|
|
`(platform=${backend?.platform || 'unknown'})`
|
|
)
|
|
}, stuckAfterMs)
|
|
|
|
if (typeof stuckTimer.unref === 'function') {
|
|
stuckTimer.unref()
|
|
}
|
|
}
|
|
|
|
const shouldGate = (backend?: FirstRunSetupBackend | null) =>
|
|
Boolean(backend && backend.kind === 'bootstrap-needed' && !localBootstrapConfirmed)
|
|
|
|
const wait = async (backend?: FirstRunSetupBackend | null) => {
|
|
if (!shouldGate(backend)) {
|
|
return 'continue-local' as const
|
|
}
|
|
|
|
if (waiter) {
|
|
return waiter.promise
|
|
}
|
|
|
|
promptChoice?.(backend)
|
|
armStuckTimer(backend)
|
|
|
|
let resolveWaiter: (decision: FirstRunSetupDecision) => void = () => {}
|
|
|
|
const promise = new Promise<FirstRunSetupDecision>(resolve => {
|
|
resolveWaiter = resolve
|
|
})
|
|
|
|
waiter = { promise, resolve: resolveWaiter }
|
|
|
|
return promise
|
|
}
|
|
|
|
const settleWaiter = (decision: FirstRunSetupDecision) => {
|
|
clearStuckTimer()
|
|
|
|
if (!waiter) {
|
|
return false
|
|
}
|
|
|
|
const activeWaiter = waiter
|
|
waiter = null
|
|
activeWaiter.resolve(decision)
|
|
|
|
return true
|
|
}
|
|
|
|
const continueLocal = () => {
|
|
localBootstrapConfirmed = true
|
|
settleWaiter('continue-local')
|
|
}
|
|
|
|
const resetForRetry = () => {
|
|
// Reset paths are followed by a renderer reload / fresh startHermes() call.
|
|
// Settle the old boot explicitly so it cannot fall through into local
|
|
// bootstrap and cannot leak a forever-pending connection promise.
|
|
settleWaiter('reset')
|
|
}
|
|
|
|
const resetForRepair = () => {
|
|
resetForRetry()
|
|
localBootstrapConfirmed = false
|
|
}
|
|
|
|
const abandonForRemoteApply = () => {
|
|
// Resume the gated startHermes() with an explicit remote decision. The
|
|
// caller re-resolves the newly-persisted remote config instead of falling
|
|
// through into local bootstrap or leaking the original connection promise.
|
|
const resumedWaiter = settleWaiter('remote-applied')
|
|
|
|
if (!resumedWaiter) {
|
|
return false
|
|
}
|
|
|
|
localBootstrapConfirmed = false
|
|
hideChoice?.()
|
|
|
|
return true
|
|
}
|
|
|
|
const isLocalBootstrapConfirmed = () => localBootstrapConfirmed
|
|
const hasWaiter = () => Boolean(waiter)
|
|
|
|
return {
|
|
abandonForRemoteApply,
|
|
continueLocal,
|
|
hasWaiter,
|
|
isLocalBootstrapConfirmed,
|
|
resetForRepair,
|
|
resetForRetry,
|
|
shouldGate,
|
|
wait
|
|
}
|
|
}
|