From 8d1b7a1e01514335a321a62d464ec2f5f25186b1 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sun, 5 Apr 2026 09:11:44 -0700 Subject: [PATCH 1/3] feat: self-update checker with one-click update for WebUI + Agent Shows a blue banner when the webui or hermes-agent git repos are behind their upstream branches. One-click 'Update Now' button does stash, pull --ff-only, stash pop, then reloads the page. Backend (api/updates.py): - _check_repo(): git fetch + rev-list count with 15s timeout - check_for_updates(): 30-min server-side cache, thread-safe, skips Docker (no .git dir) - apply_update(): stash (if dirty), pull --ff-only, pop, invalidate cache Routes: - GET /api/updates/check -- returns cached {webui, agent} with behind count - POST /api/updates/apply -- {target: 'webui'|'agent'} Frontend: - Blue banner (matches reconnect-banner pattern) with 'Later' / 'Update Now' - Non-blocking boot check via fire-and-forget .then(), once per tab session - sessionStorage guards prevent re-checking and re-showing after dismiss Settings: - 'Check for updates' checkbox (default: on) -- when off, no git operations - Removed 'Default Workspace' dropdown to keep settings panel compact Performance: - Server cache: git fetch at most 2x/hour regardless of client count - sessionStorage: one check per browser tab session - _check_in_progress flag prevents concurrent fetch storms - Fire-and-forget: does NOT block the boot sequence Co-Authored-By: Claude Opus 4.6 (1M context) --- api/config.py | 3 +- api/routes.py | 16 +++++ api/updates.py | 153 ++++++++++++++++++++++++++++++++++++++++++++++ static/boot.js | 7 ++- static/index.html | 18 ++++-- static/panels.js | 3 + static/style.css | 7 +++ static/ui.js | 41 +++++++++++++ 8 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 api/updates.py diff --git a/api/config.py b/api/config.py index ae3a92e..6742652 100644 --- a/api/config.py +++ b/api/config.py @@ -674,6 +674,7 @@ _SETTINGS_DEFAULTS = { 'show_token_usage': False, # show input/output token badge below assistant messages 'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar 'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights + 'check_for_updates': True, # check if webui/agent repos are behind upstream 'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes) 'password_hash': None, # SHA-256 hash; None = auth disabled } @@ -694,7 +695,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'} _SETTINGS_ENUM_VALUES = { 'send_key': {'enter', 'ctrl+enter'}, } -_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights'} +_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights', 'check_for_updates'} def save_settings(settings: dict) -> dict: """Save settings to disk. Returns the merged settings. Ignores unknown keys.""" diff --git a/api/routes.py b/api/routes.py index 4c05c82..60b9195 100644 --- a/api/routes.py +++ b/api/routes.py @@ -227,6 +227,14 @@ def handle_get(handler, parsed) -> bool: info = git_info_for_workspace(Path(s.workspace)) return j(handler, {'git': info}) + if parsed.path == '/api/updates/check': + settings = load_settings() + if not settings.get('check_for_updates', True): + return j(handler, {'disabled': True}) + force = parse_qs(parsed.query).get('force', ['0'])[0] == '1' + from api.updates import check_for_updates + return j(handler, check_for_updates(force=force)) + if parsed.path == '/api/chat/stream/status': stream_id = parse_qs(parsed.query).get('stream_id', [''])[0] return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id}) @@ -600,6 +608,14 @@ def handle_post(handler, parsed) -> bool: if parsed.path == '/api/session/import': return _handle_session_import(handler, body) + # ── Self-update (POST) ── + if parsed.path == '/api/updates/apply': + target = body.get('target', '') + if target not in ('webui', 'agent'): + return bad(handler, 'target must be "webui" or "agent"') + from api.updates import apply_update + return j(handler, apply_update(target)) + # ── CLI session import (POST) ── if parsed.path == '/api/session/import_cli': return _handle_session_import_cli(handler, body) diff --git a/api/updates.py b/api/updates.py new file mode 100644 index 0000000..aa18fe3 --- /dev/null +++ b/api/updates.py @@ -0,0 +1,153 @@ +""" +Hermes Web UI -- Self-update checker. + +Checks if the webui and hermes-agent git repos are behind their upstream +branches. Results are cached server-side (30-min TTL) so git fetch runs +at most twice per hour regardless of client count. + +Skips repos that are not git checkouts (e.g. Docker baked images where +.git does not exist). +""" +import subprocess +import threading +import time +from pathlib import Path + +from api.config import REPO_ROOT + +# Lazy -- may be None if agent not found +try: + from api.config import _AGENT_DIR +except ImportError: + _AGENT_DIR = None + +_update_cache = {'webui': None, 'agent': None, 'checked_at': 0} +_cache_lock = threading.Lock() +_check_in_progress = False +CACHE_TTL = 1800 # 30 minutes + + +def _run_git(args, cwd, timeout=10): + """Run a git command and return (stdout, ok).""" + try: + r = subprocess.run( + ['git'] + args, cwd=str(cwd), capture_output=True, + text=True, timeout=timeout, + ) + return r.stdout.strip(), r.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return '', False + + +def _detect_default_branch(path): + """Detect the remote default branch (master or main).""" + out, ok = _run_git(['symbolic-ref', 'refs/remotes/origin/HEAD'], path) + if ok and out: + # refs/remotes/origin/master -> master + return out.split('/')[-1] + # Fallback: try master, then main + for branch in ('master', 'main'): + _, ok = _run_git(['rev-parse', '--verify', f'origin/{branch}'], path) + if ok: + return branch + return 'master' + + +def _check_repo(path, name): + """Check if a git repo is behind its upstream. Returns dict or None.""" + if path is None or not (path / '.git').exists(): + return None + + # Fetch latest from origin (network call, cached by TTL) + _, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15) + if not fetch_ok: + return {'name': name, 'behind': 0, 'error': 'fetch failed'} + + branch = _detect_default_branch(path) + + # Count commits behind + out, ok = _run_git(['rev-list', '--count', f'HEAD..origin/{branch}'], path) + behind = int(out) if ok and out.isdigit() else 0 + + # Get short SHAs for display + current, _ = _run_git(['rev-parse', '--short', 'HEAD'], path) + latest, _ = _run_git(['rev-parse', '--short', f'origin/{branch}'], path) + + return { + 'name': name, + 'behind': behind, + 'current_sha': current, + 'latest_sha': latest, + 'branch': branch, + } + + +def check_for_updates(force=False): + """Return cached update status for webui and agent repos.""" + global _check_in_progress + with _cache_lock: + if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL: + return dict(_update_cache) + if _check_in_progress: + return dict(_update_cache) # another thread is already checking + _check_in_progress = True + + try: + # Run checks outside the lock (network I/O) + webui_info = _check_repo(REPO_ROOT, 'webui') + agent_info = _check_repo(_AGENT_DIR, 'agent') + + with _cache_lock: + _update_cache['webui'] = webui_info + _update_cache['agent'] = agent_info + _update_cache['checked_at'] = time.time() + return dict(_update_cache) + finally: + _check_in_progress = False + + +def apply_update(target): + """Stash, pull --ff-only, pop for the given target repo.""" + if target == 'webui': + path = REPO_ROOT + elif target == 'agent': + path = _AGENT_DIR + else: + return {'ok': False, 'message': f'Unknown target: {target}'} + + if path is None or not (path / '.git').exists(): + return {'ok': False, 'message': 'Not a git repository'} + + branch = _detect_default_branch(path) + + # Check for dirty working tree + status_out, _ = _run_git(['status', '--porcelain'], path) + stashed = False + if status_out: + _, ok = _run_git(['stash'], path) + if not ok: + return {'ok': False, 'message': 'Failed to stash local changes'} + stashed = True + + # Pull with ff-only (no merge commits) + pull_out, pull_ok = _run_git(['pull', '--ff-only', 'origin', branch], path, timeout=30) + if not pull_ok: + if stashed: + _run_git(['stash', 'pop'], path) + return {'ok': False, 'message': f'Pull failed: {pull_out[:200]}'} + + # Pop stash if we stashed + if stashed: + _, pop_ok = _run_git(['stash', 'pop'], path) + if not pop_ok: + return { + 'ok': False, + 'message': 'Updated but stash pop failed -- manual merge needed', + 'stash_conflict': True, + } + + # Invalidate cache + with _cache_lock: + _update_cache['checked_at'] = 0 + + return {'ok': True, 'message': f'{target} updated successfully', 'target': target} diff --git a/static/boot.js b/static/boot.js index e59cb82..e5a8c24 100644 --- a/static/boot.js +++ b/static/boot.js @@ -308,7 +308,12 @@ document.querySelectorAll('.suggestion').forEach(btn=>{ (async()=>{ // Load send key preference - try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;} + let _bootSettings={}; + try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;} + // Non-blocking update check (fire-and-forget, once per tab session) + if(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed')){ + api('/api/updates/check').then(d=>{sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{}); + } // Fetch active profile try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';} // Update profile chip label immediately diff --git a/static/index.html b/static/index.html index 2b7595b..b848ca1 100644 --- a/static/index.html +++ b/static/index.html @@ -203,6 +203,13 @@
+
+ +
+ + +
+
⚠ A response may have been in progress when you last left. Reload messages?
@@ -319,10 +326,6 @@
-
- - -
+ Check for updates + +
Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.
+
Enter a new password to set or change it. Leave blank to keep current setting.
diff --git a/static/panels.js b/static/panels.js index 85d4eed..65b3293 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1022,6 +1022,8 @@ async function loadSettingsPanel(){ if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});} const syncCb=$('settingsSyncInsights'); if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});} + const updateCb=$('settingsCheckUpdates'); + if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});} // Password field: always blank (we don't send hash back) const pwField=$('settingsPassword'); if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});} @@ -1055,6 +1057,7 @@ async function saveSettings(andClose){ body.show_token_usage=showTokenUsage; body.show_cli_sessions=showCliSessions; body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked; + body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked; // Password: only act if the field has content; blank = leave auth unchanged if(pw && pw.trim()){ try{ diff --git a/static/style.css b/static/style.css index bae06d6..e305db8 100644 --- a/static/style.css +++ b/static/style.css @@ -145,6 +145,13 @@ .reconnect-banner.visible{display:flex;} .reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;} .reconnect-btn:hover{background:rgba(201,168,76,0.25);} + /* ── Update banner ── */ + .update-banner{display:none;background:var(--surface);border:1px solid rgba(124,185,255,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--blue);align-items:center;justify-content:space-between;gap:12px;} + .update-banner.visible{display:flex;} + .update-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.3);color:var(--blue);cursor:pointer;transition:background .15s;} + .update-btn:hover{background:rgba(124,185,255,0.2);} + .update-primary{background:rgba(124,185,255,0.2);border-color:rgba(124,185,255,0.5);} + .update-btn:disabled{opacity:0.5;cursor:not-allowed;} /* ── Approval card ── */ .approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;} .approval-card.visible{display:block;} diff --git a/static/ui.js b/static/ui.js index 0cb7b54..fe78f19 100644 --- a/static/ui.js +++ b/static/ui.js @@ -334,6 +334,47 @@ async function refreshSession() { showToast('Conversation refreshed'); } catch(e) { setStatus('Refresh failed: ' + e.message); } } +// ── Update banner ── +function _showUpdateBanner(data){ + const parts=[]; + if(data.webui&&data.webui.behind>0) parts.push(`WebUI: ${data.webui.behind} update${data.webui.behind>1?'s':''}`); + if(data.agent&&data.agent.behind>0) parts.push(`Agent: ${data.agent.behind} update${data.agent.behind>1?'s':''}`); + if(!parts.length)return; + const msg=$('updateMsg'); + if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available'; + const banner=$('updateBanner'); + if(banner) banner.classList.add('visible'); + window._updateData=data; +} +function dismissUpdate(){ + const b=$('updateBanner');if(b)b.classList.remove('visible'); + sessionStorage.setItem('hermes-update-dismissed','1'); +} +async function applyUpdates(){ + const btn=$('btnApplyUpdate'); + if(btn){btn.disabled=true;btn.textContent='Updating\u2026';} + const targets=[]; + if(window._updateData?.webui?.behind>0) targets.push('webui'); + if(window._updateData?.agent?.behind>0) targets.push('agent'); + try{ + for(const target of targets){ + const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})}); + if(!res.ok){ + showToast('Update failed ('+target+'): '+(res.message||'unknown error')); + if(btn){btn.disabled=false;btn.textContent='Update Now';} + return; + } + } + showToast('Updated! Reloading\u2026'); + sessionStorage.removeItem('hermes-update-checked'); + sessionStorage.removeItem('hermes-update-dismissed'); + setTimeout(()=>location.reload(),1500); + }catch(e){ + showToast('Update failed: '+e.message); + if(btn){btn.disabled=false;btn.textContent='Update Now';} + } +} + async function checkInflightOnBoot(sid) { const raw = localStorage.getItem(INFLIGHT_KEY); if (!raw) return; From beb56b1a8bc56fec4814ab0ed1cc299a73a84169 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sun, 5 Apr 2026 16:20:12 +0000 Subject: [PATCH 2/3] fix: apply_update concurrency lock, boot.js settings-fail guard, dead workspace code, test_updates URL param - api/updates.py: add _apply_lock to prevent concurrent stash/pull/pop - static/boot.js: set check_for_updates:false on settings fetch failure - static/panels.js: remove dead settingsWorkspace references (element removed from HTML) - api/routes.py + static/boot.js: add ?test_updates=1 URL param for testing banner without being behind on git (localhost-only simulate endpoint) --- api/routes.py | 10 +++++++++- api/updates.py | 11 +++++++++++ static/boot.js | 9 ++++++--- static/panels.js | 18 +----------------- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/api/routes.py b/api/routes.py index 60b9195..5fc7ffa 100644 --- a/api/routes.py +++ b/api/routes.py @@ -231,7 +231,15 @@ def handle_get(handler, parsed) -> bool: settings = load_settings() if not settings.get('check_for_updates', True): return j(handler, {'disabled': True}) - force = parse_qs(parsed.query).get('force', ['0'])[0] == '1' + qs = parse_qs(parsed.query) + force = qs.get('force', ['0'])[0] == '1' + # ?simulate=1 returns fake behind counts for UI testing (localhost only) + if qs.get('simulate', ['0'])[0] == '1' and handler.client_address[0] == '127.0.0.1': + return j(handler, { + 'webui': {'name': 'webui', 'behind': 3, 'current_sha': 'abc1234', 'latest_sha': 'def5678', 'branch': 'master'}, + 'agent': {'name': 'agent', 'behind': 1, 'current_sha': 'aaa0001', 'latest_sha': 'bbb0002', 'branch': 'master'}, + 'checked_at': 0, + }) from api.updates import check_for_updates return j(handler, check_for_updates(force=force)) diff --git a/api/updates.py b/api/updates.py index aa18fe3..f59da6e 100644 --- a/api/updates.py +++ b/api/updates.py @@ -24,6 +24,7 @@ except ImportError: _update_cache = {'webui': None, 'agent': None, 'checked_at': 0} _cache_lock = threading.Lock() _check_in_progress = False +_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo CACHE_TTL = 1800 # 30 minutes @@ -108,6 +109,16 @@ def check_for_updates(force=False): def apply_update(target): """Stash, pull --ff-only, pop for the given target repo.""" + if not _apply_lock.acquire(blocking=False): + return {'ok': False, 'message': 'Update already in progress'} + try: + return _apply_update_inner(target) + finally: + _apply_lock.release() + + +def _apply_update_inner(target): + """Inner implementation of apply_update, called under _apply_lock.""" if target == 'webui': path = REPO_ROOT elif target == 'agent': diff --git a/static/boot.js b/static/boot.js index e5a8c24..4e74ff8 100644 --- a/static/boot.js +++ b/static/boot.js @@ -309,10 +309,13 @@ document.querySelectorAll('.suggestion').forEach(btn=>{ (async()=>{ // Load send key preference let _bootSettings={}; - try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;} + try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;_bootSettings={check_for_updates:false};} // Non-blocking update check (fire-and-forget, once per tab session) - if(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed')){ - api('/api/updates/check').then(d=>{sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{}); + // ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards) + const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1'; + if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){ + const _checkUrl='/api/updates/check'+(_testUpdates?'?simulate=1':''); + api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{}); } // Fetch active profile try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';} diff --git a/static/panels.js b/static/panels.js index 65b3293..bb3ea9a 100644 --- a/static/panels.js +++ b/static/panels.js @@ -995,21 +995,6 @@ async function loadSettingsPanel(){ modelSel.value=settings.default_model||''; modelSel.addEventListener('change',_markSettingsDirty,{once:false}); } - // Populate workspace dropdown from /api/workspaces - const wsSel=$('settingsWorkspace'); - if(wsSel){ - wsSel.innerHTML=''; - try{ - const wsData=await api('/api/workspaces'); - for(const w of (wsData.workspaces||[])){ - const opt=document.createElement('option'); - opt.value=w.path;opt.textContent=w.name||w.path; - wsSel.appendChild(opt); - } - }catch(e){} - wsSel.value=settings.default_workspace||''; - wsSel.addEventListener('change',_markSettingsDirty,{once:false}); - } // Send key preference const sendKeySel=$('settingsSendKey'); if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});} @@ -1043,7 +1028,6 @@ async function loadSettingsPanel(){ async function saveSettings(andClose){ const model=($('settingsModel')||{}).value; - const workspace=($('settingsWorkspace')||{}).value; const sendKey=($('settingsSendKey')||{}).value; const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked; const showCliSessions=!!($('settingsShowCliSessions')||{}).checked; @@ -1051,7 +1035,7 @@ async function saveSettings(andClose){ const theme=($('settingsTheme')||{}).value||'dark'; const body={}; if(model) body.default_model=model; - if(workspace) body.default_workspace=workspace; + if(sendKey) body.send_key=sendKey; body.theme=theme; body.show_token_usage=showTokenUsage; From 27706367b7b7eade88f0715272d1a23dc361b5b2 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sun, 5 Apr 2026 09:27:27 -0700 Subject: [PATCH 3/3] docs: v0.36 release notes, version bump for self-update checker Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 34 +++++++++++++++++++++++++++++++++- SPRINTS.md | 4 ++-- static/index.html | 2 +- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4091cb..9c8ac50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ --- +## [v0.36] Self-Update Checker with One-Click Update +*April 5, 2026 | 433 tests* + +### Features +- **Update checker.** Non-blocking background check on boot detects when the + WebUI or hermes-agent git repos are behind upstream. Blue banner shows + "WebUI: N updates, Agent: N updates available" with Update Now / Later. +- **One-click update.** "Update Now" runs `git stash && git pull --ff-only && + git stash pop` on each behind repo, then reloads the page. Concurrent update + attempts blocked via lock. Dirty working trees safely stashed and restored. +- **Settings toggle.** "Check for updates" checkbox in Settings panel. Persisted + server-side. Disabled = no background fetch, no banner. +- **30-minute cache.** Git fetch runs at most twice per hour regardless of tab + count. Results cached server-side with TTL. +- **Session-scoped dismissal.** "Later" dismisses banner for the current tab + session (sessionStorage). New tabs get a fresh check. +- **Test mode.** `?test_updates=1` URL param shows the banner with fake data + (localhost only) for UI testing without needing to actually be behind. + +### Architecture +- New `api/updates.py`: `check_for_updates()`, `apply_update()`. Thread-safe + caching with `_cache_lock`. Concurrent apply blocked with `_apply_lock`. + Default branch auto-detected (master/main). +- `api/routes.py`: `GET /api/updates/check`, `POST /api/updates/apply`. + Simulate endpoint gated to 127.0.0.1. +- `static/ui.js`: `_showUpdateBanner()`, `dismissUpdate()`, `applyUpdates()`. +- `static/boot.js`: fire-and-forget check on boot (does not block UI). +- `api/config.py`: `check_for_updates` in settings defaults + bool keys. +- Docker safe: all git ops gated by `.git` directory existence check. + +--- + ## [v0.35.1] Model dropdown fixes *April 5, 2026 | 433 tests* @@ -1236,4 +1268,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel. --- -*Last updated: v0.34, April 5, 2026 | Tests: 433* +*Last updated: v0.36, April 5, 2026 | Tests: 433* diff --git a/SPRINTS.md b/SPRINTS.md index 5f37bcd..fa38251 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,6 @@ # Hermes Web UI -- Forward Sprint Plan -> Current state: v0.35 | 433 tests | Daily driver ready +> Current state: v0.36 | 433 tests | Daily driver ready > This document plans the path from here to two targets: > > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the @@ -1156,6 +1156,6 @@ New test cases in `tests/test_sprint26.py`: --- *Last updated: April 5, 2026* -*Current version: v0.35 | 433 tests* +*Current version: v0.36 | 433 tests* *Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)* *Horizon sprint: Sprint 25 (macOS Desktop Application)* diff --git a/static/index.html b/static/index.html index b848ca1..6446861 100644 --- a/static/index.html +++ b/static/index.html @@ -14,7 +14,7 @@