feat: add safe desktop update system v2 (#221)

* Revert "fix: show Agent update blocked CTA"

This reverts commit 8721ae07cc.

* Revert "fix: surface blocked Agent updates"

This reverts commit 5feac16ecd.

* Revert "feat: split Workspace and Agent update flows"

This reverts commit da11ce22e9.

* Revert "fix: avoid unsafe upstream updater merge"

This reverts commit 1c865abd81.

* Revert "feat: show Hermes update release notes"

This reverts commit 7d8d4d65a8.

* Revert "fix: harden Hermes updater flow"

This reverts commit 73e47ae5c8.

* Revert "feat: add Hermes workspace updater"

This reverts commit 53b7073706.

* feat: add update system v2

* fix: fetch before update fast-forward check

* fix: handle silent git checks in updater

* fix: update configured Agent repo directly

* fix: persist update release notes server-side

* fix: repair release notes persistence syntax

* fix: flag assistant role-confusion output

* docs: add i18n contribution guide

---------

Co-authored-by: Aurora release bot <release@outsourc-e.com>
This commit is contained in:
Eric
2026-05-01 19:09:17 -04:00
committed by GitHub
parent 8721ae07cc
commit d23a63921a
17 changed files with 1609 additions and 1473 deletions

View File

@@ -0,0 +1,50 @@
# Hermes Workspace Desktop Update System
This branch introduces the update contract that the DMG/EXE packaging should use.
## Products
Hermes ships two separately updateable products:
1. **Hermes Workspace**: the UI/server shell.
2. **Hermes Agent**: the local agent/gateway runtime.
They must not be modeled as two remotes in the same git checkout. The Workspace updater updates Workspace. The Agent updater updates the installed/bundled Agent.
## API
- `GET /api/update/status`
- returns Workspace + Agent version/install/update state.
- `POST /api/update/workspace`
- applies a Workspace update only when safe.
- `POST /api/update/agent`
- applies an Agent update only when safe.
## Install kinds
Current implementation detects:
- `git`: development/source checkout.
- `docker`: running in container, update is not applied in-process.
- `desktop`: reserved for DMG/EXE auto-updater integration.
- `unknown`: cannot safely update automatically.
## Git/dev behavior
For git installs:
- Workspace updates use `origin/<branch>` and require a clean, fast-forwardable checkout.
- Agent updates call the Agent's own `hermes update` command and require a clean Agent checkout.
- Dirty or non-fast-forward states are blocked and surfaced as review-required, not as a copy-command primary path.
## Desktop behavior to wire next
The packaged app should set `HERMES_WORKSPACE_DESKTOP=1` and provide a desktop updater bridge that:
1. Checks a signed update manifest or GitHub Release.
2. Downloads the Workspace app update through Electron auto-updater or equivalent.
3. Updates the bundled Hermes Agent payload separately.
4. Restarts Workspace + Agent after update.
5. Stores release notes for the first screen after update.
The UI already expects product-level update status and release notes, so the desktop bridge should map into the same `/api/update/*` contract.

80
docs/i18n-contributing.md Normal file
View File

@@ -0,0 +1,80 @@
# Contributing UI translations
Hermes Workspace currently uses a lightweight translation map for the UI strings that have been wired for localization.
## Translation file
Translations live in:
```text
src/lib/i18n.ts
```
The important pieces are:
- `EN`: the source English keys.
- `ZH`: Simplified Chinese translations.
- `RU`: Russian translations.
- `LOCALES`: maps language ids to translation maps.
- `LOCALE_LABELS`: labels shown in the language selector.
## Adding or improving Chinese translations
1. Open `src/lib/i18n.ts`.
2. Find the `ZH` object.
3. Update the value on the right side of each key.
Example:
```ts
const ZH: LocaleTranslations = {
'nav.dashboard': '仪表板',
'nav.chat': '聊天',
}
```
Keep the key names exactly the same. Only edit the translated text.
## Adding new translatable UI text
If you find hardcoded English UI text:
1. Add a new key to `EN`.
2. Add the same key to every locale map (`ZH`, `RU`, etc.).
3. Replace the hardcoded text in the component with `t('your.newKey')`.
Example:
```ts
// src/lib/i18n.ts
const EN = {
'common.retry': 'Retry',
} as const
const ZH: LocaleTranslations = {
'common.retry': '重试',
}
```
Then in the component:
```tsx
import { t } from '@/lib/i18n'
;<button>{t('common.retry')}</button>
```
## Testing locally
Run:
```bash
pnpm exec vitest run src/lib/i18n.test.ts
pnpm build
```
Then open Settings → Language and switch to the target language.
## Current limitation
Not every UI string has been migrated to the translation map yet. If text remains in English after switching languages, it likely means that component still has hardcoded text and needs to be wired to `t(...)` first.

View File

@@ -1,891 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'motion/react'
import { HugeiconsIcon } from '@hugeicons/react'
import {
ArrowUp02Icon,
Cancel01Icon,
Loading03Icon,
Settings02Icon,
Tick01Icon,
} from '@hugeicons/core-free-icons'
import { cn } from '@/lib/utils'
import { toast } from '@/components/ui/toast'
type RemoteName = 'origin' | 'upstream'
type UpdatePhase = 'idle' | 'updating' | 'done' | 'error'
type RemoteStatus = {
name: RemoteName
label: string
remoteHead: string | null
currentHead: string | null
updateAvailable: boolean
error: string | null
}
type UpdateStatus = {
ok: boolean
app: {
version: string
branch: string | null
currentHead: string | null
dirty: boolean
}
remotes: Array<RemoteStatus>
updateAvailable: boolean
}
type ReleaseNoteSection = {
name: RemoteName | 'agent'
label: string
from: string | null
to: string | null
commits: Array<string>
}
type StoredReleaseNotes = {
id: string
updatedAt: number
sections: Array<ReleaseNoteSection>
}
type UpdateResult = {
ok: boolean
updated?: Array<RemoteName>
skipped?: Array<{ name: RemoteName; reason: string }>
restartRequired?: boolean
releaseNotes?: Array<ReleaseNoteSection>
error?: string
}
type AgentUpdateStatus = {
ok: boolean
app: {
name: string
version: string
path: string | null
repoPath: string | null
branch: string | null
currentHead: string | null
dirty: boolean
}
remote: {
label: string
url: string | null
repoMatches: boolean
currentHead: string | null
remoteHead: string | null
updateAvailable: boolean
canUpdate: boolean
error: string | null
}
updateAvailable: boolean
manualCommand: string
}
const CHECK_INTERVAL_MS = 30 * 60 * 1000
const DISMISS_KEY = 'hermes-update-dismissed-heads'
const AUTO_UPDATE_KEY = 'hermes-workspace-auto-update'
const RELEASE_NOTES_KEY = 'hermes-update-release-notes'
const RELEASE_NOTES_SEEN_KEY = 'hermes-update-release-notes-seen'
const AGENT_DISMISS_KEY = 'hermes-agent-update-v2-dismissed-head'
const AGENT_AUTO_UPDATE_KEY = 'hermes-agent-auto-update'
function shortSha(value: string | null | undefined): string {
return value ? value.slice(0, 7) : 'unknown'
}
function headsKey(remotes: Array<RemoteStatus>): string {
return remotes
.filter((remote) => remote.updateAvailable)
.map((remote) => `${remote.name}:${remote.remoteHead ?? 'unknown'}`)
.sort()
.join('|')
}
function releaseNotesId(sections: Array<ReleaseNoteSection>): string {
return sections
.map(
(section) =>
`${section.name}:${section.from ?? 'unknown'}:${section.to ?? 'unknown'}`,
)
.sort()
.join('|')
}
function readStoredReleaseNotes(): StoredReleaseNotes | null {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(RELEASE_NOTES_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as StoredReleaseNotes
if (!parsed?.id || !Array.isArray(parsed.sections)) return null
if (localStorage.getItem(RELEASE_NOTES_SEEN_KEY) === parsed.id) return null
return parsed
} catch {
return null
}
}
function storeReleaseNotes(
sections: Array<ReleaseNoteSection>,
): StoredReleaseNotes | null {
if (typeof window === 'undefined' || sections.length === 0) return null
const notes: StoredReleaseNotes = {
id: releaseNotesId(sections),
updatedAt: Date.now(),
sections,
}
localStorage.setItem(RELEASE_NOTES_KEY, JSON.stringify(notes))
localStorage.removeItem(RELEASE_NOTES_SEEN_KEY)
return notes
}
function markReleaseNotesSeen(notes: StoredReleaseNotes): void {
localStorage.setItem(RELEASE_NOTES_SEEN_KEY, notes.id)
}
export function HermesUpdateNotifier() {
const queryClient = useQueryClient()
const [dismissed, setDismissed] = useState<string | null>(null)
const [autoUpdate, setAutoUpdate] = useState(false)
const [phase, setPhase] = useState<UpdatePhase>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [progress, setProgress] = useState(0)
const [agentDismissed, setAgentDismissed] = useState<string | null>(null)
const [agentAutoUpdate, setAgentAutoUpdate] = useState(false)
const [agentPhase, setAgentPhase] = useState<UpdatePhase>('idle')
const [agentErrorMsg, setAgentErrorMsg] = useState('')
const [agentProgress, setAgentProgress] = useState(0)
const [releaseNotes, setReleaseNotes] = useState<StoredReleaseNotes | null>(
null,
)
useEffect(() => {
if (typeof window === 'undefined') return
setDismissed(localStorage.getItem(DISMISS_KEY))
setAutoUpdate(localStorage.getItem(AUTO_UPDATE_KEY) === 'true')
setAgentDismissed(localStorage.getItem(AGENT_DISMISS_KEY))
setAgentAutoUpdate(localStorage.getItem(AGENT_AUTO_UPDATE_KEY) === 'true')
setReleaseNotes(readStoredReleaseNotes())
}, [])
const { data } = useQuery({
queryKey: ['hermes-update-check'],
queryFn: async () => {
const res = await fetch('/api/claude-update')
if (!res.ok) return null
return res.json() as Promise<UpdateStatus>
},
refetchInterval: CHECK_INTERVAL_MS,
staleTime: CHECK_INTERVAL_MS,
retry: false,
})
const { data: agentData } = useQuery({
queryKey: ['hermes-agent-update-check'],
queryFn: async () => {
const res = await fetch('/api/hermes-agent-update')
if (!res.ok) return null
return res.json() as Promise<AgentUpdateStatus>
},
refetchInterval: CHECK_INTERVAL_MS,
staleTime: CHECK_INTERVAL_MS,
retry: false,
})
const updateHeadsKey = useMemo(
() => headsKey(data?.remotes ?? []),
[data?.remotes],
)
const updateRemotes =
data?.remotes.filter((remote) => remote.updateAvailable) ?? []
const target =
updateRemotes.length > 1 ? 'all' : (updateRemotes[0]?.name ?? 'origin')
const visible = Boolean(
data?.updateAvailable &&
updateHeadsKey &&
dismissed !== updateHeadsKey &&
phase !== 'done',
)
const isUpdating = phase === 'updating'
const agentHeadsKey = agentData?.remote.remoteHead ?? ''
const agentManualCommand = agentData?.app.repoPath
? `cd ${agentData.app.repoPath} && git status && hermes update`
: (agentData?.manualCommand ?? 'hermes update')
const agentVisible = Boolean(
agentData?.updateAvailable &&
agentHeadsKey &&
agentDismissed !== agentHeadsKey &&
agentPhase !== 'done',
)
const agentIsUpdating = agentPhase === 'updating'
useEffect(() => {
if (!autoUpdate || !data?.updateAvailable || !visible || phase !== 'idle')
return
if (data.app.dirty) return
void handleUpdate()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoUpdate, data?.updateAvailable, data?.app.dirty, visible, phase])
useEffect(() => {
if (
!agentAutoUpdate ||
!agentData?.updateAvailable ||
!agentVisible ||
agentPhase !== 'idle'
)
return
if (!agentData.remote.canUpdate) return
void handleAgentUpdate()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
agentAutoUpdate,
agentData?.updateAvailable,
agentData?.remote.canUpdate,
agentVisible,
agentPhase,
])
function handleDismiss() {
if (!updateHeadsKey) return
localStorage.setItem(DISMISS_KEY, updateHeadsKey)
setDismissed(updateHeadsKey)
}
function toggleAutoUpdate() {
const next = !autoUpdate
setAutoUpdate(next)
localStorage.setItem(AUTO_UPDATE_KEY, String(next))
}
function handleAgentDismiss() {
if (!agentHeadsKey) return
localStorage.setItem(AGENT_DISMISS_KEY, agentHeadsKey)
setAgentDismissed(agentHeadsKey)
}
function toggleAgentAutoUpdate() {
const next = !agentAutoUpdate
setAgentAutoUpdate(next)
localStorage.setItem(AGENT_AUTO_UPDATE_KEY, String(next))
}
async function copyAgentManualCommand() {
try {
await navigator.clipboard.writeText(agentManualCommand)
toast('Copied Hermes Agent manual update command.', { type: 'success' })
} catch {
toast(agentManualCommand, { type: 'info', duration: 9000 })
}
}
async function handleUpdate() {
setPhase('updating')
setErrorMsg('')
setProgress(10)
const progressTimer = window.setInterval(() => {
setProgress((value) => Math.min(value + 3, 88))
}, 400)
try {
const res = await fetch('/api/claude-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target }),
})
const result = (await res.json()) as UpdateResult
window.clearInterval(progressTimer)
if (!res.ok || !result.ok) {
setPhase('error')
setProgress(0)
setErrorMsg(result.error || 'Update failed')
return
}
setProgress(100)
setPhase('done')
if (updateHeadsKey) {
localStorage.setItem(DISMISS_KEY, updateHeadsKey)
setDismissed(updateHeadsKey)
}
const storedNotes = result.releaseNotes?.length
? storeReleaseNotes(result.releaseNotes)
: null
if (storedNotes) setReleaseNotes(storedNotes)
await queryClient.invalidateQueries({ queryKey: ['hermes-update-check'] })
toast(
result.restartRequired
? 'Hermes Workspace update installed. Restart the Workspace process to run the new code.'
: 'Hermes Workspace is already up to date.',
{ type: 'success', duration: 7000 },
)
} catch (err) {
window.clearInterval(progressTimer)
setPhase('error')
setProgress(0)
setErrorMsg(err instanceof Error ? err.message : String(err))
}
}
async function handleAgentUpdate() {
setAgentPhase('updating')
setAgentErrorMsg('')
setAgentProgress(10)
const progressTimer = window.setInterval(() => {
setAgentProgress((value) => Math.min(value + 2, 88))
}, 600)
try {
const res = await fetch('/api/hermes-agent-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const result = (await res.json()) as UpdateResult
window.clearInterval(progressTimer)
if (!res.ok || !result.ok) {
setAgentPhase('error')
setAgentProgress(0)
setAgentErrorMsg(result.error || 'Hermes Agent update failed')
return
}
setAgentProgress(100)
setAgentPhase('done')
if (agentHeadsKey) {
localStorage.setItem(AGENT_DISMISS_KEY, agentHeadsKey)
setAgentDismissed(agentHeadsKey)
}
const storedNotes = result.releaseNotes?.length
? storeReleaseNotes(result.releaseNotes)
: null
if (storedNotes) setReleaseNotes(storedNotes)
await queryClient.invalidateQueries({
queryKey: ['hermes-agent-update-check'],
})
toast(
'Hermes Agent update installed. Restart running agent/gateway processes to use it.',
{
type: 'success',
duration: 7000,
},
)
} catch (err) {
window.clearInterval(progressTimer)
setAgentPhase('error')
setAgentProgress(0)
setAgentErrorMsg(err instanceof Error ? err.message : String(err))
}
}
function closeReleaseNotes() {
if (releaseNotes) markReleaseNotesSeen(releaseNotes)
setReleaseNotes(null)
}
return (
<>
<ReleaseNotesModal notes={releaseNotes} onClose={closeReleaseNotes} />
<AnimatePresence>
{agentVisible && agentData ? (
<motion.div
initial={{ opacity: 0, y: -40, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -40, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="fixed left-1/2 top-[calc(var(--titlebar-h,0px)+1rem)] z-[9998] w-[90vw] max-w-md -translate-x-1/2 overflow-hidden rounded-2xl shadow-2xl"
style={{
background: 'var(--theme-card)',
border: '1px solid var(--theme-border)',
color: 'var(--theme-text)',
boxShadow: 'var(--theme-shadow-3)',
}}
>
{agentIsUpdating ? (
<motion.div
className="h-0.5 origin-left"
style={{ background: 'var(--theme-accent)' }}
initial={{ scaleX: 0 }}
animate={{ scaleX: agentProgress / 100 }}
transition={{ duration: 0.25 }}
/>
) : null}
<div className="flex items-center gap-3 px-5 py-3.5">
<div
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-xl',
agentPhase === 'error' ? 'bg-red-500/15' : '',
)}
style={
agentPhase === 'idle' || agentPhase === 'updating'
? {
background:
'color-mix(in srgb, var(--theme-accent) 14%, transparent)',
}
: undefined
}
>
{agentIsUpdating ? (
<HugeiconsIcon
icon={Loading03Icon}
size={18}
strokeWidth={2}
className="animate-spin"
style={{ color: 'var(--theme-accent)' }}
/>
) : (
<HugeiconsIcon
icon={ArrowUp02Icon}
size={18}
strokeWidth={2}
style={{ color: 'var(--theme-accent)' }}
/>
)}
</div>
<div className="min-w-0 flex-1">
<p
className="text-sm font-semibold"
style={{ color: 'var(--theme-text)' }}
>
{agentPhase === 'updating'
? 'Updating Hermes Agent...'
: agentPhase === 'error'
? 'Hermes Agent update failed'
: 'Hermes Agent update available'}
</p>
<p
className="truncate text-xs"
style={{ color: 'var(--theme-muted)' }}
>
{agentPhase === 'error'
? agentErrorMsg
: agentData.remote.error
? `Update blocked: ${agentData.remote.error}`
: `${shortSha(agentData.remote.currentHead)}${shortSha(agentData.remote.remoteHead)}`}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{agentPhase === 'idle' || agentPhase === 'error' ? (
agentData.remote.canUpdate ? (
<button
type="button"
onClick={handleAgentUpdate}
className="rounded-lg px-4 py-1.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
style={{ background: 'var(--theme-accent)' }}
>
{agentPhase === 'error' ? 'Retry' : 'Install'}
</button>
) : (
<button
type="button"
onClick={copyAgentManualCommand}
className="rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
style={{ background: 'var(--theme-accent)' }}
title={agentManualCommand}
>
Copy update command
</button>
)
) : null}
<button
type="button"
onClick={handleAgentDismiss}
className="rounded-lg p-1.5 transition-colors hover:opacity-80"
style={{ color: 'var(--theme-muted)' }}
aria-label="Dismiss Hermes Agent update"
>
<HugeiconsIcon
icon={Cancel01Icon}
size={14}
strokeWidth={2}
/>
</button>
</div>
</div>
{agentPhase === 'idle' || agentPhase === 'error' ? (
<div
className="flex items-center justify-between gap-3 border-t px-5 py-2.5"
style={{ borderColor: 'var(--theme-border)' }}
>
<div className="flex min-w-0 items-center gap-2">
<HugeiconsIcon
icon={Settings02Icon}
size={14}
strokeWidth={2}
style={{ color: 'var(--theme-muted)' }}
/>
<span
className="truncate text-xs"
style={{ color: 'var(--theme-muted)' }}
>
{agentData.remote.canUpdate
? 'Auto-update Agent when safe'
: 'Manual cleanup required. Copy the command, fix local changes, then run it.'}
</span>
</div>
{agentData.remote.canUpdate ? (
<button
type="button"
onClick={toggleAgentAutoUpdate}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors duration-200"
style={{
background: agentAutoUpdate
? 'var(--theme-accent)'
: 'var(--theme-card2)',
}}
role="switch"
aria-checked={agentAutoUpdate}
>
<span
className={cn(
'pointer-events-none mt-0.5 inline-block size-4 rounded-full bg-white shadow-sm transition-transform duration-200',
agentAutoUpdate
? 'translate-x-[17px]'
: 'translate-x-0.5',
)}
/>
</button>
) : (
<code
className="hidden max-w-[11rem] shrink-0 truncate rounded px-2 py-1 text-[10px] sm:block"
style={{
background: 'var(--theme-card2)',
color: 'var(--theme-muted)',
}}
title={agentManualCommand}
>
hermes update
</code>
)}
</div>
) : null}
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence>
{visible && data ? (
<motion.div
initial={{ opacity: 0, y: -40, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -40, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className={cn(
'fixed left-1/2 z-[9998] w-[90vw] max-w-md -translate-x-1/2 overflow-hidden rounded-2xl shadow-2xl',
agentVisible
? 'top-[calc(var(--titlebar-h,0px)+7rem)]'
: 'top-[calc(var(--titlebar-h,0px)+1rem)]',
)}
style={{
background: 'var(--theme-card)',
border: '1px solid var(--theme-border)',
color: 'var(--theme-text)',
boxShadow: 'var(--theme-shadow-3)',
}}
>
{isUpdating ? (
<motion.div
className="h-0.5 origin-left"
style={{ background: 'var(--theme-accent)' }}
initial={{ scaleX: 0 }}
animate={{ scaleX: progress / 100 }}
transition={{ duration: 0.25 }}
/>
) : null}
{phase === 'done' ? <div className="h-0.5 bg-green-500" /> : null}
<div className="flex items-center gap-3 px-5 py-3.5">
<div
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-xl',
phase === 'error'
? 'bg-red-500/15'
: phase === 'done'
? 'bg-green-500/15'
: '',
)}
style={
phase === 'idle' || phase === 'updating'
? {
background:
'color-mix(in srgb, var(--theme-accent) 14%, transparent)',
}
: undefined
}
>
{isUpdating ? (
<HugeiconsIcon
icon={Loading03Icon}
size={18}
strokeWidth={2}
className="animate-spin"
style={{ color: 'var(--theme-accent)' }}
/>
) : phase === 'done' ? (
<HugeiconsIcon
icon={Tick01Icon}
size={18}
strokeWidth={2}
className="text-green-400"
/>
) : (
<HugeiconsIcon
icon={ArrowUp02Icon}
size={18}
strokeWidth={2}
style={{ color: 'var(--theme-accent)' }}
/>
)}
</div>
<div className="min-w-0 flex-1">
<p
className="text-sm font-semibold"
style={{ color: 'var(--theme-text)' }}
>
{phase === 'updating'
? 'Updating Hermes Workspace...'
: phase === 'error'
? 'Hermes Workspace update failed'
: 'Hermes Workspace update available'}
</p>
<p
className="truncate text-xs"
style={{ color: 'var(--theme-muted)' }}
>
{phase === 'error'
? errorMsg
: data.app.dirty
? 'Local changes detected. Commit or stash before updating.'
: updateRemotes
.map(
(remote) =>
`${remote.label}: ${shortSha(remote.currentHead)}${shortSha(remote.remoteHead)}`,
)
.join(' · ')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{(phase === 'idle' || phase === 'error') && !data.app.dirty ? (
<button
type="button"
onClick={handleUpdate}
className="rounded-lg px-4 py-1.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
style={{ background: 'var(--theme-accent)' }}
>
{phase === 'error' ? 'Retry' : 'Install'}
</button>
) : null}
{!isUpdating ? (
<button
type="button"
onClick={handleDismiss}
className="rounded-lg p-1.5 transition-colors hover:opacity-80"
style={{ color: 'var(--theme-muted)' }}
aria-label="Dismiss"
>
<HugeiconsIcon
icon={Cancel01Icon}
size={14}
strokeWidth={2}
/>
</button>
) : null}
</div>
</div>
{phase === 'idle' || phase === 'error' ? (
<div
className="flex items-center justify-between border-t px-5 py-2.5"
style={{ borderColor: 'var(--theme-border)' }}
>
<div className="flex items-center gap-2">
<HugeiconsIcon
icon={Settings02Icon}
size={14}
strokeWidth={2}
style={{ color: 'var(--theme-muted)' }}
/>
<span
className="text-xs"
style={{ color: 'var(--theme-muted)' }}
>
Auto-update Workspace when clean
</span>
</div>
<button
type="button"
onClick={toggleAutoUpdate}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors duration-200',
autoUpdate ? '' : '',
)}
style={{
background: autoUpdate
? 'var(--theme-accent)'
: 'var(--theme-card2)',
}}
role="switch"
aria-checked={autoUpdate}
>
<span
className={cn(
'pointer-events-none mt-0.5 inline-block size-4 rounded-full bg-white shadow-sm transition-transform duration-200',
autoUpdate ? 'translate-x-[17px]' : 'translate-x-0.5',
)}
/>
</button>
</div>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</>
)
}
function ReleaseNotesModal({
notes,
onClose,
}: {
notes: StoredReleaseNotes | null
onClose: () => void
}) {
if (!notes) return null
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[10000] flex items-start justify-center bg-black/45 px-4 pt-[calc(var(--titlebar-h,0px)+1.5rem)] backdrop-blur-sm sm:items-center sm:pt-4"
>
<motion.div
initial={{ opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 24, scale: 0.96 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="w-full max-w-lg overflow-hidden rounded-2xl shadow-2xl"
style={{
background: 'var(--theme-card)',
border: '1px solid var(--theme-border)',
color: 'var(--theme-text)',
boxShadow: 'var(--theme-shadow-3)',
}}
>
<div className="flex items-start gap-3 px-5 py-4">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-xl"
style={{
background:
'color-mix(in srgb, var(--theme-accent) 14%, transparent)',
}}
>
<HugeiconsIcon
icon={Tick01Icon}
size={20}
strokeWidth={2}
style={{ color: 'var(--theme-accent)' }}
/>
</div>
<div className="min-w-0 flex-1">
<p
className="text-base font-semibold"
style={{ color: 'var(--theme-text)' }}
>
Hermes updated
</p>
<p className="text-sm" style={{ color: 'var(--theme-muted)' }}>
What changed in this Workspace / Agent update.
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 transition-opacity hover:opacity-80"
style={{ color: 'var(--theme-muted)' }}
aria-label="Close release notes"
>
<HugeiconsIcon icon={Cancel01Icon} size={16} strokeWidth={2} />
</button>
</div>
<div className="max-h-[60vh] space-y-4 overflow-auto px-5 pb-5">
{notes.sections.map((section) => (
<section key={`${section.name}:${section.to ?? section.label}`}>
<div className="mb-2 flex items-center justify-between gap-3">
<h3
className="text-sm font-semibold"
style={{ color: 'var(--theme-text)' }}
>
{section.label}
</h3>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[11px]"
style={{
background: 'var(--theme-card2)',
color: 'var(--theme-muted)',
}}
>
{shortSha(section.from)} {shortSha(section.to)}
</span>
</div>
{section.commits.length ? (
<ul className="space-y-1.5">
{section.commits.map((commit, index) => (
<li
key={`${section.name}-${index}-${commit}`}
className="rounded-xl px-3 py-2 text-sm"
style={{
background: 'var(--theme-card2)',
color: 'var(--theme-text)',
}}
>
{commit}
</li>
))}
</ul>
) : (
<p
className="text-sm"
style={{ color: 'var(--theme-muted)' }}
>
Updated to the latest available commit.
</p>
)}
</section>
))}
</div>
<div
className="flex justify-end border-t px-5 py-3"
style={{ borderColor: 'var(--theme-border)' }}
>
<button
type="button"
onClick={onClose}
className="rounded-lg px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
style={{ background: 'var(--theme-accent)' }}
>
Continue
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -0,0 +1,468 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'motion/react'
import { HugeiconsIcon } from '@hugeicons/react'
import {
ArrowUp02Icon,
Cancel01Icon,
Loading03Icon,
Tick01Icon,
} from '@hugeicons/core-free-icons'
import { cn } from '@/lib/utils'
import { toast } from '@/components/ui/toast'
type ProductId = 'workspace' | 'agent'
type ProductUpdateStatus = {
id: ProductId
label: string
installKind: 'git' | 'desktop' | 'docker' | 'unknown'
version: string
path: string | null
repoPath: string | null
branch: string | null
currentHead: string | null
latestHead: string | null
updateAvailable: boolean
canUpdate: boolean
state: 'current' | 'available' | 'blocked' | 'unsupported' | 'error'
reason: string | null
updateMode: string
}
type UpdateStatus = {
ok: true
checkedAt: number
products: Record<ProductId, ProductUpdateStatus>
updateAvailable: boolean
pendingReleaseNotes?: Array<ReleaseNoteSection>
}
type ReleaseNoteSection = {
product: ProductId
label: string
from: string | null
to: string | null
commits: Array<string>
}
type ApplyUpdateResult = {
ok: boolean
product: ProductId
output?: string
restartRequired?: boolean
status?: ProductUpdateStatus
releaseNotes?: Array<ReleaseNoteSection>
error?: string
}
type Phase = 'idle' | 'updating' | 'done' | 'error'
type Notes = {
id: string
sections: Array<ReleaseNoteSection>
updatedAt: number
}
const CHECK_INTERVAL_MS = 30 * 60 * 1000
const DISMISS_PREFIX = 'hermes-update-v2-dismissed:'
const NOTES_KEY = 'hermes-update-v2-release-notes'
const NOTES_SEEN_KEY = 'hermes-update-v2-release-notes-seen'
function shortSha(value: string | null | undefined): string {
return value ? value.slice(0, 7) : 'unknown'
}
function productDismissKey(product: ProductUpdateStatus): string {
return `${product.id}:${product.latestHead ?? product.version ?? 'unknown'}`
}
function notesId(sections: Array<ReleaseNoteSection>): string {
return sections
.map((section) => `${section.product}:${section.from}:${section.to}`)
.sort()
.join('|')
}
function storeNotes(sections: Array<ReleaseNoteSection>): Notes | null {
if (!sections.length) return null
const notes = { id: notesId(sections), sections, updatedAt: Date.now() }
localStorage.setItem(NOTES_KEY, JSON.stringify(notes))
localStorage.removeItem(NOTES_SEEN_KEY)
return notes
}
function readNotes(): Notes | null {
try {
const raw = localStorage.getItem(NOTES_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Notes
if (!parsed?.id || !Array.isArray(parsed.sections)) return null
if (localStorage.getItem(NOTES_SEEN_KEY) === parsed.id) return null
return parsed
} catch {
return null
}
}
export function UpdateCenterNotifier() {
const queryClient = useQueryClient()
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set())
const [phases, setPhases] = useState<Record<ProductId, Phase>>({
workspace: 'idle',
agent: 'idle',
})
const [errors, setErrors] = useState<Record<ProductId, string>>({
workspace: '',
agent: '',
})
const [notes, setNotes] = useState<Notes | null>(null)
useEffect(() => {
const values = new Set<string>()
for (const key of Object.keys(localStorage)) {
if (key.startsWith(DISMISS_PREFIX))
values.add(localStorage.getItem(key) || '')
}
setDismissed(values)
setNotes(readNotes())
}, [])
const { data } = useQuery({
queryKey: ['update-status-v2'],
queryFn: async () => {
const res = await fetch('/api/update/status')
if (!res.ok) return null
return res.json() as Promise<UpdateStatus>
},
refetchInterval: CHECK_INTERVAL_MS,
staleTime: CHECK_INTERVAL_MS,
retry: false,
})
useEffect(() => {
if (!data?.pendingReleaseNotes?.length) return
const stored = storeNotes(data.pendingReleaseNotes)
if (stored) setNotes((current) => current ?? stored)
}, [data?.pendingReleaseNotes])
const visibleProducts = useMemo(() => {
const products = data ? [data.products.workspace, data.products.agent] : []
return products.filter((product) => {
if (!product.updateAvailable) return false
if (phases[product.id] === 'done') return false
return !dismissed.has(productDismissKey(product))
})
}, [data, dismissed, phases])
function dismiss(product: ProductUpdateStatus) {
const key = productDismissKey(product)
localStorage.setItem(`${DISMISS_PREFIX}${product.id}`, key)
setDismissed((prev) => new Set([...prev, key]))
}
async function update(product: ProductUpdateStatus) {
if (!product.canUpdate) return
setPhases((prev) => ({ ...prev, [product.id]: 'updating' }))
setErrors((prev) => ({ ...prev, [product.id]: '' }))
try {
const res = await fetch(
`/api/update/${product.id === 'workspace' ? 'workspace' : 'agent'}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
},
)
const result = (await res.json()) as ApplyUpdateResult
if (!res.ok || !result.ok) {
setPhases((prev) => ({ ...prev, [product.id]: 'error' }))
setErrors((prev) => ({
...prev,
[product.id]: result.error || `${product.label} update failed`,
}))
return
}
setPhases((prev) => ({ ...prev, [product.id]: 'done' }))
dismiss(product)
const stored = result.releaseNotes?.length
? storeNotes(result.releaseNotes)
: null
if (stored) setNotes(stored)
await queryClient.invalidateQueries({ queryKey: ['update-status-v2'] })
toast(`${product.label} updated. Restart may be required.`, {
type: 'success',
duration: 7000,
})
} catch (err) {
setPhases((prev) => ({ ...prev, [product.id]: 'error' }))
setErrors((prev) => ({
...prev,
[product.id]: err instanceof Error ? err.message : String(err),
}))
}
}
function closeNotes() {
if (notes) localStorage.setItem(NOTES_SEEN_KEY, notes.id)
setNotes(null)
}
return (
<>
<ReleaseNotes notes={notes} onClose={closeNotes} />
<div className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-h,0px)+1rem)] z-[9998] flex w-[92vw] max-w-md -translate-x-1/2 flex-col gap-3">
<AnimatePresence>
{visibleProducts.map((product) => (
<UpdateCard
key={product.id}
product={product}
phase={phases[product.id]}
error={errors[product.id]}
onDismiss={() => dismiss(product)}
onUpdate={() => update(product)}
/>
))}
</AnimatePresence>
</div>
</>
)
}
function UpdateCard({
product,
phase,
error,
onDismiss,
onUpdate,
}: {
product: ProductUpdateStatus
phase: Phase
error: string
onDismiss: () => void
onUpdate: () => void
}) {
const updating = phase === 'updating'
const blocked = product.updateAvailable && !product.canUpdate
const subtitle =
phase === 'error'
? error
: blocked
? product.reason || 'Update requires manual review.'
: `${shortSha(product.currentHead)}${shortSha(product.latestHead)} · ${product.installKind}`
return (
<motion.div
initial={{ opacity: 0, y: -24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -24, scale: 0.96 }}
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
className="pointer-events-auto overflow-hidden rounded-2xl shadow-2xl"
style={{
background: 'var(--theme-card)',
border: '1px solid var(--theme-border)',
color: 'var(--theme-text)',
boxShadow: 'var(--theme-shadow-3)',
}}
>
{updating ? (
<div
className="h-0.5 animate-pulse"
style={{ background: 'var(--theme-accent)' }}
/>
) : null}
<div className="flex items-center gap-3 px-5 py-3.5">
<div
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-xl',
blocked || phase === 'error' ? 'bg-amber-500/15' : '',
)}
style={
!blocked && phase !== 'error'
? {
background:
'color-mix(in srgb, var(--theme-accent) 14%, transparent)',
}
: undefined
}
>
<HugeiconsIcon
icon={
updating
? Loading03Icon
: phase === 'done'
? Tick01Icon
: ArrowUp02Icon
}
size={18}
strokeWidth={2}
className={updating ? 'animate-spin' : undefined}
style={{
color:
blocked || phase === 'error'
? '#f59e0b'
: 'var(--theme-accent)',
}}
/>
</div>
<div className="min-w-0 flex-1">
<p
className="text-sm font-semibold"
style={{ color: 'var(--theme-text)' }}
>
{blocked
? `${product.label} update blocked`
: `${product.label} update available`}
</p>
<p
className="truncate text-xs"
style={{ color: 'var(--theme-muted)' }}
>
{subtitle}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{product.canUpdate ? (
<button
type="button"
onClick={onUpdate}
disabled={updating}
className="rounded-lg px-4 py-1.5 text-xs font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-60"
style={{ background: 'var(--theme-accent)' }}
>
{updating ? 'Updating' : 'Update'}
</button>
) : (
<span
className="rounded-lg px-3 py-1.5 text-xs font-semibold"
style={{
background: 'var(--theme-card2)',
color: 'var(--theme-muted)',
}}
>
Review required
</span>
)}
<button
type="button"
onClick={onDismiss}
className="rounded-lg p-1.5 transition-opacity hover:opacity-80"
style={{ color: 'var(--theme-muted)' }}
aria-label={`Dismiss ${product.label} update`}
>
<HugeiconsIcon icon={Cancel01Icon} size={14} strokeWidth={2} />
</button>
</div>
</div>
</motion.div>
)
}
function ReleaseNotes({
notes,
onClose,
}: {
notes: Notes | null
onClose: () => void
}) {
if (!notes) return null
return (
<AnimatePresence>
<motion.div
className="fixed inset-0 z-[10000] flex items-start justify-center bg-black/45 px-4 pt-[calc(var(--titlebar-h,0px)+1.5rem)] backdrop-blur-sm sm:items-center sm:pt-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="w-full max-w-lg overflow-hidden rounded-2xl shadow-2xl"
initial={{ opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 24, scale: 0.96 }}
style={{
background: 'var(--theme-card)',
border: '1px solid var(--theme-border)',
color: 'var(--theme-text)',
boxShadow: 'var(--theme-shadow-3)',
}}
>
<div className="flex items-start gap-3 px-5 py-4">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-xl"
style={{
background:
'color-mix(in srgb, var(--theme-accent) 14%, transparent)',
}}
>
<HugeiconsIcon
icon={Tick01Icon}
size={20}
strokeWidth={2}
style={{ color: 'var(--theme-accent)' }}
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-base font-semibold">Hermes updated</p>
<p className="text-sm" style={{ color: 'var(--theme-muted)' }}>
What changed in this update.
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 transition-opacity hover:opacity-80"
style={{ color: 'var(--theme-muted)' }}
>
<HugeiconsIcon icon={Cancel01Icon} size={16} strokeWidth={2} />
</button>
</div>
<div className="max-h-[60vh] space-y-4 overflow-auto px-5 pb-5">
{notes.sections.map((section) => (
<section key={`${section.product}:${section.to}`}>
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">{section.label}</h3>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[11px]"
style={{
background: 'var(--theme-card2)',
color: 'var(--theme-muted)',
}}
>
{shortSha(section.from)} {shortSha(section.to)}
</span>
</div>
<ul className="space-y-1.5">
{(section.commits.length
? section.commits
: ['Updated to the latest available version.']
).map((commit, index) => (
<li
key={`${section.product}-${index}-${commit}`}
className="rounded-xl px-3 py-2 text-sm"
style={{ background: 'var(--theme-card2)' }}
>
{commit}
</li>
))}
</ul>
</section>
))}
</div>
<div
className="flex justify-end border-t px-5 py-3"
style={{ borderColor: 'var(--theme-border)' }}
>
<button
type="button"
onClick={onClose}
className="rounded-lg px-4 py-2 text-sm font-semibold text-white"
style={{ background: 'var(--theme-accent)' }}
>
Continue
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}

56
src/lib/i18n.test.ts Normal file
View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { LOCALE_LABELS, t, type LocaleId } from './i18n'
function withLocale<T>(locale: LocaleId, fn: () => T): T {
const originalWindow = globalThis.window
const originalNavigator = globalThis.navigator
const store = new Map<string, string>([['hermes-workspace-locale', locale]])
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {},
})
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
},
})
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { language: 'en-US' },
})
try {
return fn()
} finally {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: originalWindow,
})
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: originalNavigator,
})
}
}
describe('i18n translations', () => {
it('uses Simplified Chinese labels for wired navigation keys', () => {
withLocale('zh', () => {
expect(t('nav.dashboard')).toBe('仪表板')
expect(t('nav.profiles')).toBe('配置文件')
})
})
it('uses Russian labels instead of falling back to English', () => {
withLocale('ru', () => {
expect(t('nav.dashboard')).toBe('Панель')
expect(t('settings.language')).toBe('Язык')
})
})
it('exposes readable locale labels for contributor-targeted languages', () => {
expect(LOCALE_LABELS.zh).toBe('中文')
expect(LOCALE_LABELS.ru).toBe('Русский')
})
})

View File

@@ -71,7 +71,6 @@ import { Route as ApiMemoryRouteImport } from './routes/api/memory'
import { Route as ApiLocalProvidersRouteImport } from './routes/api/local-providers'
import { Route as ApiIntegrationsRouteImport } from './routes/api/integrations'
import { Route as ApiHistoryRouteImport } from './routes/api/history'
import { Route as ApiHermesAgentUpdateRouteImport } from './routes/api/hermes-agent-update'
import { Route as ApiGatewayStatusRouteImport } from './routes/api/gateway-status'
import { Route as ApiFilesRouteImport } from './routes/api/files'
import { Route as ApiEventsRouteImport } from './routes/api/events'
@@ -90,6 +89,9 @@ import { Route as ApiChatEventsRouteImport } from './routes/api/chat-events'
import { Route as ApiAuthCheckRouteImport } from './routes/api/auth-check'
import { Route as ApiAuthRouteImport } from './routes/api/auth'
import { Route as ApiArtifactsRouteImport } from './routes/api/artifacts'
import { Route as ApiUpdateWorkspaceRouteImport } from './routes/api/update/workspace'
import { Route as ApiUpdateStatusRouteImport } from './routes/api/update/status'
import { Route as ApiUpdateAgentRouteImport } from './routes/api/update/agent'
import { Route as ApiSwarmMemorySearchRouteImport } from './routes/api/swarm-memory/search'
import { Route as ApiSkillsUninstallRouteImport } from './routes/api/skills/uninstall'
import { Route as ApiSkillsToggleRouteImport } from './routes/api/skills/toggle'
@@ -436,11 +438,6 @@ const ApiHistoryRoute = ApiHistoryRouteImport.update({
path: '/api/history',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHermesAgentUpdateRoute = ApiHermesAgentUpdateRouteImport.update({
id: '/api/hermes-agent-update',
path: '/api/hermes-agent-update',
getParentRoute: () => rootRouteImport,
} as any)
const ApiGatewayStatusRoute = ApiGatewayStatusRouteImport.update({
id: '/api/gateway-status',
path: '/api/gateway-status',
@@ -531,6 +528,21 @@ const ApiArtifactsRoute = ApiArtifactsRouteImport.update({
path: '/api/artifacts',
getParentRoute: () => rootRouteImport,
} as any)
const ApiUpdateWorkspaceRoute = ApiUpdateWorkspaceRouteImport.update({
id: '/api/update/workspace',
path: '/api/update/workspace',
getParentRoute: () => rootRouteImport,
} as any)
const ApiUpdateStatusRoute = ApiUpdateStatusRouteImport.update({
id: '/api/update/status',
path: '/api/update/status',
getParentRoute: () => rootRouteImport,
} as any)
const ApiUpdateAgentRoute = ApiUpdateAgentRouteImport.update({
id: '/api/update/agent',
path: '/api/update/agent',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSwarmMemorySearchRoute = ApiSwarmMemorySearchRouteImport.update({
id: '/search',
path: '/search',
@@ -738,7 +750,6 @@ export interface FileRoutesByFullPath {
'/api/events': typeof ApiEventsRoute
'/api/files': typeof ApiFilesRoute
'/api/gateway-status': typeof ApiGatewayStatusRoute
'/api/hermes-agent-update': typeof ApiHermesAgentUpdateRoute
'/api/history': typeof ApiHistoryRoute
'/api/integrations': typeof ApiIntegrationsRoute
'/api/local-providers': typeof ApiLocalProvidersRoute
@@ -818,6 +829,9 @@ export interface FileRoutesByFullPath {
'/api/skills/toggle': typeof ApiSkillsToggleRoute
'/api/skills/uninstall': typeof ApiSkillsUninstallRoute
'/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute
'/api/update/agent': typeof ApiUpdateAgentRoute
'/api/update/status': typeof ApiUpdateStatusRoute
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
'/api/sessions/$sessionKey/active-run': typeof ApiSessionsSessionKeyActiveRunRoute
'/api/sessions/$sessionKey/status': typeof ApiSessionsSessionKeyStatusRoute
}
@@ -854,7 +868,6 @@ export interface FileRoutesByTo {
'/api/events': typeof ApiEventsRoute
'/api/files': typeof ApiFilesRoute
'/api/gateway-status': typeof ApiGatewayStatusRoute
'/api/hermes-agent-update': typeof ApiHermesAgentUpdateRoute
'/api/history': typeof ApiHistoryRoute
'/api/integrations': typeof ApiIntegrationsRoute
'/api/local-providers': typeof ApiLocalProvidersRoute
@@ -934,6 +947,9 @@ export interface FileRoutesByTo {
'/api/skills/toggle': typeof ApiSkillsToggleRoute
'/api/skills/uninstall': typeof ApiSkillsUninstallRoute
'/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute
'/api/update/agent': typeof ApiUpdateAgentRoute
'/api/update/status': typeof ApiUpdateStatusRoute
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
'/api/sessions/$sessionKey/active-run': typeof ApiSessionsSessionKeyActiveRunRoute
'/api/sessions/$sessionKey/status': typeof ApiSessionsSessionKeyStatusRoute
}
@@ -972,7 +988,6 @@ export interface FileRoutesById {
'/api/events': typeof ApiEventsRoute
'/api/files': typeof ApiFilesRoute
'/api/gateway-status': typeof ApiGatewayStatusRoute
'/api/hermes-agent-update': typeof ApiHermesAgentUpdateRoute
'/api/history': typeof ApiHistoryRoute
'/api/integrations': typeof ApiIntegrationsRoute
'/api/local-providers': typeof ApiLocalProvidersRoute
@@ -1052,6 +1067,9 @@ export interface FileRoutesById {
'/api/skills/toggle': typeof ApiSkillsToggleRoute
'/api/skills/uninstall': typeof ApiSkillsUninstallRoute
'/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute
'/api/update/agent': typeof ApiUpdateAgentRoute
'/api/update/status': typeof ApiUpdateStatusRoute
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
'/api/sessions/$sessionKey/active-run': typeof ApiSessionsSessionKeyActiveRunRoute
'/api/sessions/$sessionKey/status': typeof ApiSessionsSessionKeyStatusRoute
}
@@ -1091,7 +1109,6 @@ export interface FileRouteTypes {
| '/api/events'
| '/api/files'
| '/api/gateway-status'
| '/api/hermes-agent-update'
| '/api/history'
| '/api/integrations'
| '/api/local-providers'
@@ -1171,6 +1188,9 @@ export interface FileRouteTypes {
| '/api/skills/toggle'
| '/api/skills/uninstall'
| '/api/swarm-memory/search'
| '/api/update/agent'
| '/api/update/status'
| '/api/update/workspace'
| '/api/sessions/$sessionKey/active-run'
| '/api/sessions/$sessionKey/status'
fileRoutesByTo: FileRoutesByTo
@@ -1207,7 +1227,6 @@ export interface FileRouteTypes {
| '/api/events'
| '/api/files'
| '/api/gateway-status'
| '/api/hermes-agent-update'
| '/api/history'
| '/api/integrations'
| '/api/local-providers'
@@ -1287,6 +1306,9 @@ export interface FileRouteTypes {
| '/api/skills/toggle'
| '/api/skills/uninstall'
| '/api/swarm-memory/search'
| '/api/update/agent'
| '/api/update/status'
| '/api/update/workspace'
| '/api/sessions/$sessionKey/active-run'
| '/api/sessions/$sessionKey/status'
id:
@@ -1324,7 +1346,6 @@ export interface FileRouteTypes {
| '/api/events'
| '/api/files'
| '/api/gateway-status'
| '/api/hermes-agent-update'
| '/api/history'
| '/api/integrations'
| '/api/local-providers'
@@ -1404,6 +1425,9 @@ export interface FileRouteTypes {
| '/api/skills/toggle'
| '/api/skills/uninstall'
| '/api/swarm-memory/search'
| '/api/update/agent'
| '/api/update/status'
| '/api/update/workspace'
| '/api/sessions/$sessionKey/active-run'
| '/api/sessions/$sessionKey/status'
fileRoutesById: FileRoutesById
@@ -1442,7 +1466,6 @@ export interface RootRouteChildren {
ApiEventsRoute: typeof ApiEventsRoute
ApiFilesRoute: typeof ApiFilesRoute
ApiGatewayStatusRoute: typeof ApiGatewayStatusRoute
ApiHermesAgentUpdateRoute: typeof ApiHermesAgentUpdateRoute
ApiHistoryRoute: typeof ApiHistoryRoute
ApiIntegrationsRoute: typeof ApiIntegrationsRoute
ApiLocalProvidersRoute: typeof ApiLocalProvidersRoute
@@ -1506,6 +1529,9 @@ export interface RootRouteChildren {
ApiProfilesReadRoute: typeof ApiProfilesReadRoute
ApiProfilesRenameRoute: typeof ApiProfilesRenameRoute
ApiProfilesUpdateRoute: typeof ApiProfilesUpdateRoute
ApiUpdateAgentRoute: typeof ApiUpdateAgentRoute
ApiUpdateStatusRoute: typeof ApiUpdateStatusRoute
ApiUpdateWorkspaceRoute: typeof ApiUpdateWorkspaceRoute
}
declare module '@tanstack/react-router' {
@@ -1944,13 +1970,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiHistoryRouteImport
parentRoute: typeof rootRouteImport
}
'/api/hermes-agent-update': {
id: '/api/hermes-agent-update'
path: '/api/hermes-agent-update'
fullPath: '/api/hermes-agent-update'
preLoaderRoute: typeof ApiHermesAgentUpdateRouteImport
parentRoute: typeof rootRouteImport
}
'/api/gateway-status': {
id: '/api/gateway-status'
path: '/api/gateway-status'
@@ -2077,6 +2096,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiArtifactsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/update/workspace': {
id: '/api/update/workspace'
path: '/api/update/workspace'
fullPath: '/api/update/workspace'
preLoaderRoute: typeof ApiUpdateWorkspaceRouteImport
parentRoute: typeof rootRouteImport
}
'/api/update/status': {
id: '/api/update/status'
path: '/api/update/status'
fullPath: '/api/update/status'
preLoaderRoute: typeof ApiUpdateStatusRouteImport
parentRoute: typeof rootRouteImport
}
'/api/update/agent': {
id: '/api/update/agent'
path: '/api/update/agent'
fullPath: '/api/update/agent'
preLoaderRoute: typeof ApiUpdateAgentRouteImport
parentRoute: typeof rootRouteImport
}
'/api/swarm-memory/search': {
id: '/api/swarm-memory/search'
path: '/search'
@@ -2468,7 +2508,6 @@ const rootRouteChildren: RootRouteChildren = {
ApiEventsRoute: ApiEventsRoute,
ApiFilesRoute: ApiFilesRoute,
ApiGatewayStatusRoute: ApiGatewayStatusRoute,
ApiHermesAgentUpdateRoute: ApiHermesAgentUpdateRoute,
ApiHistoryRoute: ApiHistoryRoute,
ApiIntegrationsRoute: ApiIntegrationsRoute,
ApiLocalProvidersRoute: ApiLocalProvidersRoute,
@@ -2532,6 +2571,9 @@ const rootRouteChildren: RootRouteChildren = {
ApiProfilesReadRoute: ApiProfilesReadRoute,
ApiProfilesRenameRoute: ApiProfilesRenameRoute,
ApiProfilesUpdateRoute: ApiProfilesUpdateRoute,
ApiUpdateAgentRoute: ApiUpdateAgentRoute,
ApiUpdateStatusRoute: ApiUpdateStatusRoute,
ApiUpdateWorkspaceRoute: ApiUpdateWorkspaceRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -15,7 +15,7 @@ import { MobilePromptTrigger } from '@/components/mobile-prompt/MobilePromptTrig
import { Toaster } from '@/components/ui/toast'
import { OnboardingTour } from '@/components/onboarding/onboarding-tour'
import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal'
import { HermesUpdateNotifier } from '@/components/hermes-update-notifier'
import { UpdateCenterNotifier } from '@/components/update-center-notifier'
import { initializeSettingsAppearance } from '@/hooks/use-settings'
import { useApplyChatWidth } from '@/hooks/use-chat-settings'
import {
@@ -28,7 +28,6 @@ import { LoginScreen } from '@/components/auth/login-screen'
import { fetchClaudeAuthStatus, type AuthStatus } from '@/lib/claude-auth'
import { getRootSurfaceState } from './-root-layout-state'
const APP_CSP = [
"default-src 'self'",
"base-uri 'self'",
@@ -209,7 +208,9 @@ export const Route = createRootRoute({
const queryClient = new QueryClient()
export function getRootLayoutMode(onboardingComplete: string | null): 'onboarding' | 'workspace' {
export function getRootLayoutMode(
onboardingComplete: string | null,
): 'onboarding' | 'workspace' {
return onboardingComplete === 'true' ? 'workspace' : 'onboarding'
}
@@ -218,7 +219,11 @@ export function wrapInlineScript(source: string): string {
}
type ServiceWorkerLike = {
getRegistrations: () => Promise<ReadonlyArray<{ unregister: () => boolean | Promise<boolean> | void | Promise<void> }>>
getRegistrations: () => Promise<
ReadonlyArray<{
unregister: () => boolean | Promise<boolean> | void | Promise<void>
}>
>
}
type CachesLike = {
@@ -244,7 +249,9 @@ export async function unregisterServiceWorkers({
await cachesApi
?.keys()
.then((names) => Promise.allSettled(names.map((name) => cachesApi.delete(name))))
.then((names) =>
Promise.allSettled(names.map((name) => cachesApi.delete(name))),
)
.catch(() => undefined)
}
@@ -276,12 +283,20 @@ function RootLayout() {
void fetch('/api/connection-status')
.then((res) => (res.ok ? res.json() : null))
.then((status: { ok?: boolean; chatReady?: boolean; modelConfigured?: boolean } | null) => {
if (status?.ok || (status?.chatReady && status?.modelConfigured)) {
localStorage.setItem(ONBOARDING_KEY, 'true')
syncOnboardingCompletion()
}
})
.then(
(
status: {
ok?: boolean
chatReady?: boolean
modelConfigured?: boolean
} | null,
) => {
if (status?.ok || (status?.chatReady && status?.modelConfigured)) {
localStorage.setItem(ONBOARDING_KEY, 'true')
syncOnboardingCompletion()
}
},
)
.catch(() => undefined)
const handleStorage = (event: StorageEvent) => {
@@ -300,7 +315,8 @@ function RootLayout() {
)
void unregisterServiceWorkers({
serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
serviceWorker:
'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
cachesApi: 'caches' in window ? caches : undefined,
})
@@ -352,7 +368,7 @@ function RootLayout() {
</WorkspaceShell>
<SearchModal />
<KeyboardShortcutsModal />
<HermesUpdateNotifier />
<UpdateCenterNotifier />
{rootSurfaceState.showPostOnboardingOverlays ? (
<>
<MobilePromptTrigger />
@@ -384,10 +400,14 @@ function RootDocument({ children }: { children: React.ReactNode }) {
`),
}}
/>
<script dangerouslySetInnerHTML={{ __html: wrapInlineScript(themeScript) }} />
<script
dangerouslySetInnerHTML={{ __html: wrapInlineScript(themeScript) }}
/>
<HeadContent />
<script
dangerouslySetInnerHTML={{ __html: wrapInlineScript(themeColorScript) }}
dangerouslySetInnerHTML={{
__html: wrapInlineScript(themeColorScript),
}}
/>
</head>
<body>

View File

@@ -1,24 +1,10 @@
import { describe, expect, it } from 'vitest'
import {
createRemoteStatus,
normalizeUpdateTarget,
remoteUrlMatchesExpectedRepo,
} from './claude-update'
import { createRemoteStatus, remoteUrlMatchesExpectedRepo } from './claude-update'
describe('claude update repo gating', () => {
it('matches Claude workspace repo aliases', () => {
expect(
remoteUrlMatchesExpectedRepo(
'https://github.com/example/hermes-workspace.git',
['hermes-workspace'],
),
).toBe(true)
expect(
remoteUrlMatchesExpectedRepo(
'git@github.com:outsourc-e/hermes-workspace.git',
['outsourc-e/hermes-workspace'],
),
).toBe(true)
expect(remoteUrlMatchesExpectedRepo('https://github.com/example/hermes-workspace.git', ['hermes-workspace'])).toBe(true)
expect(remoteUrlMatchesExpectedRepo('git@github.com:outsourc-e/hermes-workspace.git', ['outsourc-e/hermes-workspace'])).toBe(true)
})
it('blocks update availability for wrong remote repos even when heads differ', () => {
@@ -37,7 +23,7 @@ describe('claude update repo gating', () => {
expect(status.error).toContain('expected hermes-workspace')
})
it('marks upstream changes as manual sync only', () => {
it('allows update availability only for the expected repo with a newer remote head', () => {
const status = createRemoteStatus({
name: 'upstream',
label: 'Hermes Agent',
@@ -49,15 +35,7 @@ describe('claude update repo gating', () => {
})
expect(status.repoMatches).toBe(true)
expect(status.autoUpdateSupported).toBe(false)
expect(status.updateAvailable).toBe(false)
expect(status.error).toContain('manual sync')
})
it('normalizes update targets to safe remote names', () => {
expect(normalizeUpdateTarget('origin')).toBe('origin')
expect(normalizeUpdateTarget('upstream')).toBe('upstream')
expect(normalizeUpdateTarget('all')).toBe('all')
expect(normalizeUpdateTarget('$(rm -rf /)')).toBe('origin')
expect(status.updateAvailable).toBe(true)
expect(status.error).toBeNull()
})
})

View File

@@ -4,15 +4,8 @@ import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { isAuthenticated } from '../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
} from '../../server/rate-limit'
type RemoteName = 'origin' | 'upstream'
type UpdateTarget = RemoteName | 'all'
type RemoteStatus = {
name: RemoteName
@@ -24,7 +17,6 @@ type RemoteStatus = {
remoteHead: string | null
currentHead: string | null
updateAvailable: boolean
autoUpdateSupported: boolean
error: string | null
}
@@ -35,24 +27,6 @@ type RemoteDefinition = {
aliases: Array<string>
}
type ReleaseNoteSection = {
name: RemoteName
label: string
from: string | null
to: string | null
commits: Array<string>
}
type UpdateResult = {
ok: boolean
updated: Array<RemoteName>
skipped: Array<{ name: RemoteName; reason: string }>
restartRequired: boolean
output: string
releaseNotes: Array<ReleaseNoteSection>
error?: string
}
export const UPDATE_REMOTE_DEFINITIONS: Array<RemoteDefinition> = [
{
name: 'origin',
@@ -70,59 +44,29 @@ export const UPDATE_REMOTE_DEFINITIONS: Array<RemoteDefinition> = [
function git(args: string[], timeout = 5000): string | null {
try {
return (
execFileSync('git', args, {
cwd: process.cwd(),
encoding: 'utf8',
timeout,
}).trim() || null
)
return execFileSync('git', args, { cwd: process.cwd(), encoding: 'utf8', timeout }).trim() || null
} catch {
return null
}
}
function execOrThrow(
command: string,
args: string[],
timeout = 30_000,
): string {
return execFileSync(command, args, {
cwd: process.cwd(),
encoding: 'utf8',
timeout,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
}
function gitOrThrow(args: string[], timeout = 30_000): string {
return execOrThrow('git', args, timeout)
}
function pkgVersion(): string {
try {
const pkg = JSON.parse(
readFileSync(join(process.cwd(), 'package.json'), 'utf8'),
) as { version?: string }
const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { version?: string }
return pkg.version ?? 'unknown'
} catch {
return 'unknown'
}
}
export function remoteUrlMatchesExpectedRepo(
url: string | null,
aliases: Array<string>,
): boolean {
export function remoteUrlMatchesExpectedRepo(url: string | null, aliases: Array<string>): boolean {
if (!url) return false
const normalizedUrl = url
.toLowerCase()
.replace(/^git@github\.com:/, 'github.com/')
.replace(/^https?:\/\//, '')
.replace(/\.git$/, '')
return aliases.some((alias) =>
normalizedUrl.includes(alias.toLowerCase().replace(/\.git$/, '')),
)
return aliases.some((alias) => normalizedUrl.includes(alias.toLowerCase().replace(/\.git$/, '')))
}
export function createRemoteStatus(input: {
@@ -145,14 +89,6 @@ export function createRemoteStatus(input: {
error = 'Unable to read remote HEAD.'
}
const changed = Boolean(
repoMatches &&
input.currentHead &&
input.remoteHead &&
input.currentHead !== input.remoteHead,
)
const autoUpdateSupported = input.name === 'origin'
return {
name: input.name,
label: input.label,
@@ -162,19 +98,12 @@ export function createRemoteStatus(input: {
repoMatches,
remoteHead: repoMatches ? input.remoteHead : null,
currentHead: input.currentHead,
updateAvailable: changed && autoUpdateSupported,
autoUpdateSupported,
error:
!autoUpdateSupported && changed
? 'Upstream Hermes Agent changes require a manual sync/rebase; one-click fast-forward is disabled for this remote.'
: error,
updateAvailable: Boolean(repoMatches && input.currentHead && input.remoteHead && input.currentHead !== input.remoteHead),
error,
}
}
function remoteStatus(
definition: RemoteDefinition,
currentHead: string | null,
): RemoteStatus {
function remoteStatus(definition: RemoteDefinition, currentHead: string | null): RemoteStatus {
const url = git(['remote', 'get-url', definition.name])
const repoMatches = remoteUrlMatchesExpectedRepo(url, definition.aliases)
let remoteHead: string | null = null
@@ -198,166 +127,6 @@ function remoteStatus(
})
}
export function normalizeUpdateTarget(value: unknown): UpdateTarget {
return value === 'origin' || value === 'upstream' || value === 'all'
? value
: 'origin'
}
function selectedDefinitions(target: UpdateTarget): Array<RemoteDefinition> {
if (target === 'all') return UPDATE_REMOTE_DEFINITIONS
return UPDATE_REMOTE_DEFINITIONS.filter(
(definition) => definition.name === target,
)
}
async function readJsonBody(
request: Request,
): Promise<Record<string, unknown>> {
return request.json().catch(() => ({})) as Promise<Record<string, unknown>>
}
function readCommitMessages(
from: string | null,
to: string | null,
): Array<string> {
if (!from || !to || from === to) return []
const raw = git(['log', '--pretty=format:%s (%h)', `${from}..${to}`], 10_000)
return (
raw
?.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 12) ?? []
)
}
function applyFastForwardUpdate(target: UpdateTarget): UpdateResult {
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']) || 'main'
const startHead = git(['rev-parse', 'HEAD'])
const currentHead = startHead
const dirty = Boolean(git(['status', '--porcelain']))
if (dirty) {
return {
ok: false,
updated: [],
skipped: [],
restartRequired: false,
output: '',
releaseNotes: [],
error:
'Working tree has local changes. Commit, stash, or discard them before updating.',
}
}
const updated: Array<RemoteName> = []
const skipped: UpdateResult['skipped'] = []
const output: Array<string> = []
const releaseNotes: Array<ReleaseNoteSection> = []
for (const definition of selectedDefinitions(target)) {
const status = remoteStatus(definition, currentHead)
if (!status.repoMatches) {
skipped.push({
name: definition.name,
reason: status.error || 'Remote does not match expected repo.',
})
continue
}
if (!status.updateAvailable) {
skipped.push({
name: definition.name,
reason: status.error || 'Already up to date.',
})
continue
}
const ref = definition.name === 'origin' ? branch : 'HEAD'
const beforeMergeHead = git(['rev-parse', 'HEAD'])
output.push(`Fetching ${definition.name}...`)
output.push(gitOrThrow(['fetch', definition.name], 60_000))
if (definition.name !== 'origin') {
skipped.push({
name: definition.name,
reason:
'Manual upstream sync required. Hermes Agent upstream is not applied with one-click Workspace updates.',
})
continue
}
const remoteRef = `${definition.name}/${ref}`
try {
execFileSync('git', ['merge-base', '--is-ancestor', 'HEAD', remoteRef], {
cwd: process.cwd(),
stdio: 'ignore',
})
} catch {
skipped.push({
name: definition.name,
reason: `${remoteRef} is not a fast-forward update. Manual rebase/merge required.`,
})
continue
}
output.push(`Fast-forwarding from ${remoteRef}...`)
output.push(gitOrThrow(['merge', '--ff-only', remoteRef], 60_000))
const afterMergeHead = git(['rev-parse', 'HEAD'])
updated.push(definition.name)
releaseNotes.push({
name: definition.name,
label: definition.label,
from: beforeMergeHead,
to: afterMergeHead,
commits: readCommitMessages(beforeMergeHead, afterMergeHead),
})
}
if (updated.length > 0) {
const endHead = git(['rev-parse', 'HEAD'])
const changedFiles =
startHead && endHead
? (git(['diff', '--name-only', startHead, endHead], 10_000)
?.split('\n')
.filter(Boolean) ?? [])
: []
const dependenciesChanged = changedFiles.some(
(file) => file === 'package.json' || file === 'pnpm-lock.yaml',
)
const shouldVerifyBuild = changedFiles.some(
(file) =>
file.startsWith('src/') ||
file.startsWith('scripts/') ||
file === 'package.json' ||
file === 'pnpm-lock.yaml' ||
file.startsWith('vite') ||
file.startsWith('tsconfig'),
)
if (dependenciesChanged) {
output.push('Installing updated dependencies...')
output.push(
execOrThrow('pnpm', ['install', '--no-frozen-lockfile'], 180_000),
)
}
if (shouldVerifyBuild) {
output.push('Verifying updated Workspace build...')
output.push(execOrThrow('pnpm', ['build'], 240_000))
}
}
return {
ok: true,
updated,
skipped,
restartRequired: updated.length > 0,
output: output.filter(Boolean).join('\n'),
releaseNotes,
}
}
export const Route = createFileRoute('/api/claude-update')({
server: {
handlers: {
@@ -369,9 +138,7 @@ export const Route = createFileRoute('/api/claude-update')({
const currentHead = git(['rev-parse', 'HEAD'])
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'])
const dirty = Boolean(git(['status', '--porcelain']))
const remotes = UPDATE_REMOTE_DEFINITIONS.map((definition) =>
remoteStatus(definition, currentHead),
)
const remotes = UPDATE_REMOTE_DEFINITIONS.map((definition) => remoteStatus(definition, currentHead))
return json({
ok: true,
@@ -388,33 +155,6 @@ export const Route = createFileRoute('/api/claude-update')({
manualConfirmRequired: true,
})
},
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`claude-update-post:${ip}`, 5, 60_000)) {
return rateLimitResponse()
}
try {
const body = await readJsonBody(request)
const target = normalizeUpdateTarget(body.target)
const result = applyFastForwardUpdate(target)
return json(result, { status: result.ok ? 200 : 409 })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,240 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { execFileSync } from 'node:child_process'
import { existsSync, realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { isAuthenticated } from '../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
} from '../../server/rate-limit'
type AgentReleaseNotes = {
name: 'agent'
label: 'Hermes Agent'
from: string | null
to: string | null
commits: Array<string>
}
function exec(
command: string,
args: Array<string>,
options: { cwd?: string; timeout?: number } = {},
): string | null {
try {
return (
execFileSync(command, args, {
cwd: options.cwd ?? process.cwd(),
encoding: 'utf8',
timeout: options.timeout ?? 8_000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim() || null
)
} catch {
return null
}
}
function execOrThrow(
command: string,
args: Array<string>,
options: { cwd?: string; timeout?: number } = {},
): string {
return execFileSync(command, args, {
cwd: options.cwd ?? process.cwd(),
encoding: 'utf8',
timeout: options.timeout ?? 300_000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
}
function agentRepoPath(): string | null {
const candidates = [
process.env.HERMES_AGENT_REPO,
join(homedir(), '.hermes', 'hermes-agent'),
join(homedir(), 'hermes-agent'),
].filter(Boolean) as Array<string>
for (const candidate of candidates) {
try {
const resolved = realpathSync(candidate)
if (existsSync(join(resolved, '.git'))) return resolved
} catch {
// ignore
}
}
return null
}
function git(args: Array<string>, cwd: string): string | null {
return exec('git', args, { cwd })
}
function remoteUrlMatchesHermesAgent(url: string | null): boolean {
if (!url) return false
const normalized = url
.toLowerCase()
.replace(/^git@github\.com:/, 'github.com/')
.replace(/^https?:\/\//, '')
.replace(/\.git$/, '')
return normalized.includes('hermes-agent')
}
function readCommitMessages(
repoPath: string,
from: string | null,
to: string | null,
): Array<string> {
if (!from || !to || from === to) return []
const raw = git(
['log', '--pretty=format:%s (%h)', `${from}..${to}`],
repoPath,
)
return (
raw
?.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 12) ?? []
)
}
function readStatus() {
const versionOutput = exec('hermes', ['--version'], { timeout: 10_000 })
const repoPath = agentRepoPath()
const hermesPath = exec('which', ['hermes'])
const currentHead = repoPath ? git(['rev-parse', 'HEAD'], repoPath) : null
const branch = repoPath
? git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)
: null
const dirty = repoPath
? Boolean(git(['status', '--porcelain'], repoPath))
: false
const remoteUrl = repoPath
? git(['remote', 'get-url', 'origin'], repoPath)
: null
const repoMatches = remoteUrlMatchesHermesAgent(remoteUrl)
const rawRemote =
repoPath && repoMatches
? exec('git', ['ls-remote', remoteUrl || 'origin', 'HEAD'], {
cwd: repoPath,
timeout: 10_000,
})
: null
const remoteHead = rawRemote?.split(/\s+/)[0] ?? null
const updateAvailable = Boolean(
repoPath &&
repoMatches &&
currentHead &&
remoteHead &&
currentHead !== remoteHead,
)
return {
ok: true,
checkedAt: Date.now(),
app: {
name: 'Hermes Agent',
version: versionOutput?.split('\n')[0] ?? 'unknown',
path: hermesPath,
repoPath,
branch,
currentHead,
dirty,
},
remote: {
label: 'Hermes Agent',
url: remoteUrl,
repoMatches,
currentHead,
remoteHead,
updateAvailable,
canUpdate: Boolean(repoPath && repoMatches && !dirty),
error: !repoPath
? 'Hermes Agent git checkout was not found. Run `hermes update` manually from your terminal.'
: !repoMatches
? 'Hermes Agent origin remote does not look like a hermes-agent repository.'
: dirty
? 'Hermes Agent checkout has local changes. Commit or stash before updating.'
: null,
},
updateAvailable,
manualCommand: 'hermes update',
}
}
export const Route = createFileRoute('/api/hermes-agent-update')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
return json(readStatus())
},
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`hermes-agent-update-post:${ip}`, 3, 60_000)) {
return rateLimitResponse()
}
const before = readStatus()
if (!before.remote.canUpdate || !before.app.repoPath) {
return json(
{
ok: false,
error:
before.remote.error ||
'Hermes Agent cannot be safely updated automatically.',
},
{ status: 409 },
)
}
try {
const output = execOrThrow('hermes', ['update'], { timeout: 300_000 })
const after = readStatus()
const notes: AgentReleaseNotes | null = after.app.repoPath
? {
name: 'agent',
label: 'Hermes Agent',
from: before.app.currentHead,
to: after.app.currentHead,
commits: readCommitMessages(
after.app.repoPath,
before.app.currentHead,
after.app.currentHead,
),
}
: null
return json({
ok: true,
output,
restartRequired: true,
releaseNotes: notes ? [notes] : [],
status: after,
})
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -0,0 +1,39 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
} from '../../../server/rate-limit'
import { applyAgentUpdate } from '../../../server/update-system'
export const Route = createFileRoute('/api/update/agent')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
if (!rateLimit(`update-agent:${getClientIp(request)}`, 3, 60_000)) {
return rateLimitResponse()
}
try {
const result = applyAgentUpdate()
return json(result, { status: result.ok ? 200 : 409 })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -0,0 +1,17 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import { readUpdateStatus } from '../../../server/update-system'
export const Route = createFileRoute('/api/update/status')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
return json(readUpdateStatus())
},
},
},
})

View File

@@ -0,0 +1,39 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
} from '../../../server/rate-limit'
import { applyWorkspaceUpdate } from '../../../server/update-system'
export const Route = createFileRoute('/api/update/workspace')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
if (!rateLimit(`update-workspace:${getClientIp(request)}`, 3, 60_000)) {
return rateLimitResponse()
}
try {
const result = applyWorkspaceUpdate()
return json(result, { status: result.ok ? 200 : 409 })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { buildInlineToolRenderPlan, compactInlineToolRenderPlan } from './message-item'
import {
buildInlineToolRenderPlan,
compactInlineToolRenderPlan,
detectAssistantCorruptionWarning,
} from './message-item'
import type { ChatMessage } from '../types'
describe('buildInlineToolRenderPlan', () => {
@@ -145,3 +149,29 @@ describe('compactInlineToolRenderPlan', () => {
])
})
})
describe('detectAssistantCorruptionWarning', () => {
it('flags assistant messages that begin with raw user role text', () => {
const warning = detectAssistantCorruptionWarning(
'assistant',
'user\nNew reviews are fine...',
)
expect(warning?.kind).toBe('role-prefix')
expect(warning?.detail).toContain('Stored role is assistant')
})
it('does not flag real user messages with the same body text', () => {
expect(
detectAssistantCorruptionWarning('user', 'user\nNew reviews are fine...'),
).toBeNull()
})
it('flags very large repeated divider loops', () => {
const text = `${'normal text\n'.repeat(2000)}${'----------\n'.repeat(25)}`
expect(detectAssistantCorruptionWarning('assistant', text)?.kind).toBe(
'divider-loop',
)
})
})

View File

@@ -196,7 +196,9 @@ export function buildInlineToolRenderPlan(
}
}
const trailingSections = toolSections.filter((section) => !usedKeys.has(section.key))
const trailingSections = toolSections.filter(
(section) => !usedKeys.has(section.key),
)
for (const section of trailingSections) {
plan.push({ kind: 'tool', section })
}
@@ -382,6 +384,43 @@ function normalizeStreamToolPhase(
return 'running'
}
export type AssistantCorruptionWarning = {
kind: 'role-prefix' | 'divider-loop'
label: string
detail: string
}
export function detectAssistantCorruptionWarning(
role: string,
text: string,
): AssistantCorruptionWarning | null {
if (role !== 'assistant') return null
const trimmed = text.trimStart()
const roleMatch = /^(user|assistant|system)\s*(?:\n|:)/i.exec(trimmed)
if (roleMatch) {
return {
kind: 'role-prefix',
label: 'Assistant output contains raw transcript role text',
detail: `Stored role is assistant, but the content begins with "${roleMatch[1]}". Treat this as generated text, not a real ${roleMatch[1]} turn.`,
}
}
if (text.length > 20_000) {
const dividerMatches =
text.match(/(?:^|\n)\s*(?:[-_=*]{8,}|[─━]{8,})\s*(?=\n|$)/g) ?? []
if (dividerMatches.length >= 20) {
return {
kind: 'divider-loop',
label: 'Assistant output looks corrupted',
detail:
'This very large assistant message contains repeated divider-like lines and may be a generation loop.',
}
}
}
return null
}
function readExecNotification(message: ChatMessage): ExecNotification | null {
const raw = (message as any).__execNotification as
| Record<string, unknown>
@@ -1615,10 +1654,22 @@ function ToolCallGroup({
}, [expandAll])
if (toolSections.length > 1) {
const runningCount = toolSections.filter((section) => section.state === 'input-available' || section.state === 'input-streaming').length
const errorCount = toolSections.filter((section) => section.state === 'output-error').length
const runningCount = toolSections.filter(
(section) =>
section.state === 'input-available' ||
section.state === 'input-streaming',
).length
const errorCount = toolSections.filter(
(section) => section.state === 'output-error',
).length
const doneCount = toolSections.length - runningCount - errorCount
const labels = Array.from(new Set(toolSections.map((section) => formatToolDisplayLabel(section.type, section.input))))
const labels = Array.from(
new Set(
toolSections.map((section) =>
formatToolDisplayLabel(section.type, section.input),
),
),
)
const visibleLabels = labels.slice(0, 3).join(', ')
const overflowLabel = labels.length > 3 ? ` +${labels.length - 3} more` : ''
const statusLabel =
@@ -1635,7 +1686,9 @@ function ToolCallGroup({
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12px] text-primary-600 hover:bg-primary-50/70"
onClick={() => setOpen((value) => !value)}
>
<span className="font-mono font-semibold text-ink">Tool activity</span>
<span className="font-mono font-semibold text-ink">
Tool activity
</span>
<span className="rounded-full bg-primary-100 px-2 py-0.5 text-[10px] tabular-nums text-primary-600">
{toolSections.length} calls
</span>
@@ -1788,7 +1841,8 @@ function MessageItemComponent({
// Simulate streaming is only active while words are still being revealed
const displayWordCount = countWords(displayText)
const revealComplete = revealedWordCount >= displayWordCount && displayWordCount > 0
const revealComplete =
revealedWordCount >= displayWordCount && displayWordCount > 0
const effectiveIsStreaming =
remoteStreamingActive || (_simulateStreaming && !revealComplete)
const assistantDisplayText = effectiveIsStreaming ? revealedText : displayText
@@ -2422,7 +2476,10 @@ function MessageItemComponent({
{compactInlineRenderPlan.map((item, index) =>
item.kind === 'tools' ? (
<ToolCallGroup
key={item.sections.map((section) => section.key).join(':') || `tools-${index}`}
key={
item.sections.map((section) => section.key).join(':') ||
`tools-${index}`
}
toolSections={item.sections}
expandAll={expandAllToolSections}
isStreaming={effectiveIsStreaming}
@@ -2430,7 +2487,9 @@ function MessageItemComponent({
) : item.text.trim().length > 0 ? (
<div key={`text-${index}`} className="relative">
{extractStandaloneMarkdownFence(item.text) ? (
<MarkdownMessageCard content={extractStandaloneMarkdownFence(item.text)!} />
<MarkdownMessageCard
content={extractStandaloneMarkdownFence(item.text)!}
/>
) : (
<MessageContent
markdown
@@ -2453,6 +2512,23 @@ function MessageItemComponent({
<span className="text-pretty">{displayText}</span>
) : hasRevealedText ? (
<div className="relative">
{assistantCorruptionWarning ? (
<div
className="mb-3 rounded-xl border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(245, 158, 11, 0.45)',
background: 'rgba(245, 158, 11, 0.12)',
color: 'var(--chat-assistant-foreground)',
}}
>
<div className="font-semibold">
{assistantCorruptionWarning.label}
</div>
<div className="mt-1 opacity-80">
{assistantCorruptionWarning.detail}
</div>
</div>
) : null}
{standaloneMarkdownDocument ? (
<MarkdownMessageCard content={standaloneMarkdownDocument} />
) : (
@@ -2475,7 +2551,10 @@ function MessageItemComponent({
{isUser && isQueued && (
<span
className="self-end text-[10px]"
style={{ color: 'color-mix(in srgb, var(--chat-user-foreground) 60%, transparent)' }}
style={{
color:
'color-mix(in srgb, var(--chat-user-foreground) 60%, transparent)',
}}
>
Sent
</span>

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { remoteUrlMatches } from './update-system'
describe('update-system helpers', () => {
it('matches GitHub URL forms against expected repo aliases', () => {
expect(
remoteUrlMatches('https://github.com/outsourc-e/hermes-workspace.git', [
'outsourc-e/hermes-workspace',
]),
).toBe(true)
expect(
remoteUrlMatches('git@github.com:NousResearch/hermes-agent.git', [
'hermes-agent',
]),
).toBe(true)
expect(
remoteUrlMatches('https://github.com/example/other.git', [
'hermes-workspace',
]),
).toBe(false)
})
})

607
src/server/update-system.ts Normal file
View File

@@ -0,0 +1,607 @@
import { execFileSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
readFileSync,
realpathSync,
writeFileSync,
} from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
type ProductId = 'workspace' | 'agent'
type InstallKind = 'git' | 'desktop' | 'docker' | 'unknown'
type UpdateState = 'current' | 'available' | 'blocked' | 'unsupported' | 'error'
type ReleaseNoteSection = {
product: ProductId
label: string
from: string | null
to: string | null
commits: Array<string>
}
export type ProductUpdateStatus = {
id: ProductId
label: string
installKind: InstallKind
version: string
path: string | null
repoPath: string | null
branch: string | null
currentHead: string | null
latestHead: string | null
updateAvailable: boolean
canUpdate: boolean
state: UpdateState
reason: string | null
updateMode:
| 'git-ff'
| 'hermes-update'
| 'desktop-auto-updater'
| 'docker-manual'
| 'manual'
}
export type UpdateStatus = {
ok: true
checkedAt: number
products: {
workspace: ProductUpdateStatus
agent: ProductUpdateStatus
}
updateAvailable: boolean
pendingReleaseNotes: Array<ReleaseNoteSection>
}
export type ApplyUpdateResult = {
ok: boolean
product: ProductId
output: string
restartRequired: boolean
status: ProductUpdateStatus
releaseNotes: Array<ReleaseNoteSection>
error?: string
}
function pendingNotesPath(): string {
return join(process.cwd(), '.runtime', 'pending-update-release-notes.json')
}
function persistPendingReleaseNotes(sections: Array<ReleaseNoteSection>): void {
if (!sections.length) return
const path = pendingNotesPath()
mkdirSync(join(process.cwd(), '.runtime'), { recursive: true })
writeFileSync(
path,
`${JSON.stringify({ sections, updatedAt: Date.now() }, null, 2)}\n`,
)
}
function readPendingReleaseNotes(): Array<ReleaseNoteSection> {
try {
const raw = JSON.parse(readFileSync(pendingNotesPath(), 'utf8')) as {
sections?: Array<ReleaseNoteSection>
}
return Array.isArray(raw.sections) ? raw.sections : []
} catch {
return []
}
}
function exec(
command: string,
args: Array<string>,
options: { cwd?: string; timeout?: number; stdio?: 'pipe' | 'ignore' } = {},
): string | null {
try {
if (options.stdio === 'ignore') {
execFileSync(command, args, {
cwd: options.cwd ?? process.cwd(),
timeout: options.timeout ?? 8_000,
stdio: 'ignore',
})
return 'ok'
}
return (
execFileSync(command, args, {
cwd: options.cwd ?? process.cwd(),
encoding: 'utf8',
timeout: options.timeout ?? 8_000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim() || null
)
} catch {
return null
}
}
function execOrThrow(
command: string,
args: Array<string>,
options: { cwd?: string; timeout?: number } = {},
): string {
return execFileSync(command, args, {
cwd: options.cwd ?? process.cwd(),
encoding: 'utf8',
timeout: options.timeout ?? 300_000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
}
function git(args: Array<string>, cwd: string, timeout = 8_000): string | null {
return exec('git', args, { cwd, timeout })
}
function realGitRepoPath(path: string | null | undefined): string | null {
if (!path) return null
try {
const resolved = realpathSync(path)
return existsSync(join(resolved, '.git')) ? resolved : null
} catch {
return null
}
}
function pkgVersion(repoPath: string): string {
try {
const pkg = JSON.parse(
readFileSync(join(repoPath, 'package.json'), 'utf8'),
) as { version?: string }
return pkg.version ?? 'unknown'
} catch {
return 'unknown'
}
}
export function remoteUrlMatches(
url: string | null,
expected: Array<string>,
): boolean {
if (!url) return false
const normalized = url
.toLowerCase()
.replace(/^git@github\.com:/, 'github.com/')
.replace(/^https?:\/\//, '')
.replace(/\.git$/, '')
return expected.some((alias) =>
normalized.includes(alias.toLowerCase().replace(/\.git$/, '')),
)
}
function remoteHead(repoPath: string, remote = 'origin'): string | null {
const url = git(['remote', 'get-url', remote], repoPath)
if (!url) return null
const raw = exec('git', ['ls-remote', url, 'HEAD'], {
cwd: repoPath,
timeout: 10_000,
})
return raw?.split(/\s+/)[0] ?? null
}
function isDirty(repoPath: string): boolean {
return Boolean(git(['status', '--porcelain'], repoPath))
}
function canFastForward(repoPath: string, remoteRef: string): boolean {
return (
exec('git', ['merge-base', '--is-ancestor', 'HEAD', remoteRef], {
cwd: repoPath,
stdio: 'ignore',
}) !== null
)
}
function readCommits(
repoPath: string,
from: string | null,
to: string | null,
): Array<string> {
if (!from || !to || from === to) return []
return (
git(['log', '--pretty=format:%s (%h)', `${from}..${to}`], repoPath, 10_000)
?.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 12) ?? []
)
}
function workspaceInstallKind(): InstallKind {
if (
process.env.HERMES_WORKSPACE_DESKTOP === '1' ||
process.env.ELECTRON_RUN_AS_NODE
)
return 'desktop'
if (process.env.HERMES_WORKSPACE_DOCKER === '1' || existsSync('/.dockerenv'))
return 'docker'
return realGitRepoPath(process.cwd()) ? 'git' : 'unknown'
}
export function readWorkspaceUpdateStatus(
repoPath = process.cwd(),
): ProductUpdateStatus {
const installKind = workspaceInstallKind()
const gitRepo = realGitRepoPath(repoPath)
const version = gitRepo ? pkgVersion(gitRepo) : 'unknown'
if (installKind === 'desktop') {
return {
id: 'workspace',
label: 'Hermes Workspace',
installKind,
version,
path: repoPath,
repoPath: gitRepo,
branch: null,
currentHead: null,
latestHead: null,
updateAvailable: false,
canUpdate: false,
state: 'unsupported',
reason:
'Desktop auto-updater manifest is not wired yet. This path is reserved for DMG/EXE packaging.',
updateMode: 'desktop-auto-updater',
}
}
if (installKind === 'docker') {
return {
id: 'workspace',
label: 'Hermes Workspace',
installKind,
version,
path: repoPath,
repoPath: gitRepo,
branch: null,
currentHead: null,
latestHead: null,
updateAvailable: false,
canUpdate: false,
state: 'unsupported',
reason:
'Docker installs should update by pulling a newer image/tag, not by mutating the running container.',
updateMode: 'docker-manual',
}
}
if (!gitRepo) {
return {
id: 'workspace',
label: 'Hermes Workspace',
installKind: 'unknown',
version,
path: repoPath,
repoPath: null,
branch: null,
currentHead: null,
latestHead: null,
updateAvailable: false,
canUpdate: false,
state: 'unsupported',
reason: 'Workspace install type could not be detected.',
updateMode: 'manual',
}
}
const remoteUrl = git(['remote', 'get-url', 'origin'], gitRepo)
const repoMatches = remoteUrlMatches(remoteUrl, [
'hermes-workspace',
'outsourc-e/hermes-workspace',
])
if (repoMatches) git(['fetch', 'origin', '--quiet'], gitRepo, 30_000)
const currentHead = git(['rev-parse', 'HEAD'], gitRepo)
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], gitRepo)
const supportedBranch = branch === 'main' || branch === 'master'
const latestHead =
repoMatches && supportedBranch ? remoteHead(gitRepo, 'origin') : null
const dirty = isDirty(gitRepo)
const updateAvailable = Boolean(
supportedBranch && currentHead && latestHead && currentHead !== latestHead,
)
const remoteRef = `origin/${branch || 'main'}`
const ff = updateAvailable ? canFastForward(gitRepo, remoteRef) : true
const canUpdate = Boolean(
repoMatches && supportedBranch && updateAvailable && !dirty && ff,
)
return {
id: 'workspace',
label: 'Hermes Workspace',
installKind: 'git',
version,
path: repoPath,
repoPath: gitRepo,
branch,
currentHead,
latestHead,
updateAvailable,
canUpdate,
state: !repoMatches
? 'unsupported'
: !supportedBranch
? 'unsupported'
: dirty
? 'blocked'
: updateAvailable
? ff
? 'available'
: 'blocked'
: 'current',
reason: !repoMatches
? 'Workspace origin remote does not look like hermes-workspace.'
: !supportedBranch
? 'Workspace one-click updates are only enabled on main/master branches.'
: dirty
? 'Workspace checkout has local changes. Commit or stash before updating.'
: updateAvailable && !ff
? 'Workspace update is not a fast-forward. Manual rebase/merge required.'
: null,
updateMode: 'git-ff',
}
}
function agentRepoPath(): string | null {
const candidates = [
process.env.HERMES_AGENT_REPO,
join(homedir(), '.hermes', 'hermes-agent'),
join(homedir(), 'hermes-agent'),
]
for (const candidate of candidates) {
const repo = realGitRepoPath(candidate)
if (repo) return repo
}
return null
}
export function readAgentUpdateStatus(): ProductUpdateStatus {
const version =
exec('hermes', ['--version'], { timeout: 10_000 })?.split('\n')[0] ??
'unknown'
const path = exec('which', ['hermes'])
const repoPath = agentRepoPath()
if (!repoPath) {
return {
id: 'agent',
label: 'Hermes Agent',
installKind: 'unknown',
version,
path,
repoPath: null,
branch: null,
currentHead: null,
latestHead: null,
updateAvailable: false,
canUpdate: false,
state: 'unsupported',
reason:
'Hermes Agent git checkout was not found. Bundled desktop installs will update through the app updater.',
updateMode: 'manual',
}
}
const remoteUrl = git(['remote', 'get-url', 'origin'], repoPath)
const repoMatches = remoteUrlMatches(remoteUrl, [
'hermes-agent',
'outsourc-e/hermes-agent',
'NousResearch/hermes-agent',
])
if (repoMatches) git(['fetch', 'origin', '--quiet'], repoPath, 30_000)
const currentHead = git(['rev-parse', 'HEAD'], repoPath)
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)
const latestHead = repoMatches ? remoteHead(repoPath, 'origin') : null
const dirty = isDirty(repoPath)
const updateAvailable = Boolean(
currentHead && latestHead && currentHead !== latestHead,
)
const canUpdate = Boolean(repoMatches && updateAvailable && !dirty)
return {
id: 'agent',
label: 'Hermes Agent',
installKind: 'git',
version,
path,
repoPath,
branch,
currentHead,
latestHead,
updateAvailable,
canUpdate,
state: !repoMatches
? 'unsupported'
: dirty
? 'blocked'
: updateAvailable
? 'available'
: 'current',
reason: !repoMatches
? 'Hermes Agent origin remote does not look like hermes-agent.'
: dirty
? 'Hermes Agent checkout has local changes. Commit or stash before updating.'
: null,
updateMode: 'hermes-update',
}
}
export function readUpdateStatus(): UpdateStatus {
const workspace = readWorkspaceUpdateStatus()
const agent = readAgentUpdateStatus()
return {
ok: true,
checkedAt: Date.now(),
products: { workspace, agent },
updateAvailable: workspace.updateAvailable || agent.updateAvailable,
pendingReleaseNotes: readPendingReleaseNotes(),
}
}
export function applyWorkspaceUpdate(): ApplyUpdateResult {
const before = readWorkspaceUpdateStatus()
if (!before.canUpdate || !before.repoPath || !before.branch) {
return {
ok: false,
product: 'workspace',
output: '',
restartRequired: false,
status: before,
releaseNotes: [],
error: before.reason || 'Workspace update is not available.',
}
}
const output: Array<string> = []
output.push(
execOrThrow('git', ['fetch', 'origin'], {
cwd: before.repoPath,
timeout: 60_000,
}),
)
const remoteRef = `origin/${before.branch}`
if (!canFastForward(before.repoPath, remoteRef)) {
const status = readWorkspaceUpdateStatus()
return {
ok: false,
product: 'workspace',
output: output.filter(Boolean).join('\n'),
restartRequired: false,
status,
releaseNotes: [],
error: `${remoteRef} is not a fast-forward update.`,
}
}
output.push(
execOrThrow('git', ['merge', '--ff-only', remoteRef], {
cwd: before.repoPath,
timeout: 60_000,
}),
)
const after = readWorkspaceUpdateStatus()
const changedFiles =
before.currentHead && after.currentHead
? (git(
['diff', '--name-only', before.currentHead, after.currentHead],
before.repoPath,
10_000,
)
?.split('\n')
.filter(Boolean) ?? [])
: []
if (
changedFiles.some(
(file) => file === 'package.json' || file === 'pnpm-lock.yaml',
)
) {
output.push(
execOrThrow('pnpm', ['install', '--no-frozen-lockfile'], {
cwd: before.repoPath,
timeout: 180_000,
}),
)
}
if (
changedFiles.some(
(file) =>
file.startsWith('src/') ||
file === 'package.json' ||
file === 'pnpm-lock.yaml' ||
file.startsWith('vite') ||
file.startsWith('tsconfig'),
)
) {
output.push(
execOrThrow('pnpm', ['build'], {
cwd: before.repoPath,
timeout: 240_000,
}),
)
}
const releaseNotes = [
{
product: 'workspace' as const,
label: 'Hermes Workspace',
from: before.currentHead,
to: after.currentHead,
commits: readCommits(
before.repoPath,
before.currentHead,
after.currentHead,
),
},
]
persistPendingReleaseNotes(releaseNotes)
return {
ok: true,
product: 'workspace',
output: output.filter(Boolean).join('\n'),
restartRequired: before.currentHead !== after.currentHead,
status: after,
releaseNotes,
}
}
export function applyAgentUpdate(): ApplyUpdateResult {
const before = readAgentUpdateStatus()
if (!before.canUpdate || !before.repoPath) {
return {
ok: false,
product: 'agent',
output: '',
restartRequired: false,
status: before,
releaseNotes: [],
error: before.reason || 'Hermes Agent update is not available.',
}
}
const output: Array<string> = []
output.push(
execOrThrow('git', ['fetch', 'origin'], {
cwd: before.repoPath,
timeout: 60_000,
}),
)
const remoteRef = `origin/${before.branch || 'main'}`
if (!canFastForward(before.repoPath, remoteRef)) {
const status = readAgentUpdateStatus()
return {
ok: false,
product: 'agent',
output: output.filter(Boolean).join('\n'),
restartRequired: false,
status,
releaseNotes: [],
error: `${remoteRef} is not a fast-forward update.`,
}
}
output.push(
execOrThrow('git', ['merge', '--ff-only', remoteRef], {
cwd: before.repoPath,
timeout: 60_000,
}),
)
const after = readAgentUpdateStatus()
const releaseNotes = [
{
product: 'agent' as const,
label: 'Hermes Agent',
from: before.currentHead,
to: after.currentHead,
commits: readCommits(
before.repoPath,
before.currentHead,
after.currentHead,
),
},
]
persistPendingReleaseNotes(releaseNotes)
return {
ok: true,
product: 'agent',
output: output.filter(Boolean).join('\n'),
restartRequired: before.currentHead !== after.currentHead,
status: after,
releaseNotes,
}
}