# NousResearch/hermes-agent · Desktop app boot overlay and recovery fixes

## Open now

- Nothing open on GitHub.

These items relate to the desktop app's handling of startup issues, specifically improving the boot overlay to be dismissible and allowing recovery from a gateway error state.

## TL;DR

- 5 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #63030. Verify it addresses your case.
- **Recommended:** read PR #63030 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-63030,pr-122035`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.34`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #54545: \[Bug\]: Settings unreachable during startup error — no Settings item in the macOS app menu (⌘,), and the recovery screen blocks the only in-app path
- #62533: \[Bug\]: Desktop App: Hermes couldnt start notification blocks the UI

## Prior & competing PRs

- **#63030: fix(desktop): recover a failed gateway from the boot-failure screen**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/src/app/settings/gateway-settings.tsx`, `apps/desktop/src/app/settings/primitives.tsx`, `apps/desktop/src/components/boot-failure-overlay.test.tsx`, `apps/desktop/src/components/boot-failure-overlay.tsx`, `apps/desktop/src/components/boot-failure-reauth.test.ts`, `apps/desktop/src/components/boot-failure-reauth.ts`, `apps/desktop/src/components/gateway-connecting-overlay.test.tsx`, `apps/desktop/src/i18n/en.ts`, `apps/desktop/src/i18n/ja.ts`, `apps/desktop/src/i18n/types.ts`, `apps/desktop/src/i18n/zh-hant.ts`, `apps/desktop/src/i18n/zh.ts`
  - fix hunks:
  - `apps/desktop/src/app/settings/gateway-settings.tsx`

    ```diff
    @@ -110,7 +110,11 @@ function ScopeChip({ active, label, onSelect }: { active: boolean; label: string
       )
     }
     
    -export function GatewaySettings() {
    +// `embedded` trims the page chrome for reuse inside the boot-failure recovery
    +// card: the outer title/intro, the "Save for next restart" action, and the
    +// Diagnostics row are redundant there (the card owns its header + a single
    +// reconnect action), so only the connection controls render.
    +export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {}) {
       const { t } = useI18n()
       const g = t.settings.gateway
       const [loading, setLoading] = useState(true)
    ```

    ```diff
    @@ -707,17 +711,19 @@ export function GatewaySettings() {
       }
     
       return (
    -    <SettingsContent>
    -      <div className="mb-5">
    -        <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
    -          <Globe className="size-4 text-muted-foreground" />
    -          {g.title}
    -          {state.envOverride ? <Pill tone="primary">{g.envOverride}</Pill> : null}
    +    <SettingsContent bare={embedded}>
    +      {embedded ? null : (
    +        <div className="mb-5">
    +          <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
    +            <Globe className="size-4 text-muted-foreground" />
    +            {g.title}
    +            {state.envOverride ? <Pill tone="primary">{g.envOverride}</Pill> : null}
    +          </div>
    +          <p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
    +            {g.intro}
    +          </p>
             </div>
    -        <p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
    -          {g.intro}
    -        </p>
    -      </div>
    +      )}
     
           {namedProfiles.length > 0 ? (
             <div className="mb-5 grid gap-2">
    ```

    ```diff
    @@ -1037,14 +1043,16 @@ export function GatewaySettings() {
                   {g.testRemote}
                 </Button>
               ) : null}
    -          <Button
    -            disabled={state.envOverride || saving}
    -            onClick={() => void save(false)}
    -            size="sm"
    -            variant="textStrong"
    -          >
    -            {g.saveForRestart}
    -          </Button>
    +          {embedded ? null : (
    +            <Button
    +              disabled={state.envOverride || saving}
    +              onClick={() => void save(false)}
    +              size="sm"
    +              variant="textStrong"
    +            >
    +              {g.saveForRestart}
    +            </Button>
    +          )}
               <Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
                 {saving ? <Loader2 className="animate-spin" /> : null}
                 {g.saveAndReconnect}
    ```

    ```diff
    @@ -1052,18 +1060,20 @@ export function GatewaySettings() {
             </div>
           ) : null}
     
    -      <div className="mt-6 grid gap-1">
    -        <ListRow
    -          action={
    -            <Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
    -              <FileText />
    -              {g.openLogs}
    -            </Button>
    -          }
    -          description={g.diagnosticsDesc}
    -          title={g.diagnostics}
    -        />
    -      </div>
    +      {embedded ? null : (
    +        <div className="mt-6 grid gap-1">
    +          <ListRow
    +            action={
    +              <Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
    +                <FileText />
    +                {g.openLogs}
    +              </Button>
    +            }
    +            description={g.diagnosticsDesc}
    +            title={g.diagnostics}
    +          />
    +        </div>
    +      )}
         </SettingsContent>
       )
     }
    ```
  - `apps/desktop/src/app/settings/primitives.tsx`

    ```diff
    @@ -8,10 +8,14 @@ import { cn } from '@/lib/utils'
     
     import { PAGE_INSET_X } from '../layout-constants'
     
    -export function SettingsContent({ children }: { children: ReactNode }) {
    +// `bare` drops the page gutters + tall bottom pad for embedding in a tighter
    +// surface (e.g. the boot-failure recovery card owns its own padding).
    +export function SettingsContent({ children, bare = false }: { children: ReactNode; bare?: boolean }) {
       return (
         <section className="min-h-0 overflow-hidden">
    -      <div className={cn('h-full min-h-0 overflow-y-auto pb-20', PAGE_INSET_X)}>{children}</div>
    +      <div className={cn('h-full min-h-0 overflow-y-auto', bare ? 'px-5 pb-6' : cn('pb-20', PAGE_INSET_X))}>
    +        {children}
    +      </div>
         </section>
       )
     }
    ```
  - `apps/desktop/src/components/boot-failure-overlay.test.tsx`

    ```diff
    @@ -0,0 +1,101 @@
    +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
    +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
    +
    +import { $desktopBoot } from '@/store/boot'
    +import { $desktopOnboarding } from '@/store/onboarding'
    +
    +import { BootFailureOverlay } from './boot-failure-overlay'
    +
    +// Remote-backend users hit a hard boot failure that isn't OAuth reauth (token
    +// auth, wrong URL, unreachable host). The recovery screen must let them fix the
    +// remote connection in place — the "Connection settings" action swaps the card
    +// to an in-line connect form — instead of stranding them (the old bug forced a
    +// hand-edit of connection.json).
    +
    +function failBoot() {
    +  $desktopBoot.set({
    +    error: 'Could not connect to Hermes gateway',
    +    fakeMode: false,
    +    message: 'boot failed',
    +    phase: 'renderer.error',
    +    progress: 40,
    +    running: false,
    +    timestamp: Date.now(),
    +    visible: true
    +  })
    +}
    +
    +function stubDesktop(config: Record<string, unknown>) {
    +  const original = window.hermesDesktop
    +  Object.defineProperty(window, 'hermesDesktop', {
    +    configurable: true,
    +    value: { getRecentLogs: async () => ({ lines: [] }), getConnectionConfig: async () => config }
    +  })
    +
    +  return () => Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: original })
    +}
    +
    +const remoteToken = {
    +  envOverride: false,
    +  mode: 'remote',
    +  profile: null,
    +  remoteAuthMode: 'token',
    +  remoteOauthConnected: false,
    +  remoteTokenPreview: null,
    +  remoteTokenSet: true,
    +  remoteUrl: 'http://100.116.104.53:9191',
    +  cloudOrg: ''
    +}
    +
    +beforeEach(() => {
    +  $desktopOnboarding.set({
    +    configured: true,
    +    flow: { status: 'idle' },
    +    mode: 'oauth',
    +    providers: null,
    +    reason: null,
    +    requested: false,
    +    firstRunSkipped: false,
    +    manual: false,
    +    localEndpoint: false
    +  })
    +  failBoot()
    +})
    +
    +afterEach(cleanup)
    +
    +describe('BootFailureOverlay', () => {
    +  it('swaps to the in-place gateway settings view (no route nav) and back', async () => {
    +    render(<BootFailureOverlay />)
    +
    +    fireEvent.click(screen.getByRole('button', { name: /gateway settings/i }))
    +    // Recovery actions give way to the embedded panel (behind a Back control).
    +    expect(await screen.findByRole('button', { name: /back/i })).toBeTruthy()
    +    expect(screen.queryByRole('button', { name: /retry/i })).toBeNull()
    +
    +    fireEvent.click(screen.getByRole('button', { name: /back/i }))
    +    expect(screen.getByRole('button', { name: /retry/i })).toBeTruthy()
    +    expect(screen.queryByRole('button', { name: /back/i })).toBeNull()
    +  })
    +
    +  it('drops local-only Repair and Use-local-gateway on a local failure', () => {
    +    render(<BootFailureOverlay />)
    +    // No connection config stub → treated as a local failure.
    +    expect(screen.getByRole('button', { name: /retry/i })).toBeTruthy()
    +    expect(screen.getByRole('button', { name: /repair/i })).toBeTruthy()
    +    expect(screen.queryByRole('button', { name: /use local gateway/i })).toBeNull()
    +  })
    +
    +  it('leads with Gateway settings and drops Repair for a remote (token) failure', async () => {
    +    const restore = stubDesktop(remoteToken)
    +
    +    try {
    +      render(<BootFailureOverlay />)
    +      await waitFor(() => expect(screen.queryByRole('button', { name: /repair/i })).toBeNull())
    +      expect(screen.getByRole('button', { name: /gateway settings/i })).toBeTruthy()
    +      expect(screen.getByRole('button', { name: /use local gateway/i })).toBeTruthy()
    +    } finally {
    +      restore()
    +    }
    +  })
    +})
    ```
  - `apps/desktop/src/components/boot-failure-overlay.tsx`

    ```diff
    @@ -1,20 +1,29 @@
     import { useStore } from '@nanostores/react'
    -import { useEffect, useState } from 'react'
    +import { type ComponentProps, lazy, type ReactNode, Suspense, useEffect, useState } from 'react'
     
     import { Button } from '@/components/ui/button'
     import { ErrorIcon } from '@/components/ui/error-state'
    +import { Loader } from '@/components/ui/loader'
     import { LogView } from '@/components/ui/log-view'
     import type { DesktopConnectionConfig } from '@/global'
     import { useI18n } from '@/i18n'
    -import { FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
    +import { ChevronLeft, FileText, Loader2, LogIn, RefreshCw, SlidersHorizontal, Wrench } from '@/lib/icons'
     import { $desktopBoot } from '@/store/boot'
     import { notify, notifyError } from '@/store/notifications'
     import { $desktopOnboarding } from '@/store/onboarding'
     
     import type { RemoteReauth } from './boot-failure-reauth'
    -import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
    +import { deriveProviderShape, isRemoteConfig, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
    +
    +// The recovery "Gateway settings" view embeds the real Settings → Gateway panel
    +// (identical URL/auth/test/save controls — no parallel form to drift). Lazy so
    +// it stays out of the always-mounted overlay's bundle until opened.
    +const GatewaySettings = lazy(() =>
    +  import('@/app/settings/gateway-settings').then(module => ({ default: module.GatewaySettings }))
    +)
     
     type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null
    +type RecoveryView = 'connect' | 'recovery'
     
     // A remote gateway whose access cookie has lapsed (e.g. the dashboard
     // restarted on the remote box) boots into this overlay with a reauth-shaped
    ```

    ```diff
    @@ -35,6 +44,13 @@ export function BootFailureOverlay() {
       const [logs, setLogs] = useState<string[]>([])
       const [showLogs, setShowLogs] = useState(false)
       const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null)
    +  // A remote/cloud backend that failed to boot is fixable from gateway settings,
    +  // so the escape hatch earns emphasis (local failures keep it as a quiet ghost).
    +  const [remoteFailure, setRemoteFailure] = useState(false)
    +  // Swap the card body to the embedded Gateway settings panel in place of routing
    +  // to the full Settings page (keeps the user on the recovery surface, no z-index
    +  // juggling, no second connection form to maintain).
    +  const [view, setView] = useState<RecoveryView>('recovery')
     
       const visible = Boolean(boot.error) && !boot.running
       // While first-run onboarding owns the picker/flow we let it surface its own
    ```

    ```diff
    @@ -51,7 +67,7 @@ export function BootFailureOverlay() {
           ?.getRecentLogs()
           .then(res => setLogs(res.lines ?? []))
           .catch(() => undefined)
    -  }, [visible])
    +  }, [boot.error, visible])
     
       // Resolve whether this boot failure is a remote-gateway reauth so we can
       // offer the actionable "Sign in" path instead of the local-only recovery
    ```

    ```diff
    @@ -59,6 +75,8 @@ export function BootFailureOverlay() {
       useEffect(() => {
         if (!visible) {
           setRemoteReauth(null)
    +      setRemoteFailure(false)
    +      setView('recovery')
     
           return
         }
    ```

    ```diff
    @@ -80,7 +98,13 @@ export function BootFailureOverlay() {
             return
           }
     
    -      if (cancelled || !isRemoteReauthFailure(config)) {
    +      if (cancelled) {
    +        return
    +      }
    +
    +      setRemoteFailure(isRemoteConfig(config))
    +
    +      if (!isRemoteReauthFailure(config, boot.error)) {
             return
           }
     
    ```

    ```diff
    @@ -104,7 +128,7 @@ export function BootFailureOverlay() {
         return () => {
           cancelled = true
         }
    -  }, [visible])
    +  }, [boot.error, visible])
     
       if (!visible || suppressed) {
         return null
    ```

    ```diff
    @@ -129,11 +153,12 @@ export function BootFailureOverlay() {
         setBusy(null)
       }
     
    -  // Open the gateway's login window (renders the username/password form for a
    -  // basic gateway, or the OAuth redirect otherwise — the desktop drives both
    -  // through the same window). On a successful sign-in the session cookie is
    -  // re-established in the persistent partition; reload so boot re-runs and the
    -  // reconnect now mints a ticket against a live session.
    +  // Clear the OAuth partition first, then open the gateway's login window
    +  // (username/password form or OAuth redirect — the desktop drives both). A
    +  // partition-wide sign-out drops stale gateway AND identity-provider cookies so
    +  // an expired session can't silently bounce us back into the same state. On a
    +  // successful sign-in the cookie is re-established; reload so boot mints a fresh
    +  // ticket against a live session.
       const signInRemote = async () => {
         if (!remoteReauth) {
           return
    ```

    ```diff
    @@ -142,6 +167,7 @@ export function BootFailureOverlay() {
         setBusy('signin')
     
         try {
    +      await window.hermesDesktop?.oauthLogoutConnectionConfig?.()
           const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url)
     
           if (result?.connected) {
    ```

    ```diff
    @@ -172,6 +198,92 @@ export function BootFailureOverlay() {
         withProvider: copy.signInWithProvider
       })
     
    +  // Recovery actions are shaped by the failure kind so the leading (primary)
    +  // button is the one that actually fixes it: Sign in for a lapsed remote
    +  // session, Connection settings for any other remote failure (local Retry /
    +  // Repair can't revive a dead remote — Repair is dropped there), Retry for a
    +  // local backend. Open logs is always appended.
    +  type RecoveryVariant = ComponentProps<typeof Button>['variant']
    +  interface RecoveryAction {
    +    key: string
    +    label: string
    +    onClick: () => void
    +    icon?: ReactNode
    +    variant?: RecoveryVariant
    +    busy?: Exclude<BusyAction, null>
    +  }
    +
    +  const settingsAction: RecoveryAction = {
    +    key: 'settings',
    +    label: copy.gatewaySettings,
    +    onClick: () => setView('connect'),
    +    icon: <SlidersHorizontal />
    +  }
    +
    +  const retryAction: RecoveryAction = {
    +    key: 'retry',
    +    label: copy.retry,
    +    onClick: () => void retry(),
    +    icon: <RefreshCw />,
    +    busy: 'retry'
    +  }
    +
    +  const localAction: RecoveryAction = {
    +    key: 'local',
    +    label: copy.useLocalGateway,
    +    onClick: () => void switchToLocalGateway(),
    +    variant: 'secondary',
    +    busy: 'local'
    +  }
    +
    +  let actions: RecoveryAction[]
    +  let hint: string
    +
    +  if (remoteReauth) {
    +    actions = [
    +      { key: 'signin', label: copy.signOutAndSignIn, onClick: () => void signInRemote(), icon: <LogIn />, busy: 'signin' },
    +      { ...settingsAction, variant: 'secondary' },
    +      localAction
    +    ]
    +    hint = copy.remoteSignInHint(label)
    +  } else if (remoteFailure) {
    +    actions = [settingsAction, { ...retryAction, variant: 'secondary' }, localAction]
    +    hint = copy.remoteFailureHint
    +  } else {
    +    // Local failure: Use-local is redundant with Retry (both re-target local), so
    +    // it's dropped here; keep it for remote failures where it's the fall-back.
    +    actions = [
    +      retryAction,
    +      { key: 'repair', label: copy.repairInstall, onClick: () => void repair(), icon: <Wrench />, variant: 'secondary', busy: 'repair' },
    +      { ...settingsAction, variant: 'ghost' }
    +    ]
    +    hint = copy.repairHint
    +  }
    +
    +  if (view === 'connect') {
    +    return (
    +      <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
    +        <div className="flex max-h-[86vh] w-full max-w-[46rem] flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
    +          {/* Subtle back affordance (projects/overlay idiom): muted → foreground
    +              on hover, no divider. */}
    +          <button
    +            className="flex w-full items-center gap-1.5 px-4 pt-4 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
    +            onClick={() => setView('recovery')}
    +            type="button"
    +          >
    +            <ChevronLeft className="size-3.5" />
    +            {copy.back}
    +          </button>
    +          <div className="min-h-0 flex-1 pt-4">
    +            <Suspense fallback={<Loader className="mx-auto my-16 size-6 text-(--ui-text-tertiary)" />}>
    +              <GatewaySettings embedded />
    +            </Suspense>
    +          </div>
    +        </div>
    +      </div>
    +    )
    +  }
    +
       return (
         <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
           <div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
    ```

    ```diff
    @@ -187,40 +299,25 @@ export function BootFailureOverlay() {
               </div>
             </div>
     
    -        <div className="grid gap-4 p-5">
    +        <div className="grid gap-4 p-5 pt-0">
               <div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive">
                 {boot.error}
               </div>
     
               <div className="grid gap-2">
                 <div className="flex flex-wrap gap-2">
    -              {remoteReauth ? (
    -                <Button disabled={Boolean(busy)} onClick={() => void signInRemote()}>
    -                  {busy === 'signin' ? <Loader2 className="animate-spin" /> : <LogIn />}
    -                  {label}
    -                </Button>
    -              ) : (
    -                <Button disabled={Boolean(busy)} onClick={() => void retry()}>
    -                  {busy === 'retry' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
    -                  {copy.retry}
    +              {actions.map(action => (
    +                <Button disabled={Boolean(busy)} key={action.key} onClick={action.onClick} variant={action.variant}>
    +                  {action.busy && busy === action.busy ? <Loader2 className="animate-spin" /> : action.icon}
    +                  {action.label}
                     </Button>
    -              )}
    -              {!remoteReauth ? (
    -                <Button disabled={Boolean(busy)} onClick={() => void repair()} variant="secondary">
    -                  {busy === 'repair' ? <Loader2 className="animate-spin" /> : <Wrench />}
    -                  {copy.repairInstall}
    -                </Button>
    -              ) : null}
    -              <Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary">
    -                {busy === 'local' ? <Loader2 className="animate-spin" /> : null}
    -                {copy.useLocalGateway}
    -              </Button>
    +              ))}
                   <Button onClick={openLogs} variant="ghost">
                     <FileText />
                     {copy.openLogs}
                   </Button>
                 </div>
    -            <p className="text-xs text-muted-foreground">{remoteReauth ? copy.remoteSignInHint : copy.repairHint}</p>
    +            <p className="text-xs text-muted-foreground">{hint}</p>
               </div>
     
               {logs.length > 0 ? (
    ```
  - `apps/desktop/src/components/boot-failure-reauth.test.ts`

    ```diff
    @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest'
     
     import type { DesktopConnectionConfig } from '@/global'
     
    -import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
    +import {
    +  deriveProviderShape,
    +  isRemoteConfig,
    +  isRemoteReauthError,
    +  isRemoteReauthFailure,
    +  signInLabel
    +} from './boot-failure-reauth'
     
     function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig {
       return {
    ```

    ```diff
    @@ -19,13 +25,32 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
       }
     }
     
    +describe('isRemoteConfig', () => {
    +  it('true for remote/cloud with a URL, regardless of auth mode or connection', () => {
    +    expect(isRemoteConfig(config({ remoteAuthMode: 'token', remoteOauthConnected: false }))).toBe(true)
    +    expect(isRemoteConfig(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(true)
    +  })
    +
    +  it('false for local, for a remote with no URL, and for nullish', () => {
    +    expect(isRemoteConfig(config({ mode: 'local' }))).toBe(false)
    +    expect(isRemoteConfig(config({ remoteUrl: '' }))).toBe(false)
    +    expect(isRemoteConfig(null)).toBe(false)
    +  })
    +})
    +
     describe('isRemoteReauthFailure', () => {
       it('true for a remote, gated, disconnected gateway with a URL', () => {
         expect(isRemoteReauthFailure(config())).toBe(true)
       })
     
    -  it('false when the oauth session is still connected', () => {
    -    expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }))).toBe(false)
    +  it('false when connected and the boot error is not auth-shaped', () => {
    +    expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }), 'Python exploded')).toBe(false)
    +  })
    +
    +  it('true when the indicator reads connected but the boot error is auth-shaped (expired session)', () => {
    +    expect(
    +      isRemoteReauthFailure(config({ remoteOauthConnected: true }), 'Your remote gateway session has expired.')
    +    ).toBe(true)
       })
     
       it('false for a local gateway', () => {
    ```

    ```diff
    @@ -56,6 +81,18 @@ describe('isRemoteReauthFailure', () => {
       })
     })
     
    +describe('isRemoteReauthError', () => {
    +  it('recognizes auth-shaped boot errors', () => {
    +    expect(isRemoteReauthError('Your remote gateway session has expired.')).toBe(true)
    +    expect(isRemoteReauthError('OAuth: please sign in')).toBe(true)
    +  })
    +
    +  it('ignores non-auth boot errors and nullish', () => {
    +    expect(isRemoteReauthError('Hermes background process exited during startup.')).toBe(false)
    +    expect(isRemoteReauthError(null)).toBe(false)
    +  })
    +})
    +
     describe('deriveProviderShape', () => {
       it('generic copy when there are no providers', () => {
         expect(deriveProviderShape([])).toEqual({ isPassword: [redacted:secret-assignment] providerLabel: 'your identity provider' })
    ```
  - `apps/desktop/src/components/boot-failure-reauth.ts`

    ```diff
    @@ -26,24 +26,40 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = {
       withProvider: provider => `Sign in with ${provider}`
     }
     
    -// A remote, gated (oauth-bucket), not-currently-connected gateway is a
    -// remote-reauth boot failure: the access cookie lapsed (e.g. the remote
    -// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
    -// fix it — only re-establishing the remote session can. A connected oauth
    -// session, or a token/local gateway, boots for some other reason the
    -// local-recovery buttons address, so those return false here. 'cloud' counts
    -// as remote here — it resolves to a remote oauth backend (cloud-auto-discovery
    -// Q6), so a lapsed cloud session is the same reauth failure.
    -export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean {
    -  if (!config) {
    -    return false
    -  }
    +// True when the app is pointed at a remote/cloud backend (either resolves to a
    +// remote URL). Any boot failure in this shape is fixable from Settings →
    +// Gateway (edit URL / token / sign in) — the local Retry/Repair buttons target
    +// the bundled backend and can't help. Drives the escape-hatch emphasis.
    +export function isRemoteConfig(config: DesktopConnectionConfig | null | undefined): boolean {
    +  return Boolean(config && (config.mode === 'remote' || config.mode === 'cloud') && config.remoteUrl)
    +}
    +
    +// True when a boot error is auth-shaped — the refresh token was rejected or the
    +// remote couldn't mint a websocket ticket. The Settings indicator can still read
    +// "connected" (a stale RT cookie exists), so the error text is part of the
    +// signal; without it a connected-but-expired session drops into the local-only
    +// recovery buttons for a problem only reauth can fix.
    +export function isRemoteReauthError(error: string | null | undefined): boolean {
    +  const text = String(error || '').toLowerCase()
    +
    +  return (
    +    text.includes('remote gateway session has expired') ||
    +    text.includes('gateway sign-in required') ||
    +    text.includes('needs oauth login') ||
    +    (text.includes('oauth') && (text.includes('not signed in') || text.includes('sign in')))
    +  )
    +}
     
    +// A remote, gated (oauth-bucket) gateway is a remote-reauth boot failure when the
    +// session isn't connected OR the boot error is auth-shaped (connected-but-expired
    +// — see isRemoteReauthError). Only re-establishing the remote session fixes it;
    +// the local Retry/Repair buttons can't. 'cloud' counts as remote (it resolves to
    +// a remote oauth backend), so a lapsed cloud session is the same failure.
    +export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined, error?: string | null): boolean {
       return (
    -    (config.mode === 'remote' || config.mode === 'cloud') &&
    -    config.remoteAuthMode === 'oauth' &&
    -    !config.remoteOauthConnected &&
    -    Boolean(config.remoteUrl)
    +    isRemoteConfig(config) &&
    +    config!.remoteAuthMode === 'oauth' &&
    +    (!config!.remoteOauthConnected || isRemoteReauthError(error))
       )
     }
     
    ```
  - `apps/desktop/src/components/gateway-connecting-overlay.test.tsx`

    ```diff
    @@ -177,7 +177,7 @@ describe('connecting overlay vs recovery surface', () => {
     
         // Escape hatch is now reachable; the connecting overlay bows out.
         expect(isRecoveryShown()).toBe(true)
    -    expect(screen.getByText(/use local gateway/i)).toBeTruthy()
    +    expect(screen.getByRole('button', { name: /gateway settings/i })).toBeTruthy()
         expect(isConnectingShown()).toBe(false)
       })
     })
    ```
  - `apps/desktop/src/i18n/en.ts`

    ```diff
    @@ -90,9 +90,14 @@ export const en: Translations = {
           retry: 'Retry',
           repairInstall: 'Repair install',
           useLocalGateway: 'Use local gateway',
    +      gatewaySettings: 'Gateway settings',
    +      back: 'Back',
           openLogs: 'Open logs',
           repairHint: 'Repair re-runs the installer and can take a few minutes on a fresh machine.',
    -      remoteSignInHint: 'Opens the gateway login window. Use local gateway to switch to the bundled backend instead.',
    +      remoteSignInHint: signInLabel =>
    +        `Signs out of the saved remote browser session, then opens ${signInLabel}. Use local gateway to switch to the bundled backend instead.`,
    +      signOutAndSignIn: 'Sign out & sign in',
    +      remoteFailureHint: 'Check the gateway URL and sign-in under Gateway settings, or switch to the local gateway.',
           hideRecentLogs: 'Hide recent logs',
           showRecentLogs: 'Show recent logs',
           signedInTitle: 'Signed in',
    ```
  - `apps/desktop/src/i18n/ja.ts`

    ```diff
    @@ -90,10 +90,14 @@ export const ja = defineLocale({
           retry: '再試行',
           repairInstall: 'インストールを修復',
           useLocalGateway: 'ローカルゲートウェイを使用',
    +      gatewaySettings: 'ゲートウェイ設定',
    +      back: '戻る',
           openLogs: 'ログを開く',
           repairHint: '修復はインストーラーを再実行します。新しいマシンでは数分かかる場合があります。',
    -      remoteSignInHint:
    -        'ゲートウェイのログインウィンドウを開きます。代わりにバンドルされたバックエンドに切り替えるには「ローカルゲートウェイを使用」を選択してください。',
    +      remoteSignInHint: signInLabel =>
    +        `保存済みのリモートブラウザセッションからサインアウトし、${signInLabel}を開きます。代わりにバンドルされたバックエンドに切り替えるには「ローカルゲートウェイを使用」を選択してください。`,
    +      signOutAndSignIn: 'サインアウトして再サインイン',
    +      remoteFailureHint: '「ゲートウェイ設定」でゲートウェイの URL とサインインを確認するか、ローカルゲートウェイに切り替えてください。',
           hideRecentLogs: '最近のログを非表示',
           showRecentLogs: '最近のログを表示',
           signedInTitle: 'サインインしました',
    ```
  - `apps/desktop/src/i18n/types.ts`

    ```diff
    @@ -133,9 +133,13 @@ export interface Translations {
           retry: string
           repairInstall: string
           useLocalGateway: string
    +      gatewaySettings: string
    +      back: string
           openLogs: string
           repairHint: string
    -      remoteSignInHint: string
    +      remoteSignInHint: (signInLabel: string) => string
    +      signOutAndSignIn: string
    +      remoteFailureHint: string
           hideRecentLogs: string
           showRecentLogs: string
           signedInTitle: string
    ```
  - `apps/desktop/src/i18n/zh-hant.ts`

    ```diff
    @@ -88,9 +88,14 @@ export const zhHant = defineLocale({
           retry: '重試',
           repairInstall: '修復安裝',
           useLocalGateway: '使用本機閘道',
    +      gatewaySettings: '閘道設定',
    +      back: '返回',
           openLogs: '開啟記錄',
           repairHint: '修復會重新執行安裝程式，在新機器上可能需要幾分鐘。',
    -      remoteSignInHint: '開啟閘道登入視窗。使用本機閘道可切換至內建後端。',
    +      remoteSignInHint: signInLabel =>
    +        `先登出已儲存的遠端瀏覽器工作階段，然後開啟${signInLabel}。使用本機閘道可切換至內建後端。`,
    +      signOutAndSignIn: '登出並重新登入',
    +      remoteFailureHint: '在「閘道設定」中檢查閘道 URL 與登入，或切換至本機閘道。',
           hideRecentLogs: '隱藏最近記錄',
           showRecentLogs: '顯示最近記錄',
           signedInTitle: '已登入',
    ```
  - `apps/desktop/src/i18n/zh.ts`

    ```diff
    @@ -88,9 +88,14 @@ export const zh: Translations = {
           retry: '重试',
           repairInstall: '修复安装',
           useLocalGateway: '使用本地网关',
    +      gatewaySettings: '网关设置',
    +      back: '返回',
           openLogs: '打开日志',
           repairHint: '修复会重新运行安装器，在新机器上可能需要几分钟。',
    -      remoteSignInHint: '打开网关登录窗口。也可以使用本地网关切换到随应用提供的后端。',
    +      remoteSignInHint: signInLabel =>
    +        `先退出已保存的远程浏览器会话，然后打开${signInLabel}。也可以使用本地网关切换到随应用提供的后端。`,
    +      signOutAndSignIn: '退出并重新登录',
    +      remoteFailureHint: '在“网关设置”中检查网关 URL 和登录，或切换到本地网关。',
           hideRecentLogs: '隐藏最近日志',
           showRecentLogs: '显示最近日志',
           signedInTitle: '已登录',
    ```
- **#122035: fix(desktop): let the boot-failure overlay be dismissed**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/src/components/boot-failure-overlay.test.tsx`, `apps/desktop/src/components/boot-failure-overlay.tsx`
- **#62559: fix(desktop): make boot failure overlay dismissible**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#122035`

## Fix status

- Merged to main in PR #63030 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-25`.
- Subsystem: `apps/desktop`
