diff --git a/apps/desktop/src/plugins/README.md b/apps/desktop/src/plugins/README.md index 10ec027a9aba..6386ed227dae 100644 --- a/apps/desktop/src/plugins/README.md +++ b/apps/desktop/src/plugins/README.md @@ -11,6 +11,15 @@ in the companion [`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins) repo instead. +The Axiom fork's bundled **Update Control** plugin belongs here because it +dogfoods the fork-local `host.updates` facade and provides a shipped management +surface. It reads detached local-client and active-backend snapshots, including +deploy/upstream disparity when core publishes it, then hands the active target +to the core-owned native updater. Do not add direct checks, branch writes, pull, +merge, install, restart, or relaunch mutation to plugin code; polling, +confirmation, dirty-tree policy, deploy reconciliation, and process handoff +stay in core. + User- and agent-authored plugins load at runtime from `$HERMES_HOME/desktop-plugins//plugin.js` (the disk door) — see the `hermes-desktop-plugins` skill. diff --git a/apps/desktop/src/plugins/update-control/model.test.ts b/apps/desktop/src/plugins/update-control/model.test.ts new file mode 100644 index 000000000000..5acefd0dc19b --- /dev/null +++ b/apps/desktop/src/plugins/update-control/model.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' + +import { friendlyError, hasUpdate, shortSha } from './model' + +describe('update summaries', () => { + it('treats either the explicit flag or a positive behind count as an update', () => { + expect(hasUpdate({ supported: true, updateAvailable: true })).toBe(true) + expect(hasUpdate({ supported: true, behind: 2 })).toBe(true) + expect(hasUpdate({ supported: false, behind: 2 })).toBe(false) + expect(hasUpdate({ supported: true, behind: 0 })).toBe(false) + }) + + it('shortens commit identifiers without inventing one', () => { + expect(shortSha('1234567890abcdef')).toBe('12345678') + expect(shortSha('abc')).toBe('abc') + expect(shortSha()).toBe('—') + }) + + it('turns unknown failures into useful general-purpose copy', () => { + expect(friendlyError(new Error('bridge offline'))).toBe('bridge offline') + expect(friendlyError('timeout')).toBe('timeout') + expect(friendlyError(null)).toBe('Update information is unavailable right now.') + }) +}) diff --git a/apps/desktop/src/plugins/update-control/model.ts b/apps/desktop/src/plugins/update-control/model.ts new file mode 100644 index 000000000000..8ecda28f931f --- /dev/null +++ b/apps/desktop/src/plugins/update-control/model.ts @@ -0,0 +1,25 @@ +export interface UpdateSummary { + supported?: boolean + updateAvailable?: boolean + behind?: number +} + +export function hasUpdate(status: UpdateSummary | null | undefined): boolean { + return status?.supported === true && (status.updateAvailable === true || (status.behind ?? 0) > 0) +} + +export function shortSha(value?: null | string): string { + return value ? value.slice(0, 8) : '—' +} + +export function friendlyError(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message + } + + if (typeof error === 'string' && error.trim()) { + return error + } + + return 'Update information is unavailable right now.' +} diff --git a/apps/desktop/src/plugins/update-control/plugin.test.tsx b/apps/desktop/src/plugins/update-control/plugin.test.tsx new file mode 100644 index 000000000000..9fd0c5e45cf8 --- /dev/null +++ b/apps/desktop/src/plugins/update-control/plugin.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' + +import plugin from './plugin' + +describe('Update Control plugin registration', () => { + it('ships opt-in and contributes the page, navigation, status, and palette action', () => { + const registerMany = vi.fn() + + plugin.register({ registerMany } as never) + + expect(plugin.defaultEnabled).toBe(false) + expect(plugin.id).toBe('update-control') + + const contributions = registerMany.mock.calls[0]?.[0] as Array<{ + area: string + data?: { label?: string; path?: string } + id: string + render?: unknown + }> + + expect(contributions.map(contribution => contribution.id)).toEqual(['page', 'nav', 'status', 'open']) + expect(contributions.find(contribution => contribution.id === 'page')).toMatchObject({ + area: 'routes', + data: { path: '/update-control' } + }) + expect(contributions.find(contribution => contribution.id === 'nav')).toMatchObject({ + area: 'sidebar.nav', + data: { label: 'Update Control', path: '/update-control' } + }) + expect(contributions.find(contribution => contribution.id === 'status')?.render).toBeTypeOf('function') + expect(contributions.find(contribution => contribution.id === 'open')?.data?.label).toBe('Update Control: Open') + }) +}) diff --git a/apps/desktop/src/plugins/update-control/plugin.tsx b/apps/desktop/src/plugins/update-control/plugin.tsx new file mode 100644 index 000000000000..7c551f17f79b --- /dev/null +++ b/apps/desktop/src/plugins/update-control/plugin.tsx @@ -0,0 +1,383 @@ +import { + Badge, + Button, + cn, + Codicon, + type DesktopUpdateStatus, + fmtDateTime, + type HermesPlugin, + host, + PALETTE_AREA, + type PaletteContribution, + type RouteContribution, + ROUTES_AREA, + SIDEBAR_NAV_AREA, + type SidebarNavContribution, + STATUSBAR_AREAS, + StatusDot, + type StatusTone, + Tip, + useQuery +} from '@hermes/plugin-sdk' +import { useState } from 'react' + +import { friendlyError, hasUpdate, shortSha } from './model' + +const ROUTE = '/update-control' +const SNAPSHOTS_KEY = ['update-control', 'snapshots'] as const + +type UpdateTarget = 'backend' | 'client' + +interface ReadonlyUpdatesApi { + getStatus?: (target: UpdateTarget) => DesktopUpdateStatus | null + open?: () => void +} + +interface UpdateSnapshots { + backend: DesktopUpdateStatus | null + client: DesktopUpdateStatus | null +} + +function updatesApi(): ReadonlyUpdatesApi | undefined { + return (host as typeof host & { updates?: ReadonlyUpdatesApi }).updates +} + +function readSnapshots(): UpdateSnapshots { + const api = updatesApi() + + return { + backend: api?.getStatus?.('backend') ?? null, + client: api?.getStatus?.('client') ?? null + } +} + +function useUpdateSnapshots() { + return useQuery({ + queryFn: readSnapshots, + queryKey: SNAPSHOTS_KEY, + refetchInterval: 30_000, + retry: false + }) +} + +function toneFor(snapshots?: UpdateSnapshots): StatusTone { + if (!snapshots?.client && !snapshots?.backend) { + return 'muted' + } + + if (snapshots.client?.error || snapshots.backend?.error) { + return 'bad' + } + + if (hasUpdate(snapshots.client) || hasUpdate(snapshots.backend)) { + return 'warn' + } + + return snapshots.client?.supported || snapshots.backend?.supported ? 'good' : 'muted' +} + +function statusLabel(snapshots?: UpdateSnapshots): string { + if (!snapshots?.client && !snapshots?.backend) { + return 'update status pending' + } + + const pending = Number(hasUpdate(snapshots.client)) + Number(hasUpdate(snapshots.backend)) + + if (pending > 0) { + return `${pending} update${pending === 1 ? '' : 's'} available` + } + + if (snapshots.client?.error || snapshots.backend?.error) { + return 'update status issue' + } + + if (!snapshots.client?.supported && !snapshots.backend?.supported) { + return 'updates unavailable' + } + + return 'updates current' +} + +function UpdateStatusIndicator() { + const snapshots = useUpdateSnapshots() + const label = statusLabel(snapshots.data) + + return ( + + + + ) +} + +function SummaryCell({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+

{label}

+
{value}
+
+ ) +} + +function UpdateStateBadge({ status }: { status: DesktopUpdateStatus | null }) { + if (!status) { + return Pending + } + + if (status.error) { + return Check failed + } + + if (!status.supported) { + return Unavailable + } + + if (hasUpdate(status)) { + return Update available + } + + return Current +} + +function RecentCommits({ status }: { status: DesktopUpdateStatus | null }) { + const commits = (status?.commits ?? []).slice(0, 6) + + if (commits.length === 0) { + return

No recent commit summary was provided.

+ } + + return ( + + ) +} + +function TargetCard({ + icon, + status, + subtitle, + title +}: { + icon: string + status: DesktopUpdateStatus | null + subtitle: string + title: string +}) { + const branch = status?.currentBranch ?? status?.branch ?? '—' + const current = status?.currentSha ?? status?.currentVersion + const message = status?.message ?? status?.backendMessage ?? status?.error + + return ( +
+
+
+ + + +
+

{title}

+

{subtitle}

+
+
+ +
+ + {message ? ( +
+ {message} +
+ ) : null} + +
+ + + + +
+ +
+ + +
+ + {status?.deployBehind != null || status?.upstreamBehind != null ? ( +
+ + +
+ ) : null} + + {status?.dirty ? ( +

+ Local changes are present. Review them before updating. +

+ ) : null} + +
+

Recent commits

+ +
+
+ ) +} + +function UpdateControlPage() { + const snapshots = useUpdateSnapshots() + const [openError, setOpenError] = useState(null) + + const openUpdater = () => { + const open = updatesApi()?.open + + if (!open) { + setOpenError('The native updater is unavailable in this Desktop build.') + + return + } + + setOpenError(null) + + try { + open() + } catch (error) { + setOpenError(friendlyError(error)) + } + } + + return ( +
+
+
+
+
+ +

Update Control

+
+

+ Compare core-owned update snapshots for this Desktop client and the connected backend. The native updater + owns checks, confirmation, install, and restart. +

+
+
+ + +
+
+ + {openError || snapshots.error ? ( +
+ {openError ?? friendlyError(snapshots.error)} +
+ ) : null} + +
+ + +
+ +

+ Update Control is read-only. Opening the updater refreshes the active target and keeps dirty-tree handling, + deploy reconciliation, update execution, and process handoff in Hermes core. +

+
+
+ ) +} + +const plugin: HermesPlugin = { + id: 'update-control', + name: 'Update Control', + defaultEnabled: false, + register(ctx) { + ctx.registerMany([ + { + id: 'page', + area: ROUTES_AREA, + data: { path: ROUTE } satisfies RouteContribution, + render: () => + }, + { + id: 'nav', + area: SIDEBAR_NAV_AREA, + order: 60, + data: { codicon: 'cloud-download', label: 'Update Control', path: ROUTE } satisfies SidebarNavContribution + }, + { + id: 'status', + area: STATUSBAR_AREAS.right, + order: 85, + render: () => + }, + { + id: 'open', + area: PALETTE_AREA, + data: { + id: 'update-control.open', + label: 'Update Control: Open', + keywords: ['update', 'version', 'client', 'backend', 'branch'], + run: () => host.navigate(ROUTE) + } satisfies PaletteContribution + } + ]) + } +} + +export default plugin diff --git a/apps/desktop/src/sdk/index.test.ts b/apps/desktop/src/sdk/index.test.ts new file mode 100644 index 000000000000..9ffe60b81f47 --- /dev/null +++ b/apps/desktop/src/sdk/index.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const updateMocks = vi.hoisted(() => ({ + $backendUpdateStatus: { get: vi.fn() }, + $updateStatus: { get: vi.fn() }, + openUpdatesWindow: vi.fn() +})) + +vi.mock('@/store/updates', () => updateMocks) + +const { host } = await import('./index') + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('host.updates', () => { + it('returns detached client and backend snapshots', () => { + const clientStatus = { + behind: 1, + commits: [{ at: 1, author: 'Nous', sha: 'client-sha', summary: 'Client update' }], + fetchedAt: 1, + supported: true + } + + const backendStatus = { behind: 2, fetchedAt: 2, supported: true } + updateMocks.$updateStatus.get.mockReturnValue(clientStatus) + updateMocks.$backendUpdateStatus.get.mockReturnValue(backendStatus) + + const client = host.updates.getStatus('client') + const backend = host.updates.getStatus('backend') + + expect(client).toEqual(clientStatus) + expect(backend).toEqual(backendStatus) + expect(client).not.toBe(clientStatus) + expect(client?.commits).not.toBe(clientStatus.commits) + expect(client?.commits?.[0]).not.toBe(clientStatus.commits[0]) + expect(backend).not.toBe(backendStatus) + }) + + it('returns null when core has not published a snapshot yet', () => { + updateMocks.$updateStatus.get.mockReturnValue(null) + updateMocks.$backendUpdateStatus.get.mockReturnValue(null) + + expect(host.updates.getStatus('client')).toBeNull() + expect(host.updates.getStatus('backend')).toBeNull() + }) + + it('opens the core-owned updater for the active target', () => { + host.updates.open() + + expect(updateMocks.openUpdatesWindow).toHaveBeenCalledOnce() + }) + + it('does not expose mutation, branch, check, progress, or raw bridge doors', () => { + expect(Object.keys(host.updates).sort()).toEqual(['getStatus', 'open']) + expect(host.updates).not.toHaveProperty('apply') + expect(host.updates).not.toHaveProperty('setBranch') + expect(host.updates).not.toHaveProperty('check') + expect(host.updates).not.toHaveProperty('onProgress') + expect(host.updates).not.toHaveProperty('bridge') + }) +}) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index ae7d02a42005..0a0d7c86feb4 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -22,12 +22,14 @@ import { atom, type ReadableAtom } from 'nanostores' import { $narrowViewport } from '@/components/pane-shell/tree/store' import { onGatewayEvent } from '@/contrib/events' +import type { DesktopUpdateStatus } from '@/global' import { getLogs, getStatus } from '@/hermes' import { $gateway } from '@/store/gateway' import { notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile } from '@/store/profile' import { $activeSessionId, $currentCwd, $currentModel, $gatewayState } from '@/store/session' import { runGatewayRestart } from '@/store/system-actions' +import { $backendUpdateStatus, $updateStatus, openUpdatesWindow, type UpdateTarget } from '@/store/updates' // -- state: readonly views over the app's live atoms ------------------------- @@ -55,6 +57,23 @@ if (typeof window !== 'undefined') { $narrowViewport.listen(refresh) } +export type PluginUpdateTarget = UpdateTarget + +export interface PluginUpdateManagement { + /** Detached status for the local client or currently connected backend. */ + getStatus: (target: PluginUpdateTarget) => DesktopUpdateStatus | null + /** Open the core updater for the active connection target. */ + open: () => void +} + +const cloneUpdateStatus = (status: DesktopUpdateStatus | null): DesktopUpdateStatus | null => + status + ? { + ...status, + commits: status.commits?.map(commit => ({ ...commit })) + } + : null + export const host = { state: { /** Runtime id of the active chat session (null on a fresh draft). */ @@ -71,6 +90,13 @@ export const host = { viewport: readonlyAtom($viewport) }, + /** Read detached update snapshots; hand every mutation to the core updater. */ + updates: { + getStatus: (target: PluginUpdateTarget) => + cloneUpdateStatus(target === 'client' ? $updateStatus.get() : $backendUpdateStatus.get()), + open: () => openUpdatesWindow() + } satisfies PluginUpdateManagement, + /** Toast into the app's notification stack. */ notify, notifyError, @@ -214,7 +240,10 @@ export type { * `ctx.register` stays the door for permanent contributions. Namespace the * id with your plugin slug (`kanban:board-switcher`). */ export { Contribute, type ContributeProps } from '@/contrib/react/contribute' + export type { Contribution } from '@/contrib/types' +/** Public update snapshot contract for plugin-owned status UI. */ +export type { DesktopUpdateStatus } from '@/global' /** Grab-to-pan for overflow containers (boards, timelines, wide tables) — * the shared scrub primitive; don't hand-roll drag-to-scroll. */ export { type GrabScroll, useGrabScroll } from '@/hooks/use-grab-scroll' diff --git a/docs/axiom-fork-contract.md b/docs/axiom-fork-contract.md index d90daa0b5bbd..e98197f8af84 100644 --- a/docs/axiom-fork-contract.md +++ b/docs/axiom-fork-contract.md @@ -8,6 +8,17 @@ This repository is Bailey/Axiom's deploy fork of `NousResearch/hermes-agent`. - Axiom deploy branch: `origin/axiom` (`https://github.com/Codename-11/hermes-agent.git`). - Axiom-Desktop install path: `%LOCALAPPDATA%\hermes\hermes-agent`. - Axiom-Desktop tracks `origin/axiom`; do not silently switch it back to upstream `main`. +- Axiom-Desktop's update branch must be explicit. On Windows it is persisted in + `%APPDATA%\Hermes\updates.json`: + + ```json + { + "branch": "axiom" + } + ``` + + The checkout's current branch does not replace this setting; a checkout can + be on `axiom` while Desktop still checks another configured update channel. - Bare `hermes update`, `hermes update --check`, and `hermes --version` are intentionally deploy-branch-aware on `axiom`; operators should not need a special Desktop-only update command. On a deploy branch, plain `hermes update` fetches both remotes, reconciles `upstream/main` into `origin/` in a temporary worktree, publishes the result, then fast-forwards the live checkout. - Desktop's update UI should distinguish deploy-branch freshness from upstream disparity: `HEAD..origin/axiom` means a published result is ready to consume, while `origin/axiom..upstream/main` means the next update must first reconcile and publish upstream work. - If upstream has new commits but `origin/axiom` has not moved, the first host to run `hermes update` becomes the integration host for that run. It resolves and publishes once; later hosts consume the published `origin/axiom` result unless upstream advances again. @@ -47,6 +58,31 @@ The `axiom` branch is expected to: 9. A retained handoff is a snapshot, not a permanent merge state. Before launching the resolver, compare the marker's recorded `origin_head` and `upstream_head` against current `origin/`. If both recorded refs are already ancestors, clear the stale marker/worktree and start a fresh deploy update; do not compare completion only against the moving current `upstream/main`. The resolver transcript streams live under an explicit advisory banner, while the parent updater remains authoritative: it validates the worktree, runs focused checks, commits, and pushes only after the child exits. Resolver failures also print the exit code plus a bounded transcript tail. 10. There are no deploy update modes. The first host to observe upstream work publishes the reconciled artifact; any later host fast-forwards to that same `origin/` result. +## Desktop Update Control contract + +The bundled **Update Control** plugin is a read-only cockpit over the existing +Desktop updater. It reports the local Desktop client and the active backend as +separate targets because they can update on different hosts and schedules. + +- **Client freshness** describes the Windows checkout, configured Desktop update + branch, built Desktop artifact, and running `Hermes.exe`. +- **Backend freshness** describes the connected `hermes serve` runtime. A current + client does not prove a remote backend is current, and vice versa. +- **Checkout disparity** compares the local `HEAD` with the published deploy + branch (`origin/axiom`). +- **Deploy disparity** compares the published deploy branch with + `upstream/main`. Upstream work can be pending reconciliation even when the + local checkout has consumed every published Axiom commit. + +The fork-local `host.updates` plugin facade exposes only detached client/backend +status snapshots and one entry point that opens the native updater for the +active connection target. It does not expose checks, branch selection, progress +streams, raw Electron IPC, shell commands, or apply controls. It reports +Git/update readiness but does not replace the separate build-stamp/source-hash/ +running-executable verification below. All polling, mutation, confirmation, +dirty-tree policy, deploy reconciliation, and restart/relaunch handling stay in +the core updater. + Suggested focused verification for Desktop patch work: ```bash @@ -114,8 +150,26 @@ hermes update Verify: ```powershell +Get-Content "$env:APPDATA\Hermes\updates.json" git status --short --branch git log -5 --oneline hermes --version +Get-Content "$env:LOCALAPPDATA\hermes\desktop-build-stamp.json" Get-Item apps\desktop\release\win-unpacked\Hermes.exe | Select-Object FullName,Length,LastWriteTime +Get-CimInstance Win32_Process -Filter "Name = 'Hermes.exe'" | Select-Object ExecutablePath,CreationDate ``` + +Do not use the executable timestamp alone as proof. Verify all of the following: + +1. `%APPDATA%\Hermes\updates.json` names `axiom` explicitly. +2. `HEAD...origin/axiom` shows no unpublished deploy commits waiting for this + checkout, while `origin/axiom...upstream/main` is interpreted separately as + fork carry versus upstream work still awaiting reconciliation. +3. `%LOCALAPPDATA%\hermes\desktop-build-stamp.json` exists and its + `contentHash` matches the current Desktop source hash. A matching Git commit + alone is insufficient when tracked source is modified. +4. The expected unpacked executable exists and was produced by that verified + build. +5. the running `Hermes.exe` process points to that executable and started after + it was built. Otherwise the source/build may be current while the open client + is still the previous process. diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index 2e5abb8bec37..72d6102567cd 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -41,7 +41,8 @@ plugin, and fail to resolve in a disk plugin). Capability comes in tiers: - **`host.state.*`** — readonly views over the app's live state (nanostore atoms): active session, cwd, gateway status, model, profile, viewport. - **`host.*` actions** — curated safe verbs: toast, navigate, tail logs, - restart the gateway, subscribe to the gateway event stream. + restart the gateway, subscribe to the gateway event stream, and inspect or + open the core-owned updater through `host.updates`. - **`host.request`** — the gateway JSON-RPC door: sessions, config, skills, cron — everything the app itself calls. - **`ctx.rest` / `ctx.socket`** — your plugin's own backend namespace @@ -59,8 +60,8 @@ plugin, and fail to resolve in a disk plugin). Capability comes in tiers: Both take the same `HermesPlugin` contract, appear in **Settings → Plugins**, and enable/disable live. Everything on this page is written against the disk door (what you and the agent write); [Bundled plugins](#bundled-plugins) notes the two -differences. No desktop plugins ship in the core tree today — reference demos -live in the companion +differences. The Axiom fork ships the **Update Control** bundled plugin; reference +demos live in the companion [`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins) repo. @@ -403,6 +404,33 @@ The other doors (`openExternal`, `revealPath`, `writeClipboard`) resolve `false` instead of throwing when the capability isn't available (older desktop shell, plain browser) — branch on the result rather than sniffing the bridge. +### Update status and native-updater handoff + +The Axiom fork adds a narrow `host.updates` facade for plugins that need update +observability without becoming an updater: + +```ts +const client = host.updates.getStatus('client') +const backend = host.updates.getStatus('backend') + +host.updates.open() +``` + +The client and backend are deliberately separate. `client` describes the +Electron host's checkout and configured update branch; `backend` describes the +active `hermes serve` runtime, which may be remote and have a different version +or deploy state. Each call returns a detached snapshot (or `null` before core has +published one), including `fetchedAt` when available. Mutating a returned object +cannot change core update state. + +`open()` takes no target or options. Core selects the active client/backend +target from the current connection, refreshes it, and owns confirmation, +dirty-tree handling, deploy reconciliation, install, restart, and relaunch. +There are intentionally no plugin-facing check, branch-write, progress, shell, +raw IPC, or apply methods. Build-stamp/source-hash and running-executable +verification remain separate host/operator checks rather than claims inferred +from update status. + ## Data layer — React Query + nanostores Plugins share the app's single `QueryClient`, so plugin queries cache, dedupe, @@ -567,8 +595,9 @@ enable/disable contract as a disk plugin. The two differences: 2. It's still lint-fenced to `@hermes/plugin-sdk` + `react` only — no `@/…` app internals. -No desktop plugins ship in the core tree today; the shipped app stays uncluttered -and demos live in the +The Axiom fork ships **Update Control** from this tree. It uses +`host.updates.getStatus()` for read-only comparison and `host.updates.open()` to +delegate the active target to the native update overlay. Demos still live in the [`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins) companion repo. @@ -614,7 +643,7 @@ not treat this pipeline as a trust boundary. | Category | Exports | |----------|---------| -| Host | `host` (`.state.*`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`) | +| Host | `host` (`.state.*`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`, `.updates.getStatus`, `.updates.open`) | | Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `Contribution` | | Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS` | | Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider` | diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index d7c803ed77ba..194f3b2d738e 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -168,10 +168,73 @@ The app also surfaces the broader Hermes management surface so you don't have to ## Updating -The app checks for updates in the background and offers a one-click update when one is ready. +The app checks for updates in the background and offers a one-click update when +one is ready. In the Axiom fork, the bundled **Update Control** plugin adds a +page, status-bar summary, and command-palette entry for viewing the local +Desktop client and active backend snapshots without treating them as one target. +Enable it under **Settings → Plugins**, then open **Update Control** from the +sidebar or command palette. The [manual update process](https://hermes-agent.nousresearch.com/docs/getting-started/updating) also works with the GUI. +### Client and backend freshness are separate + +- **Desktop client** means the Electron app and source checkout on the machine + where the window is running. +- **Active backend** means the `hermes serve` runtime the app is connected to. + In remote or cloud mode, that normally lives on another machine. + +Updating one does not update the other. A fresh Windows client can be connected +to an older backend, and a fresh backend can still be viewed through an older +client. Update Control shows both and hands the active connection target to the +native Hermes update overlay, which owns checks, confirmation, changelog display, +dirty-tree handling, install, restart, and relaunch. The plugin never performs +update mutation itself. + +### Fork and deploy update branches + +For Axiom-Desktop, set the client update branch explicitly to `axiom`. On +Windows the selection is persisted at: + +```text +%APPDATA%\Hermes\updates.json +``` + +```json +{ + "branch": "axiom" +} +``` + +The configured update branch is independent of the checkout's current branch. +For fork/deploy installations, read the three comparisons separately: + +1. **Checkout → deploy branch:** `HEAD...origin/axiom` tells you whether this + client has consumed the published Axiom result. +2. **Deploy branch → upstream:** `origin/axiom...upstream/main` tells you whether + upstream work still needs reconciliation and publication to the deploy + branch. +3. **Client → backend:** each target reports its own version, branch, and update + availability; equality is not implied by either Git comparison. + +### Verify the build that is actually running + +"Source is current" is only one layer. For a source-built Windows client, +verify the Desktop build stamp at +`%LOCALAPPDATA%\hermes\desktop-build-stamp.json`, compare its `contentHash` +with the current Desktop source hash, inspect the expected executable under +`%LOCALAPPDATA%\hermes\hermes-agent\apps\desktop\release\win-unpacked\Hermes.exe`, +and confirm the running `Hermes.exe` process points to that file and started +after the build. These checks distinguish: + +- checkout current, rebuild required; +- build current, relaunch required; and +- verified build currently running. + +Update Control reports Git/update readiness; it does not replace this build and +running-process verification. Use **Open native updater** to refresh and manage +the active connection target through core. + ## Uninstalling Open **Settings → About → Danger zone** and pick how much to remove: