Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Already have the Hermes CLI? Just run:
hermes desktop
```

It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. On first launch Hermes walks you through picking a provider and model; nothing else to configure.
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. If Desktop cannot find a usable runtime or saved remote connection, first launch lets you connect to an existing Hermes gateway or install Hermes locally. Local onboarding then walks you through choosing a provider and model.

### Prebuilt installers

Expand Down Expand Up @@ -134,6 +134,19 @@ Desktop supports a managed local backend, explicit remote gateways, and Hermes
Cloud connections. Remote and cloud modes use the same remote-capability path;
authentication and discovery differ, not the renderer feature model.

When no usable local runtime or saved remote connection exists, the first-run
screen offers **Connect to existing Hermes** before starting the local installer.
Desktop probes the gateway to discover token or OAuth authentication, requires a
successful HTTP and WebSocket connection test, and saves the connection using
the same encrypted Desktop configuration used by Settings. A saved remote
connection bypasses this choice on later launches. The regular Desktop build
still includes the local-install option; this is a remote operating mode, not a
separate client-only application.

In remote mode the gateway host is the execution boundary: agent tools,
terminal commands, and file operations run against the remote Hermes host, not
the computer displaying the Desktop UI.

Projects are the workspace abstraction. A project may own multiple folders,
repositories, worktrees, and sessions; a bare new chat remains detached unless
the user enters a project or configures a default project directory. Use the
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/electron/connection-apply.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
async function applyConnectionChange({
cancelAndWait,
isPrimary,
rehomePrimary = null,
scope,
sendApplied,
stopPool,
Expand All @@ -16,6 +17,12 @@ async function applyConnectionChange({
return
}

if (rehomePrimary) {
await rehomePrimary()

return
}

await teardownPrimary()
sendApplied()
}
Expand Down
133 changes: 133 additions & 0 deletions apps/desktop/electron/first-run-setup-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict'

import { test } from 'vitest'

import { createFirstRunSetupGate } from './first-run-setup-gate'

const bootstrapBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bootstrap-needed',
platform: 'linux'
}

function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}

async function settledState(promise: Promise<unknown>) {
return Promise.race([promise.then(() => 'resolved'), delay(10).then(() => 'pending')])
}

test('first-run setup gate skips non-bootstrap backends', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })

await gate.wait({ kind: 'remote' })
await gate.wait(null)

assert.deepEqual(prompts, [])
assert.equal(gate.hasWaiter(), false)
})

test('first-run setup gate prompts once for concurrent waits', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })

const first = gate.wait(bootstrapBackend)
const second = gate.wait(bootstrapBackend)

assert.equal(gate.hasWaiter(), true)
assert.equal(prompts.length, 1)
assert.equal(await settledState(first), 'pending')

gate.continueLocal()

assert.deepEqual(await Promise.all([first, second]), ['continue-local', 'continue-local'])
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})

test('continueLocal keeps the setup choice visible until bootstrap owns the overlay', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)

gate.continueLocal()

assert.equal(await pending, 'continue-local')
assert.equal(hidden, 0)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})

test('retry reset preserves the local install confirmation', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })

const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
await pending

gate.resetForRetry()
await gate.wait(bootstrapBackend)

assert.equal(gate.isLocalBootstrapConfirmed(), true)
assert.equal(prompts.length, 1)
assert.equal(gate.hasWaiter(), false)
})

test('retry reset explicitly settles an active waiter without allowing local bootstrap', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)

gate.resetForRetry()

assert.equal(await pending, 'reset')
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
})

test('repair reset clears the local install confirmation and shows the gate again', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })

const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
await pending

gate.resetForRepair()
const next = gate.wait(bootstrapBackend)

assert.equal(gate.isLocalBootstrapConfirmed(), false)
assert.equal(prompts.length, 2)
assert.equal(gate.hasWaiter(), true)

gate.continueLocal()
await next
})

test('remote apply settles the gated boot for remote re-resolution and hides the choice', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)

const resumedWaiter = gate.abandonForRemoteApply()

assert.equal(resumedWaiter, true)
assert.equal(hidden, 1)
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
assert.equal(await pending, 'remote-applied')
})

test('remote apply without a waiter has no first-run side effects', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)

gate.continueLocal()
await pending

assert.equal(gate.abandonForRemoteApply(), false)
assert.equal(hidden, 0)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
146 changes: 146 additions & 0 deletions apps/desktop/electron/first-run-setup-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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
}
}
Loading
Loading