diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx
index 0014e4e71f12..59f8ba0cbae3 100644
--- a/apps/desktop/src/app/command-palette/index.tsx
+++ b/apps/desktop/src/app/command-palette/index.tsx
@@ -49,6 +49,7 @@ import {
} from '@/lib/icons'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
+import { resolveVersionStatus } from '@/lib/version-status'
import { $repoWorktrees } from '@/store/coding-status'
import {
$commandPaletteOpen,
@@ -59,8 +60,16 @@ import {
import { $bindings } from '@/store/keybinds'
import { openPetGenerate } from '@/store/pet-generate'
import { requestStartWorkSession } from '@/store/projects'
+import { $connection } from '@/store/session'
import { runGatewayRestart } from '@/store/system-actions'
-import { applyBackendUpdate } from '@/store/updates'
+import {
+ $backendUpdateApply,
+ $backendUpdateStatus,
+ $desktopVersion,
+ $updateApply,
+ $updateStatus,
+ requestActiveUpdate
+} from '@/store/updates'
import { canOpenNewWindow, openNewWindow } from '@/store/windows'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
@@ -93,6 +102,8 @@ interface PaletteItem {
action?: string
/** Renders a trailing check: this row IS the current setting (theme, mode). */
active?: boolean
+ /** Muted text beside the label — state the row acts on (a version, a count). */
+ detail?: string
icon: IconComponent
id: string
/** Keep the palette open after running (live-preview pickers like theme/mode). */
@@ -242,6 +253,7 @@ const PaletteRow = memo(function PaletteRow({
>
{item.label}
+ {item.detail && {item.detail}}
{combo && }
{item.to && }
{item.active && }
@@ -345,6 +357,35 @@ export function CommandPalette() {
const [search, setSearch] = useState('')
const [page, setPage] = useState(null)
+ // The Update row names the same install the statusbar names — same target
+ // selection, same resolver. Reduced to the label string: an in-flight apply
+ // rewrites these stores on every progress line, and only a changed string
+ // should rebuild the palette's groups.
+ const connection = useStore($connection)
+ const desktopVersion = useStore($desktopVersion)
+ const clientStatus = useStore($updateStatus)
+ const clientApply = useStore($updateApply)
+ const backendStatus = useStore($backendUpdateStatus)
+ const backendApply = useStore($backendUpdateApply)
+
+ const updateVersionLabel = useMemo(() => {
+ const backend = connection?.mode === 'remote'
+ const apply = backend ? backendApply : clientApply
+ const status = backend ? backendStatus : clientStatus
+
+ return resolveVersionStatus({
+ applying: apply.applying || apply.stage === 'restart',
+ behind: status?.behind ?? 0,
+ copy: t.shell.statusbar,
+ remote: backend,
+ restarting: apply.stage === 'restart',
+ sha: status?.currentSha?.slice(0, 7) ?? null,
+ target: backend ? 'backend' : 'client',
+ updateAvailable: status?.updateAvailable,
+ version: backend ? status?.currentVersion : desktopVersion?.appVersion
+ }).label
+ }, [backendApply, backendStatus, clientApply, clientStatus, connection?.mode, desktopVersion?.appVersion, t])
+
// cmdk's onSelect doesn't forward the triggering event — keep the last
// click/keydown modifiers so session rows can honour ⌘-Enter / ⌘-click.
const lastSelectMods = useRef<{ ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }>({
@@ -582,11 +623,12 @@ export function CommandPalette() {
run: () => void runGatewayRestart()
},
{
+ detail: updateVersionLabel,
icon: Download,
id: 'cc-update-hermes',
keywords: ['update', 'upgrade', 'hermes', 'version', 'system', 'restart'],
label: cc.updateHermes,
- run: () => void applyBackendUpdate()
+ run: () => requestActiveUpdate()
}
]
},
@@ -663,7 +705,7 @@ export function CommandPalette() {
]
: [])
]
- }, [contributedItems, go, settingsSectionLabel, t, worktrees])
+ }, [contributedItems, go, settingsSectionLabel, t, updateVersionLabel, worktrees])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the
diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
index fb2aac6d2daa..0aa3c69fe3b6 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -14,6 +14,7 @@ import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
+import { resolveVersionStatus } from '@/lib/version-status'
import { copyFilePath, revealFile } from '@/store/file-actions'
import { revealFileInTree } from '@/store/layout'
import { $activeGatewayProfile } from '@/store/profile'
@@ -218,42 +219,33 @@ export function useStatusbarItems({
: 'text-destructive hover:text-destructive'
const clientVersionItem = useMemo(() => {
- const appVersion = desktopVersion?.appVersion
- const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
- const behind = updateStatus?.behind ?? 0
const applying = updateApply.applying || updateApply.stage === 'restart'
- const remote = connection?.mode === 'remote'
- const version = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
- const base = remote ? copy.clientLabel(appVersion ?? sha ?? copy.unknown) : version
- const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
-
- const label = applying
- ? `${base} · ${updateApply.stage === 'restart' ? copy.restart : copy.update}`
- : `${base}${behindHint}`
-
- const tooltip = [
- applying ? updateApply.message || copy.updateInProgress : null,
- !applying && behind > 0 && copy.commitsBehind(behind, updateStatus?.branch ?? '...'),
- appVersion && copy.desktopVersion(appVersion),
- sha && copy.commit(sha),
- updateStatus?.branch && copy.branch(updateStatus.branch)
- ]
- .filter(Boolean)
- .join(' · ')
+ const status = resolveVersionStatus({
+ applying,
+ applyMessage: updateApply.message,
+ behind: updateStatus?.behind ?? 0,
+ branch: updateStatus?.branch,
+ copy,
+ remote: connection?.mode === 'remote',
+ restarting: updateApply.stage === 'restart',
+ sha: updateStatus?.currentSha?.slice(0, 7) ?? null,
+ target: 'client',
+ version: desktopVersion?.appVersion
+ })
return {
- className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
- detail: appVersion && sha && !applying && !remote ? sha : undefined,
- hidden: !appVersion && !sha,
+ className: status.hasUpdate ? 'text-primary hover:text-primary' : undefined,
+ detail: status.detail,
+ hidden: status.unknown,
icon: applying ? : ,
id: 'version-client',
- label,
+ label: status.label,
// Update state is not a preference: hiding it is how a user misses that
// their client is behind. Listed in the menu, but locked on.
lockedVisible: true,
onSelect: () => openUpdateOverlayFor('client'),
- title: tooltip || undefined,
+ title: status.tooltip,
toggleLabel: copy.toggleVersion,
variant: 'action'
}
@@ -274,38 +266,29 @@ export function useStatusbarItems({
return null
}
- const backendVersion = statusSnapshot?.version
- const behind = backendUpdateStatus?.behind ?? 0
- const updateAvailable = backendUpdateStatus?.updateAvailable || behind > 0
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
- const base = copy.backendLabel(backendVersion ?? copy.unknown)
-
- const behindHint =
- !applying && behind > 0 ? ` (+${behind})` : !applying && updateAvailable ? ` (${copy.update})` : ''
-
- const label = applying
- ? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}`
- : `${base}${behindHint}`
-
- const tooltip = [
- applying ? backendUpdateApply.message || copy.updateInProgress : null,
- !applying && behind > 0 && copy.commitsBehind(behind, 'main'),
- !applying && behind <= 0 && updateAvailable && copy.update,
- backendVersion && copy.backendVersion(backendVersion)
- ]
- .filter(Boolean)
- .join(' · ')
+ const status = resolveVersionStatus({
+ applying,
+ applyMessage: backendUpdateApply.message,
+ behind: backendUpdateStatus?.behind ?? 0,
+ copy,
+ remote: true,
+ restarting: backendUpdateApply.stage === 'restart',
+ target: 'backend',
+ updateAvailable: backendUpdateStatus?.updateAvailable,
+ version: statusSnapshot?.version
+ })
return {
- className: !applying && updateAvailable ? 'text-primary hover:text-primary' : undefined,
- hidden: !backendVersion,
+ className: status.hasUpdate ? 'text-primary hover:text-primary' : undefined,
+ hidden: status.unknown,
icon: applying ? : ,
id: 'version-backend',
- label,
+ label: status.label,
lockedVisible: true,
onSelect: () => openUpdateOverlayFor('backend'),
- title: tooltip || undefined,
+ title: status.tooltip,
toggleLabel: copy.toggleBackendVersion,
variant: 'action'
}
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index 336f95b54ae2..a53965a63b59 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -373,6 +373,8 @@ export interface DesktopUpdateStatus {
error?: string
behind?: number
currentSha?: string
+ /** Backend only: the version string the backend reports for itself. */
+ currentVersion?: string
targetSha?: string
commits?: DesktopUpdateCommit[]
dirty?: boolean
diff --git a/apps/desktop/src/lib/version-status.test.ts b/apps/desktop/src/lib/version-status.test.ts
new file mode 100644
index 000000000000..54a4bb2d5760
--- /dev/null
+++ b/apps/desktop/src/lib/version-status.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'vitest'
+
+import { en } from '@/i18n/en'
+
+import { resolveVersionStatus } from './version-status'
+
+const copy = en.shell.statusbar
+
+const client = (over: Partial[0]> = {}) =>
+ resolveVersionStatus({ applying: false, copy, remote: false, restarting: false, target: 'client', ...over })
+
+const backend = (over: Partial[0]> = {}) =>
+ resolveVersionStatus({ applying: false, copy, remote: true, restarting: false, target: 'backend', ...over })
+
+describe('resolveVersionStatus', () => {
+ it('labels a current local client with its version and sha detail', () => {
+ const status = client({ sha: 'abc1234', version: '0.4.2' })
+
+ expect(status.label).toBe('v0.4.2')
+ expect(status.detail).toBe('abc1234')
+ expect(status.hasUpdate).toBe(false)
+ expect(status.unknown).toBe(false)
+ })
+
+ it('appends the commit diff when the client is behind', () => {
+ const status = client({ behind: 12, branch: 'main', version: '0.4.2' })
+
+ expect(status.label).toBe('v0.4.2 (+12)')
+ expect(status.hasUpdate).toBe(true)
+ expect(status.tooltip).toContain('12 commits behind main')
+ })
+
+ it('names the client as one of two versions in remote mode', () => {
+ expect(client({ remote: true, version: '0.4.2' }).label).toBe('client v0.4.2')
+ })
+
+ it('falls back to the sha, then to unknown, when there is no version', () => {
+ expect(client({ sha: 'abc1234' }).label).toBe('abc1234')
+ expect(client({ sha: 'abc1234' }).unknown).toBe(false)
+ expect(client().label).toBe(copy.unknown)
+ expect(client().unknown).toBe(true)
+ })
+
+ it('drops the diff and the sha detail while an apply is in flight', () => {
+ const applying = client({ applying: true, behind: 3, sha: 'abc1234', version: '0.4.2' })
+
+ expect(applying.label).toBe('v0.4.2 · update')
+ expect(applying.detail).toBeUndefined()
+ expect(applying.hasUpdate).toBe(false)
+
+ expect(client({ applying: true, restarting: true, version: '0.4.2' }).label).toBe('v0.4.2 · restart')
+ })
+
+ it('leads the tooltip with the apply message while applying', () => {
+ expect(client({ applyMessage: 'Pulling…', applying: true, version: '0.4.2' }).tooltip).toBe(
+ 'Pulling… · Hermes Desktop v0.4.2'
+ )
+ expect(client({ applying: true, version: '0.4.2' }).tooltip).toBe(
+ `${copy.updateInProgress} · Hermes Desktop v0.4.2`
+ )
+ })
+
+ it('labels the backend target distinctly and never claims a client sha', () => {
+ const status = backend({ sha: 'abc1234', version: '0.4.2' })
+
+ expect(status.label).toBe('backend v0.4.2')
+ expect(status.detail).toBeUndefined()
+ expect(status.tooltip).toBe('Backend v0.4.2')
+ })
+
+ it('falls back to (update) for a backend that cannot count commits', () => {
+ const status = backend({ updateAvailable: true, version: '0.4.2' })
+
+ expect(status.label).toBe('backend v0.4.2 (update)')
+ expect(status.hasUpdate).toBe(true)
+ })
+
+ it('prefers the exact commit diff over the generic (update) hint', () => {
+ expect(backend({ behind: 4, updateAvailable: true, version: '0.4.2' }).label).toBe('backend v0.4.2 (+4)')
+ })
+
+ it('hides a backend row that has no version at all', () => {
+ expect(backend().unknown).toBe(true)
+ })
+})
diff --git a/apps/desktop/src/lib/version-status.ts b/apps/desktop/src/lib/version-status.ts
new file mode 100644
index 000000000000..030464e8ecac
--- /dev/null
+++ b/apps/desktop/src/lib/version-status.ts
@@ -0,0 +1,106 @@
+/**
+ * Pure derivation of how the app names an update target: the label
+ * (`v0.4.2`, `backend v0.4.2 (+12)`, `v0.4.2 · update`), its tooltip, and
+ * whether an update is waiting.
+ *
+ * The statusbar and the command palette both name the same two targets, so the
+ * wording lives here once — a palette row and its statusbar item can't drift
+ * into describing the same install differently.
+ */
+
+import type { UpdateTarget } from '@/lib/update-copy'
+
+export interface VersionStatusCopy {
+ backendLabel: (version: string) => string
+ backendVersion: (version: string) => string
+ branch: (branch: string) => string
+ clientLabel: (version: string) => string
+ commit: (sha: string) => string
+ commitsBehind: (count: number, branch: string) => string
+ desktopVersion: (version: string) => string
+ restart: string
+ unknown: string
+ update: string
+ updateInProgress: string
+}
+
+export interface VersionStatusInput {
+ /** True while an apply is in flight (including the restart hand-off). */
+ applying: boolean
+ /** Latest line from the apply stream — leads the tooltip while applying. */
+ applyMessage?: string
+ behind?: number
+ branch?: string
+ copy: VersionStatusCopy
+ /** Remote mode: the client is one of two versions on screen, so it says so. */
+ remote: boolean
+ /** The apply reached the restart stage — labels `restart`, not `update`. */
+ restarting: boolean
+ /** Client only: short commit sha of the running build. */
+ sha?: null | string
+ target: UpdateTarget
+ /** Backend only: an update the commit count can't express (pip installs). */
+ updateAvailable?: boolean
+ version?: null | string
+}
+
+export interface VersionStatusResult {
+ /** Secondary text beside the label — the commit sha, when it adds anything. */
+ detail?: string
+ /** An update is waiting: callers tint the row with it. */
+ hasUpdate: boolean
+ label: string
+ tooltip?: string
+ /** Nothing identifies this target yet — callers hide the row. */
+ unknown: boolean
+}
+
+export function resolveVersionStatus({
+ applyMessage,
+ applying,
+ behind = 0,
+ branch,
+ copy,
+ remote,
+ restarting,
+ sha = null,
+ target,
+ updateAvailable,
+ version = null
+}: VersionStatusInput): VersionStatusResult {
+ const client = target === 'client'
+ const busy = applying || restarting
+ const available = behind > 0 || (!client && !!updateAvailable)
+
+ // A client with no version still identifies itself by sha; a backend can't.
+ const named = version ?? (client ? sha : null) ?? copy.unknown
+
+ const base = !client
+ ? copy.backendLabel(named)
+ : remote
+ ? copy.clientLabel(named)
+ : (version && `v${version}`) || named
+
+ // Commits behind is the precise diff; `(update)` is the fallback for a
+ // backend that knows it's stale but can't count (pip, non-git checkout).
+ const hint = busy ? '' : behind > 0 ? ` (+${behind})` : available ? ` (${copy.update})` : ''
+
+ const tooltip = [
+ busy && (applyMessage || copy.updateInProgress),
+ !busy && behind > 0 && copy.commitsBehind(behind, (client ? branch : 'main') || '...'),
+ !busy && behind <= 0 && available && copy.update,
+ version && (client ? copy.desktopVersion(version) : copy.backendVersion(version)),
+ client && sha && copy.commit(sha),
+ client && branch && copy.branch(branch)
+ ]
+ .filter(Boolean)
+ .join(' · ')
+
+ return {
+ detail: client && version && sha && !busy && !remote ? sha : undefined,
+ hasUpdate: !busy && available,
+ label: busy ? `${base} · ${restarting ? copy.restart : copy.update}` : `${base}${hint}`,
+ tooltip: tooltip || undefined,
+ unknown: !version && !(client && sha)
+ }
+}
diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts
index 7b19ba15f459..9dd1cbed6a38 100644
--- a/apps/desktop/src/store/updates.test.ts
+++ b/apps/desktop/src/store/updates.test.ts
@@ -51,6 +51,8 @@ const {
applyUpdates,
$updateApply,
$updateOverlayOpen,
+ $updateOverlayTarget,
+ requestActiveUpdate,
resetUpdateApplyState,
startUpdatePoller,
stopUpdatePoller,
@@ -69,6 +71,18 @@ const status = (over: Partial = {}): DesktopUpdateStatus =>
const lastToast = () => notifySpy.mock.calls.at(-1)?.[0] as { onDismiss: () => void }
+const setRemote = (on: boolean) =>
+ setConnection({
+ baseUrl: 'http://box:9119',
+ isFullscreen: false,
+ mode: on ? 'remote' : 'local',
+ nativeOverlayWidth: 0,
+ token: 't',
+ wsUrl: 'ws://box:9119',
+ logs: [],
+ windowButtonPosition: null
+ })
+
describe('maybeNotifyUpdateAvailable', () => {
beforeEach(() => {
storage.clear()
@@ -175,18 +189,6 @@ describe('checkBackendUpdates', () => {
vi.useRealTimers()
})
- const setRemote = (on: boolean) =>
- setConnection({
- baseUrl: 'http://box:9119',
- isFullscreen: false,
- mode: on ? 'remote' : 'local',
- nativeOverlayWidth: 0,
- token: 't',
- wsUrl: 'ws://box:9119',
- logs: [],
- windowButtonPosition: null
- })
-
it('maps the backend /update/check onto the backend status, including commits', async () => {
setRemote(true)
checkHermesUpdateSpy.mockResolvedValue({
@@ -254,6 +256,96 @@ describe('checkBackendUpdates', () => {
})
})
+// The ⌘K "Update Hermes" row. It used to call applyBackendUpdate() flat, which
+// in local mode aimed at the backend checkout instead of the client and, with
+// no overlay open, showed nothing at all.
+describe('requestActiveUpdate', () => {
+ const applyClientMock = vi.fn()
+ const checkClientMock = vi.fn()
+
+ beforeEach(() => {
+ storage.clear()
+ notifySpy.mockClear()
+ dismissSpy.mockClear()
+ applyClientMock.mockReset().mockResolvedValue({ ok: true, handedOff: true })
+ checkClientMock.mockReset().mockResolvedValue(status({ behind: 0 }))
+ updateHermesSpy.mockReset().mockResolvedValue({ ok: true, name: 'update' })
+ checkHermesUpdateSpy.mockReset().mockResolvedValue({
+ install_method: 'git',
+ current_version: '0.4.2',
+ behind: 0,
+ update_available: false,
+ can_apply: true,
+ update_command: null,
+ message: null
+ })
+ getActionStatusSpy.mockReset().mockResolvedValue({ lines: [], running: false, exit_code: 0 })
+ resetUpdateApplyState()
+ $updateStatus.set(null)
+ $backendUpdateStatus.set(null)
+ $updateOverlayOpen.set(false)
+ ;(globalThis as unknown as { window: unknown }).window = {
+ hermesDesktop: { updates: { apply: applyClientMock, check: checkClientMock } }
+ }
+ vi.useRealTimers()
+ })
+
+ afterEach(() => {
+ setRemote(false)
+ delete (globalThis as unknown as { window?: unknown }).window
+ })
+
+ it('applies the CLIENT update in local mode, never the backend', async () => {
+ setRemote(false)
+ $updateStatus.set(status({ behind: 3 }))
+
+ requestActiveUpdate()
+ await vi.waitFor(() => expect(applyClientMock).toHaveBeenCalled())
+
+ expect(updateHermesSpy).not.toHaveBeenCalled()
+ expect($updateOverlayTarget.get()).toBe('client')
+ })
+
+ it('applies the BACKEND update in remote mode', async () => {
+ setRemote(true)
+ $backendUpdateStatus.set(status({ behind: 3 }))
+
+ requestActiveUpdate()
+ await vi.waitFor(() => expect(updateHermesSpy).toHaveBeenCalled())
+
+ expect(applyClientMock).not.toHaveBeenCalled()
+ expect($updateOverlayTarget.get()).toBe('backend')
+ })
+
+ it('always opens the overlay, so selecting the row is never a silent no-op', () => {
+ setRemote(false)
+ $updateStatus.set(status({ behind: 3 }))
+
+ requestActiveUpdate()
+
+ expect($updateOverlayOpen.get()).toBe(true)
+ })
+
+ it('opens the overlay to re-check instead of applying when already current', () => {
+ setRemote(false)
+ $updateStatus.set(status({ behind: 0, updateAvailable: false }))
+
+ requestActiveUpdate()
+
+ expect($updateOverlayOpen.get()).toBe(true)
+ expect(applyClientMock).not.toHaveBeenCalled()
+ expect(updateHermesSpy).not.toHaveBeenCalled()
+ })
+
+ it('applies on a backend that reports an update it cannot count commits for', async () => {
+ setRemote(true)
+ $backendUpdateStatus.set(status({ behind: 0, updateAvailable: true }))
+
+ requestActiveUpdate()
+ await vi.waitFor(() => expect(updateHermesSpy).toHaveBeenCalled())
+ })
+})
+
describe('applyUpdates terminal state', () => {
const applyMock = vi.fn()
diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts
index 1f0998a5e069..0b46920e30d2 100644
--- a/apps/desktop/src/store/updates.ts
+++ b/apps/desktop/src/store/updates.ts
@@ -254,6 +254,25 @@ export function startActiveUpdate(): void {
void (target === 'backend' ? applyBackendUpdate() : applyUpdates())
}
+/**
+ * Command-palette entry point. The About panel's "Update now" only renders once
+ * we know an update is waiting; this row is always listed, so it also has to
+ * handle "already current" — open the overlay for the active target and let its
+ * check answer, and only apply when there's something to install.
+ */
+export function requestActiveUpdate(): void {
+ const target: UpdateTarget = isRemoteMode() ? 'backend' : 'client'
+ const status = target === 'backend' ? $backendUpdateStatus.get() : $updateStatus.get()
+
+ if ((status?.behind ?? 0) > 0 || status?.updateAvailable) {
+ startActiveUpdate()
+
+ return
+ }
+
+ openUpdateOverlayFor(target)
+}
+
/** Re-read the running app's version from the Electron main process and
* publish it on `$desktopVersion`. Called when the About panel mounts, the
* update flow finishes, and the window regains focus, so the About text
@@ -294,6 +313,7 @@ function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
message: res.message ?? undefined,
updateAvailable: res.update_available,
behind: behind > 0 ? behind : 0,
+ currentVersion: res.current_version,
targetSha: res.update_available ? `backend:${res.current_version}` : undefined,
commits: res.commits,
fetchedAt: Date.now()