diff --git a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md
index 3e1fe164..d22a10dd 100644
--- a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md
+++ b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md
@@ -515,7 +515,9 @@ SSH/远程项目操作;已有 Host、项目和凭据保持不变。若当前
[SSH Host 远程环境准备与直连下载设计](../../architecture/remote-host-environment-provisioning-design.md)。
进入或切回“主机与远程执行”页面只读取本地 Host 列表,不逐台建立 SSH 连接。版本和瞬时
-下载能力仅在用户点击某台 Host 的“刷新版本”时探测。该动作不是项目连接前置条件;用户
+下载能力仅在用户点击某台 Host 的“刷新版本”时探测,成功结果在当前 Renderer 进程内按
+Host 持久化缓存,切换页面或重启应用后返回直接显示;Host 编辑、删除或开始更新远程环境后
+不复用旧结果。该动作不是项目连接前置条件;用户
切换已有远程项目只更新 Renderer 的本地项目选择,不建立 Host/Agent 连接。
新建远程项目弹窗同样只读取本地 Host 验证记录,不扫描所有 Host;浏览目录或保存时才连接
所选 Host。版本号相同时仍允许用户显式重新安装,以修复 registry、签名或 identity 异常,
diff --git a/src/renderer/src/SshHostsSettingsSection.test.tsx b/src/renderer/src/SshHostsSettingsSection.test.tsx
index fb0b328f..5ee12ebb 100644
--- a/src/renderer/src/SshHostsSettingsSection.test.tsx
+++ b/src/renderer/src/SshHostsSettingsSection.test.tsx
@@ -749,6 +749,23 @@ describe('SshHostsSettingsSection', () => {
).not.toBeNull()
})
+ it('keeps the last successful version check when the settings page remounts', async () => {
+ getSnapshot.mockResolvedValue(verifiedSnapshot)
+ const first = render()
+
+ await refreshHostEnvironment()
+ expect(getRemoteEnvironment).toHaveBeenCalledOnce()
+ first.unmount()
+
+ render()
+ const host = await screen.findByRole('region', {
+ name: 'Build host'
+ })
+
+ expect(within(host).getByText('GoodBuddy Agent')).toBeInTheDocument()
+ expect(getRemoteEnvironment).toHaveBeenCalledOnce()
+ })
+
it('does not contact any verified Host when the section opens', async () => {
getSnapshot.mockResolvedValue({
hosts: [
diff --git a/src/renderer/src/SshHostsSettingsSection.tsx b/src/renderer/src/SshHostsSettingsSection.tsx
index 6ac490d0..c1660a43 100644
--- a/src/renderer/src/SshHostsSettingsSection.tsx
+++ b/src/renderer/src/SshHostsSettingsSection.tsx
@@ -31,6 +31,11 @@ import type { AppNotificationInput } from './notifications'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import { displayErrorMessage } from './error-message'
import { SshHostDialog } from './SshHostDialog'
+import {
+ getCachedSshHostRemoteEnvironments,
+ removeCachedSshHostRemoteEnvironment,
+ setCachedSshHostRemoteEnvironment
+} from './ssh-host-remote-environment-cache'
import {
DestructiveConfirmActions,
EmptyState,
@@ -112,7 +117,16 @@ export function SshHostsSettingsSection({
>({})
const [remoteEnvironments, setRemoteEnvironments] = useState<
Record
- >({})
+ >(() =>
+ Object.fromEntries(
+ Object.entries(getCachedSshHostRemoteEnvironments()).map(
+ ([hostId, value]) => [
+ hostId,
+ { loading: false, value }
+ ]
+ )
+ )
+ )
const [
remoteEnvironmentPreparationMethods,
setRemoteEnvironmentPreparationMethods
@@ -252,6 +266,7 @@ export function SshHostsSettingsSection({
...current,
[hostId]: { loading: false, value }
}))
+ setCachedSshHostRemoteEnvironment(value)
} catch (reason) {
if (
!mounted.current ||
@@ -285,6 +300,7 @@ export function SshHostsSettingsSection({
) {
return
}
+ removeCachedSshHostRemoteEnvironment(host.id)
const requestId = remoteEnvironmentUpdateRequestId.current + 1
remoteEnvironmentUpdateRequestId.current = requestId
const activeUpdate: ActiveRemoteEnvironmentUpdate = {
@@ -474,6 +490,7 @@ export function SshHostsSettingsSection({
const handleSaved = (result: SshHostValidationResult): void => {
const edited = hosts.some((host) => host.id === result.host.id)
+ removeCachedSshHostRemoteEnvironment(result.host.id)
setHosts((current) => upsertHost(current, result.host))
setTestResults((current) => ({
...current,
@@ -500,6 +517,7 @@ export function SshHostsSettingsSection({
result.deletedProjects.map((project) => project.id)
)
setConfirmingDeleteId(undefined)
+ removeCachedSshHostRemoteEnvironment(host.id)
setHosts((current) =>
current.filter((candidate) => candidate.id !== host.id)
)
diff --git a/src/renderer/src/ssh-host-remote-environment-cache.test.ts b/src/renderer/src/ssh-host-remote-environment-cache.test.ts
new file mode 100644
index 00000000..64846bfe
--- /dev/null
+++ b/src/renderer/src/ssh-host-remote-environment-cache.test.ts
@@ -0,0 +1,73 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import type { SshHostRemoteEnvironment } from '../../shared/ssh-host-contracts'
+import { SshHostRemoteEnvironmentCache } from './ssh-host-remote-environment-cache'
+
+const hostId = '00000000-0000-4000-8000-000000000104'
+const version = {
+ version: '1.2.3',
+ architecture: 'x64' as const
+}
+const remoteEnvironment: SshHostRemoteEnvironment = {
+ hostId,
+ checkedAt: '2026-08-31T00:00:00.000Z',
+ architecture: 'x64',
+ agent: {
+ state: 'current',
+ expected: version,
+ installed: version
+ },
+ runtimes: [
+ {
+ runtimeId: 'opencode',
+ provider: 'opencode',
+ state: 'current',
+ expected: version,
+ installed: version
+ }
+ ],
+ remoteDownload: {
+ available: true,
+ source: 'mirror',
+ packageSize: 1024
+ }
+}
+
+describe('SshHostRemoteEnvironmentCache', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it('restores successful checks in a new cache instance', () => {
+ const first = new SshHostRemoteEnvironmentCache(localStorage)
+ first.set(remoteEnvironment)
+
+ const restarted = new SshHostRemoteEnvironmentCache(localStorage)
+ expect(restarted.getAll()).toEqual({
+ [hostId]: remoteEnvironment
+ })
+ })
+
+ it('persists Host invalidation across cache instances', () => {
+ const first = new SshHostRemoteEnvironmentCache(localStorage)
+ first.set(remoteEnvironment)
+ first.remove(hostId)
+
+ const restarted = new SshHostRemoteEnvironmentCache(localStorage)
+ expect(restarted.getAll()).toEqual({})
+ })
+
+ it('ignores malformed persisted values', () => {
+ localStorage.setItem(
+ 'goodbuddy.ssh-host-remote-environments.v1',
+ JSON.stringify([
+ remoteEnvironment,
+ { ...remoteEnvironment, checkedAt: 'not-a-date' }
+ ])
+ )
+
+ const restarted = new SshHostRemoteEnvironmentCache(localStorage)
+ expect(restarted.getAll()).toEqual({
+ [hostId]: remoteEnvironment
+ })
+ })
+})
diff --git a/src/renderer/src/ssh-host-remote-environment-cache.ts b/src/renderer/src/ssh-host-remote-environment-cache.ts
new file mode 100644
index 00000000..24640514
--- /dev/null
+++ b/src/renderer/src/ssh-host-remote-environment-cache.ts
@@ -0,0 +1,119 @@
+import {
+ SSH_HOST_LIMITS,
+ sshHostRemoteEnvironmentSchema,
+ type SshHostRemoteEnvironment
+} from '../../shared/ssh-host-contracts'
+
+type CacheStorage = Pick<
+ Storage,
+ 'getItem' | 'removeItem' | 'setItem'
+>
+
+const storageKey = 'goodbuddy.ssh-host-remote-environments.v1'
+const maximumPersistedPayloadLength = 1_000_000
+
+function getLocalStorage(): CacheStorage | undefined {
+ try {
+ return typeof window === 'undefined' ? undefined : window.localStorage
+ } catch {
+ return undefined
+ }
+}
+
+export class SshHostRemoteEnvironmentCache {
+ private readonly remoteEnvironments = new Map<
+ string,
+ SshHostRemoteEnvironment
+ >()
+
+ constructor(
+ private readonly storage: CacheStorage | undefined = getLocalStorage()
+ ) {
+ this.load()
+ }
+
+ getAll(): Readonly> {
+ return Object.fromEntries(this.remoteEnvironments)
+ }
+
+ set(environment: SshHostRemoteEnvironment): void {
+ this.remoteEnvironments.set(environment.hostId, environment)
+ this.persist()
+ }
+
+ remove(hostId: string): void {
+ if (this.remoteEnvironments.delete(hostId)) {
+ this.persist()
+ }
+ }
+
+ clear(): void {
+ this.remoteEnvironments.clear()
+ try {
+ this.storage?.removeItem(storageKey)
+ } catch {
+ // The in-memory cache remains usable when persistence is unavailable.
+ }
+ }
+
+ private load(): void {
+ try {
+ const raw = this.storage?.getItem(storageKey)
+ if (!raw || raw.length > maximumPersistedPayloadLength) {
+ return
+ }
+ const parsed: unknown = JSON.parse(raw)
+ if (!Array.isArray(parsed)) {
+ return
+ }
+ for (const candidate of parsed.slice(
+ 0,
+ SSH_HOST_LIMITS.maximumHosts
+ )) {
+ const environment =
+ sshHostRemoteEnvironmentSchema.safeParse(candidate)
+ if (environment.success) {
+ this.remoteEnvironments.set(
+ environment.data.hostId,
+ environment.data
+ )
+ }
+ }
+ } catch {
+ // Corrupt or inaccessible storage behaves like an empty cache.
+ }
+ }
+
+ private persist(): void {
+ try {
+ this.storage?.setItem(
+ storageKey,
+ JSON.stringify([...this.remoteEnvironments.values()])
+ )
+ } catch {
+ // Version checks still remain cached for the current process.
+ }
+ }
+}
+
+const cache = new SshHostRemoteEnvironmentCache()
+
+export function getCachedSshHostRemoteEnvironments(): Readonly<
+ Record
+> {
+ return cache.getAll()
+}
+
+export function setCachedSshHostRemoteEnvironment(
+ environment: SshHostRemoteEnvironment
+): void {
+ cache.set(environment)
+}
+
+export function removeCachedSshHostRemoteEnvironment(hostId: string): void {
+ cache.remove(hostId)
+}
+
+export function clearSshHostRemoteEnvironmentCache(): void {
+ cache.clear()
+}
diff --git a/src/renderer/src/test-setup.ts b/src/renderer/src/test-setup.ts
index 013ff0b2..0d735671 100644
--- a/src/renderer/src/test-setup.ts
+++ b/src/renderer/src/test-setup.ts
@@ -1,12 +1,14 @@
import '@testing-library/jest-dom/vitest'
import { beforeEach, vi } from 'vitest'
import i18n from './i18n'
+import { clearSshHostRemoteEnvironmentCache } from './ssh-host-remote-environment-cache'
if (typeof Element !== 'undefined') {
Element.prototype.scrollTo = vi.fn()
}
beforeEach(async () => {
+ clearSshHostRemoteEnvironmentCache()
if (typeof localStorage === 'undefined' || typeof document === 'undefined') {
return
}