From 9ecc331be8477b782cf733931d438b09c87555d4 Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Wed, 3 Jun 2026 09:36:09 +0700 Subject: [PATCH 01/52] feat(desktop): search sessions by id --- apps/desktop/src/app/chat/sidebar/index.tsx | 4 +- apps/desktop/src/lib/session-search.test.ts | 58 +++++++++++++++ apps/desktop/src/lib/session-search.ts | 19 +++++ hermes_cli/web_server.py | 50 ++++++++++--- hermes_state.py | 44 +++++++++++ .../test_web_server_session_search.py | 73 +++++++++++++++++++ tests/test_hermes_state.py | 54 ++++++++++++++ 7 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/lib/session-search.test.ts create mode 100644 apps/desktop/src/lib/session-search.ts create mode 100644 tests/hermes_cli/test_web_server_session_search.py diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index a8aa706e2e..dffb21ce77 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -35,6 +35,7 @@ import { } from '@/components/ui/sidebar' import { Skeleton } from '@/components/ui/skeleton' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' +import { sessionMatchesSearch } from '@/lib/session-search' import { cn } from '@/lib/utils' import { $panesFlipped, @@ -330,11 +331,10 @@ export function ChatSidebar({ return [] } - const needle = trimmedQuery.toLowerCase() const out = new Map() for (const s of sortedSessions) { - if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) { + if (sessionMatchesSearch(s, trimmedQuery)) { out.set(s.id, s) } } diff --git a/apps/desktop/src/lib/session-search.test.ts b/apps/desktop/src/lib/session-search.test.ts new file mode 100644 index 0000000000..aa40fe59c0 --- /dev/null +++ b/apps/desktop/src/lib/session-search.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/hermes' + +import { sessionMatchesSearch } from './session-search' + +function makeSession(overrides: Partial = {}): SessionInfo { + return { + archived: false, + cwd: '/home/user/projects/hermes-agent', + ended_at: null, + id: '20260603_090200_abcd12', + input_tokens: 0, + is_active: false, + last_active: 1_000, + message_count: 2, + model: 'claude', + output_tokens: 0, + preview: 'Fix Desktop session search', + source: 'cli', + started_at: 1_000, + title: 'Desktop Search Feature', + tool_call_count: 0, + ...overrides + } +} + +describe('sessionMatchesSearch', () => { + it('matches loaded sessions by full and partial session id', () => { + const session = makeSession() + + expect(sessionMatchesSearch(session, '20260603_090200_abcd12')).toBe(true) + expect(sessionMatchesSearch(session, '090200')).toBe(true) + expect(sessionMatchesSearch(session, 'ABCD12')).toBe(true) + }) + + it('matches projected compression sessions by lineage root id', () => { + const session = makeSession({ + _lineage_root_id: '20260602_235959_root99', + id: '20260603_010000_tip01' + }) + + expect(sessionMatchesSearch(session, 'root99')).toBe(true) + expect(sessionMatchesSearch(session, '20260602')).toBe(true) + }) + + it('preserves title, preview, and workspace matching', () => { + const session = makeSession() + + expect(sessionMatchesSearch(session, 'desktop search')).toBe(true) + expect(sessionMatchesSearch(session, 'session search')).toBe(true) + expect(sessionMatchesSearch(session, 'hermes-agent')).toBe(true) + }) + + it('does not match unrelated queries', () => { + expect(sessionMatchesSearch(makeSession(), 'totally-unrelated')).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/session-search.ts b/apps/desktop/src/lib/session-search.ts new file mode 100644 index 0000000000..b8ee6ebf30 --- /dev/null +++ b/apps/desktop/src/lib/session-search.ts @@ -0,0 +1,19 @@ +import type { SessionInfo } from '@/types/hermes' + +import { sessionTitle } from './chat-runtime' + +export function sessionMatchesSearch(session: SessionInfo, query: string): boolean { + const needle = query.trim().toLowerCase() + + if (!needle) { + return true + } + + return [ + session.id, + session._lineage_root_id ?? '', + sessionTitle(session), + session.preview ?? '', + session.cwd ?? '' + ].some(value => value.toLowerCase().includes(needle)) +} diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d25ca16434..b9afe17116 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1611,14 +1611,15 @@ async def get_sessions( @app.get("/api/sessions/search") async def search_sessions(q: str = "", limit: int = 20): - """Full-text search across session message content using FTS5. + """Search sessions by ID plus full-text message content using FTS5. - Results are deduped by compression lineage, not by raw ``session_id``. - Auto-compression rotates a conversation onto a fresh session id (and leaves - the old segment's messages in the FTS index), so one logical chat can own - many ``sessions`` rows that all match the same query. Branches also use - ``parent_session_id``, but they are real alternate conversations; don't - collapse branch-specific hits back into the parent. + Direct session-id matches are surfaced first, then FTS message-content + matches. Results are deduped by compression lineage, not by raw + ``session_id``. Auto-compression rotates a conversation onto a fresh + session id (and leaves the old segment's messages in the FTS index), so one + logical chat can own many ``sessions`` rows that all match the same query. + Branches also use ``parent_session_id``, but they are real alternate + conversations; don't collapse branch-specific hits back into the parent. """ if not q or not q.strip(): return {"results": []} @@ -1626,6 +1627,32 @@ async def search_sessions(q: str = "", limit: int = 20): from hermes_state import SessionDB db = SessionDB() try: + safe_limit = max(1, min(int(limit or 20), 100)) + seen: dict = {} + + def add_result(sid: str, payload: dict) -> None: + if sid and sid not in seen and len(seen) < safe_limit: + seen[sid] = payload + + # Direct ID matches first: users often paste a session id from CLI, + # logs, or another Hermes surface. FTS can't find those unless the + # id happens to appear in message text. + for row in db.search_sessions_by_id(q, limit=safe_limit, include_archived=True): + sid = row.get("id") + preview = (row.get("preview") or "").strip() + snippet = preview or f"Session ID: {sid}" + add_result( + sid, + { + "session_id": sid, + "snippet": snippet, + "role": None, + "source": row.get("source"), + "model": row.get("model"), + "session_started": row.get("started_at"), + }, + ) + # Auto-add prefix wildcards so partial words match # e.g. "nimb" → "nimb*" matches "nimby" # Preserve quoted phrases and existing wildcards as-is @@ -1639,7 +1666,7 @@ async def search_sessions(q: str = "", limit: int = 20): prefix_query = " ".join(terms) # Over-fetch so lineage dedup can still surface `limit` distinct # conversations even when several hits collapse onto one root. - fetch_limit = max(limit * 5, 50) + fetch_limit = max(safe_limit * 5, 50) matches = db.search_messages(query=prefix_query, limit=fetch_limit) # Walk parent_session_id to the compression root, memoized so a @@ -1713,12 +1740,15 @@ async def search_sessions(q: str = "", limit: int = 20): return tip # Keep the best (first / most relevant) hit per compression root. - seen: dict = {} + # `seen` already holds the direct ID matches collected above; the + # content matches extend it without clobbering them. for m in matches: raw_sid = m["session_id"] root = compression_root(raw_sid) if root in seen: continue + if len(seen) >= safe_limit: + break seen[root] = { "session_id": lineage_tip(root), "lineage_root": root, @@ -1728,8 +1758,6 @@ async def search_sessions(q: str = "", limit: int = 20): "model": m.get("model"), "session_started": m.get("session_started"), } - if len(seen) >= limit: - break return {"results": list(seen.values())} finally: db.close() diff --git a/hermes_state.py b/hermes_state.py index f08acdce29..8d2d89131c 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3007,6 +3007,50 @@ class SessionDB: return matches + def search_sessions_by_id( + self, + query: str, + limit: int = 20, + include_archived: bool = True, + ) -> List[Dict[str, Any]]: + """Search surfaced sessions by exact/prefix/substring session id. + + Desktop search uses this alongside FTS message search so users can paste + a session id from logs, CLI output, or another Hermes surface and jump + straight to that conversation. Matching also checks ``_lineage_root_id`` + for projected compression-chain tips, so an old root id still resolves to + the live continuation row. + """ + needle = (query or "").strip().lower() + if not needle or limit <= 0: + return [] + + scan_limit = max(limit, 10_000) + sessions = self.list_sessions_rich( + limit=scan_limit, + offset=0, + include_archived=include_archived, + order_by_last_active=True, + ) + + def score(row: Dict[str, Any]) -> int: + ids = [str(row.get("id") or ""), str(row.get("_lineage_root_id") or "")] + normalized = [value.lower() for value in ids if value] + if any(value == needle for value in normalized): + return 0 + if any(value.startswith(needle) for value in normalized): + return 1 + return 2 + + matches = [ + (score(row), idx, row) + for idx, row in enumerate(sessions) + if needle in str(row.get("id") or "").lower() + or needle in str(row.get("_lineage_root_id") or "").lower() + ] + matches.sort(key=lambda item: (item[0], item[1])) + return [row for _, _, row in matches[:limit]] + def search_sessions( self, source: str = None, diff --git a/tests/hermes_cli/test_web_server_session_search.py b/tests/hermes_cli/test_web_server_session_search.py new file mode 100644 index 0000000000..035f428624 --- /dev/null +++ b/tests/hermes_cli/test_web_server_session_search.py @@ -0,0 +1,73 @@ +import asyncio + +from hermes_cli import web_server + + +class _FakeSessionDB: + closed = False + + def search_sessions_by_id(self, query, limit=20, include_archived=True): + assert query == "20260603" + assert limit == 2 + assert include_archived is True + return [ + { + "id": "20260603_090200_exact", + "preview": "ID match preview", + "source": "cli", + "model": "claude", + "started_at": 100, + } + ] + + def search_messages(self, query, limit=20): + assert query == "20260603*" + assert limit == 2 + return [ + { + "session_id": "20260603_090200_exact", + "snippet": "duplicate content hit should not replace ID hit", + "role": "user", + "source": "cli", + "model": "claude", + "session_started": 100, + }, + { + "session_id": "content_session", + "snippet": "content hit", + "role": "assistant", + "source": "desktop", + "model": "gpt", + "session_started": 200, + }, + ] + + def close(self): + self.closed = True + + +def test_desktop_session_search_merges_id_matches_before_content_matches(monkeypatch): + monkeypatch.setattr("hermes_state.SessionDB", _FakeSessionDB) + + response = asyncio.run(web_server.search_sessions(q="20260603", limit=2)) + + assert response == { + "results": [ + { + "session_id": "20260603_090200_exact", + "snippet": "ID match preview", + "role": None, + "source": "cli", + "model": "claude", + "session_started": 100, + }, + { + "session_id": "content_session", + "snippet": "content hit", + "role": "assistant", + "source": "desktop", + "model": "gpt", + "session_started": 200, + }, + ] + } diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 572fd6489d..f5e4f69ae6 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -3786,3 +3786,57 @@ class TestSessionArchive: both = {s["id"] for s in db.list_sessions_rich(include_archived=True)} assert both == {"live", "hidden"} assert db.session_count(include_archived=True) == 2 + + + +class TestSessionIdSearch: + """Session id search backs Desktop's Search Sessions UX.""" + + def _seed(self, db, sid, *, content="ordinary message", archived=False): + db.create_session(session_id=sid, source="cli", model="test-model") + db.append_message(session_id=sid, role="user", content=content) + if archived: + db.set_session_archived(sid, True) + + def test_search_sessions_by_id_matches_exact_prefix_and_substring(self, db): + self._seed(db, "20260603_090200_abcd12", content="content without id") + self._seed(db, "20260602_111111_other99", content="other content") + + assert [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12")] == [ + "20260603_090200_abcd12" + ] + assert [s["id"] for s in db.search_sessions_by_id("20260603")] == ["20260603_090200_abcd12"] + assert [s["id"] for s in db.search_sessions_by_id("ABCD12")] == ["20260603_090200_abcd12"] + + def test_search_sessions_by_id_respects_limit_and_prioritizes_exact_matches(self, db): + self._seed(db, "20260603_090200_abcd12") + self._seed(db, "20260603_090200_abcd12_child") + self._seed(db, "x_20260603_090200_abcd12") + + ids = [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12", limit=2)] + + assert ids == ["20260603_090200_abcd12", "20260603_090200_abcd12_child"] + + def test_search_sessions_by_id_can_include_or_exclude_archived(self, db): + self._seed(db, "20260603_090200_live") + self._seed(db, "20260603_090200_archived", archived=True) + + included = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=True)} + excluded = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=False)} + + assert included == {"20260603_090200_live", "20260603_090200_archived"} + assert excluded == {"20260603_090200_live"} + + def test_search_sessions_by_id_matches_projected_lineage_root_id(self, db): + root = "20260602_235959_root99" + tip = "20260603_010000_tip01" + db.create_session(session_id=root, source="cli") + db.append_message(root, role="user", content="root conversation") + db.end_session(root, "compression") + db.create_session(session_id=tip, source="cli", parent_session_id=root) + db.append_message(tip, role="user", content="continued conversation") + + matches = db.search_sessions_by_id("root99") + + assert [s["id"] for s in matches] == [tip] + assert matches[0]["_lineage_root_id"] == root From 580d9240979cdb2cdfdba13216febbfb1157bca8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 06:05:22 -0700 Subject: [PATCH 02/52] perf(desktop): make session-id search SQL-bounded, not O(n) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_sessions_by_id previously fetched up to 10k sessions via list_sessions_rich and filtered them in Python — O(n) per keystroke. Push the id match into SQL instead. - list_sessions_rich gains an optional id_query param: a case-insensitive LIKE pushed into the outer WHERE, matched against each surfaced row's id AND every id in its forward compression chain (via the existing chain CTE). Searching a compression root id or a tip id both resolve to the same projected conversation. LIKE wildcards in the needle are escaped. - search_sessions_by_id now fetches only matching rows (limit*4) and ranks exact > prefix > substring in Python over that small set. - web_server /api/sessions/search: route ID matches and content matches through one lineage-keyed dedup helper so an id-hit and a content-hit on the same conversation collapse to a single result (the contributor's version keyed ID hits by raw sid and content hits by root, which could double-list a compression tip). - command-center haystack also matches _lineage_root_id for parity. E2E verified against a real DB: exact match over 3000+ sessions materializes 1 row in Python (was ~3000), 5ms; root-id resolves to tip; LIKE-wildcard escaping holds. Follow-up to @0xharryriddle's feat(desktop): search sessions by id. --- apps/desktop/src/app/command-center/index.tsx | 2 +- hermes_cli/web_server.py | 118 +++++++++--------- hermes_state.py | 65 +++++++--- .../test_web_server_session_search.py | 21 +++- 4 files changed, 133 insertions(+), 73 deletions(-) diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index f4420b3ed3..feb1e20a48 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -156,7 +156,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on } return sorted.filter(session => { - const haystack = `${sessionTitle(session)} ${session.id}`.toLowerCase() + const haystack = `${sessionTitle(session)} ${session.id} ${session._lineage_root_id ?? ''}`.toLowerCase() return haystack.includes(needle) }) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b9afe17116..fcd97cc76b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1628,46 +1628,6 @@ async def search_sessions(q: str = "", limit: int = 20): db = SessionDB() try: safe_limit = max(1, min(int(limit or 20), 100)) - seen: dict = {} - - def add_result(sid: str, payload: dict) -> None: - if sid and sid not in seen and len(seen) < safe_limit: - seen[sid] = payload - - # Direct ID matches first: users often paste a session id from CLI, - # logs, or another Hermes surface. FTS can't find those unless the - # id happens to appear in message text. - for row in db.search_sessions_by_id(q, limit=safe_limit, include_archived=True): - sid = row.get("id") - preview = (row.get("preview") or "").strip() - snippet = preview or f"Session ID: {sid}" - add_result( - sid, - { - "session_id": sid, - "snippet": snippet, - "role": None, - "source": row.get("source"), - "model": row.get("model"), - "session_started": row.get("started_at"), - }, - ) - - # Auto-add prefix wildcards so partial words match - # e.g. "nimb" → "nimb*" matches "nimby" - # Preserve quoted phrases and existing wildcards as-is - import re - terms = [] - for token in re.findall(r'"[^"]*"|\S+', q.strip()): - if token.startswith('"') or token.endswith("*"): - terms.append(token) - else: - terms.append(token + "*") - prefix_query = " ".join(terms) - # Over-fetch so lineage dedup can still surface `limit` distinct - # conversations even when several hits collapse onto one root. - fetch_limit = max(safe_limit * 5, 50) - matches = db.search_messages(query=prefix_query, limit=fetch_limit) # Walk parent_session_id to the compression root, memoized so a # chain of compression segments only costs one walk. We deliberately @@ -1739,25 +1699,71 @@ async def search_sessions(q: str = "", limit: int = 20): tip_cache[root_id] = tip return tip - # Keep the best (first / most relevant) hit per compression root. - # `seen` already holds the direct ID matches collected above; the - # content matches extend it without clobbering them. - for m in matches: - raw_sid = m["session_id"] + # Both ID matches and content matches share one keyspace, keyed by + # compression lineage root, so an id-hit and a content-hit on the + # same logical conversation collapse to a single result. The first + # hit for a lineage wins; ID matches run first and take priority. + seen: dict = {} + + def add_lineage_result(raw_sid: str, payload: dict) -> None: + if not raw_sid: + return root = compression_root(raw_sid) - if root in seen: - continue + if root in seen or len(seen) >= safe_limit: + return + payload = dict(payload) + payload["session_id"] = lineage_tip(root) + payload["lineage_root"] = root + seen[root] = payload + + # Direct ID matches first: users often paste a session id from CLI, + # logs, or another Hermes surface. FTS can't find those unless the + # id happens to appear in message text. search_sessions_by_id is + # SQL-bounded, so this stays cheap even with thousands of sessions. + for row in db.search_sessions_by_id(q, limit=safe_limit, include_archived=True): + sid = row.get("id") + preview = (row.get("preview") or "").strip() + snippet = preview or f"Session ID: {sid}" + add_lineage_result( + sid, + { + "snippet": snippet, + "role": None, + "source": row.get("source"), + "model": row.get("model"), + "session_started": row.get("started_at"), + }, + ) + + # Auto-add prefix wildcards so partial words match + # e.g. "nimb" → "nimb*" matches "nimby" + # Preserve quoted phrases and existing wildcards as-is + import re + terms = [] + for token in re.findall(r'"[^"]*"|\S+', q.strip()): + if token.startswith('"') or token.endswith("*"): + terms.append(token) + else: + terms.append(token + "*") + prefix_query = " ".join(terms) + # Over-fetch so lineage dedup can still surface `limit` distinct + # conversations even when several hits collapse onto one root. + fetch_limit = max(safe_limit * 5, 50) + matches = db.search_messages(query=prefix_query, limit=fetch_limit) + + for m in matches: if len(seen) >= safe_limit: break - seen[root] = { - "session_id": lineage_tip(root), - "lineage_root": root, - "snippet": m.get("snippet", ""), - "role": m.get("role"), - "source": m.get("source"), - "model": m.get("model"), - "session_started": m.get("session_started"), - } + add_lineage_result( + m["session_id"], + { + "snippet": m.get("snippet", ""), + "role": m.get("role"), + "source": m.get("source"), + "model": m.get("model"), + "session_started": m.get("session_started"), + }, + ) return {"results": list(seen.values())} finally: db.close() diff --git a/hermes_state.py b/hermes_state.py index 8d2d89131c..1a3a4ff4e5 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1565,6 +1565,7 @@ class SessionDB: order_by_last_active: bool = False, include_archived: bool = False, archived_only: bool = False, + id_query: str = None, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -1626,6 +1627,16 @@ class SessionDB: where_clauses.append("s.archived = 0") where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + + # Optional session-id filter, pushed into SQL so callers (Desktop + # session-id search) don't have to fetch every row and filter in + # Python. ``id_query`` is matched as a case-insensitive substring + # against each surfaced row's id AND every id in its forward + # compression chain — so searching a compression *root* id or a *tip* + # id both resolve to the same projected conversation. Only used in the + # order_by_last_active path (which builds the chain CTE); other callers + # pass id_query=None. + id_needle = (id_query or "").strip().lower() if order_by_last_active: # Compute effective_last_active by walking each surfaced session's # compression-continuation chain forward in SQL and taking the MAX @@ -1638,6 +1649,28 @@ class SessionDB: # compression-continuation edges using the same criteria as # get_compression_tip (parent.end_reason='compression' AND # child.started_at >= parent.ended_at). + outer_where = where_sql + id_params: List[Any] = [] + if id_needle: + # Admit a surfaced row if its own id or any id in its forward + # compression chain matches the needle. LIKE with a leading + # wildcard can't use an index, but the chain membership and + # the small result set keep this bounded — far cheaper than + # fetching every session and scanning in Python. + id_clause = ( + "EXISTS (SELECT 1 FROM chain cq" + " WHERE cq.root_id = s.id" + " AND LOWER(cq.cur_id) LIKE ? ESCAPE '\\')" + ) + like_pattern = ( + "%" + + id_needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + "%" + ) + id_params = [like_pattern] + outer_where = ( + f"{where_sql} AND {id_clause}" if where_sql else f"WHERE {id_clause}" + ) query = f""" WITH RECURSIVE chain(root_id, cur_id) AS ( SELECT s.id, s.id FROM sessions s {where_sql} @@ -1674,12 +1707,13 @@ class SessionDB: COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active FROM sessions s LEFT JOIN chain_max cm ON cm.root_id = s.id - {where_sql} + {outer_where} ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ? """ - # WHERE params apply twice (CTE seed + outer select). - params = params + params + [limit, offset] + # WHERE params apply twice (CTE seed + outer select); the id filter + # only applies to the outer select. + params = params + params + id_params + [limit, offset] else: query = f""" SELECT s.*, @@ -3025,12 +3059,18 @@ class SessionDB: if not needle or limit <= 0: return [] - scan_limit = max(limit, 10_000) - sessions = self.list_sessions_rich( - limit=scan_limit, + # SQL-bounded: list_sessions_rich pushes the id LIKE filter into the + # query (matching the row's own id AND any id in its forward + # compression chain), so we only materialize matching rows instead of + # scanning every session. Fetch a small multiple of `limit` so the + # in-Python exact/prefix/substring ranking below has enough candidates + # to order, then truncate. + candidates = self.list_sessions_rich( + limit=max(limit * 4, limit), offset=0, include_archived=include_archived, order_by_last_active=True, + id_query=needle, ) def score(row: Dict[str, Any]) -> int: @@ -3042,14 +3082,11 @@ class SessionDB: return 1 return 2 - matches = [ - (score(row), idx, row) - for idx, row in enumerate(sessions) - if needle in str(row.get("id") or "").lower() - or needle in str(row.get("_lineage_root_id") or "").lower() - ] - matches.sort(key=lambda item: (item[0], item[1])) - return [row for _, _, row in matches[:limit]] + ranked = sorted( + enumerate(candidates), + key=lambda item: (score(item[1]), item[0]), + ) + return [row for _, row in ranked[:limit]] def search_sessions( self, diff --git a/tests/hermes_cli/test_web_server_session_search.py b/tests/hermes_cli/test_web_server_session_search.py index 035f428624..e233e29bb7 100644 --- a/tests/hermes_cli/test_web_server_session_search.py +++ b/tests/hermes_cli/test_web_server_session_search.py @@ -4,11 +4,18 @@ from hermes_cli import web_server class _FakeSessionDB: + """Fake backing the /api/sessions/search endpoint. + + The endpoint surfaces direct session-id matches first, then FTS message + matches, deduping both by compression lineage root. This fake has no + compression chains (get_session returns no parent), so each session is its + own lineage root. + """ + closed = False def search_sessions_by_id(self, query, limit=20, include_archived=True): assert query == "20260603" - assert limit == 2 assert include_archived is True return [ { @@ -22,7 +29,6 @@ class _FakeSessionDB: def search_messages(self, query, limit=20): assert query == "20260603*" - assert limit == 2 return [ { "session_id": "20260603_090200_exact", @@ -42,6 +48,13 @@ class _FakeSessionDB: }, ] + def get_session(self, session_id): + # No compression chains in this fixture — every session is its own root. + return {"id": session_id, "parent_session_id": None} + + def get_compression_tip(self, session_id): + return session_id + def close(self): self.closed = True @@ -51,10 +64,13 @@ def test_desktop_session_search_merges_id_matches_before_content_matches(monkeyp response = asyncio.run(web_server.search_sessions(q="20260603", limit=2)) + # ID match surfaces first; the content hit on the SAME session is deduped + # by lineage root (not double-listed); the unrelated content hit follows. assert response == { "results": [ { "session_id": "20260603_090200_exact", + "lineage_root": "20260603_090200_exact", "snippet": "ID match preview", "role": None, "source": "cli", @@ -63,6 +79,7 @@ def test_desktop_session_search_merges_id_matches_before_content_matches(monkeyp }, { "session_id": "content_session", + "lineage_root": "content_session", "snippet": "content hit", "role": "assistant", "source": "desktop", From 2982122be7689bfdaf16254bf5adfcc4e1c64844 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 06:04:46 -0700 Subject: [PATCH 03/52] fix(gateway): deliver $HOME deliverables on root-run gateways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-run gateways have $HOME=/root, which is on the MEDIA system-path denylist, so the gateway silently dropped agent-generated deliverables under /root (e.g. /root/work/proposal.docx) — the user got a 'here is your file' reply with nothing attached. _path_under_denied_prefix now treats the running user's own home as deliverable: the home tree itself is no longer denied, while the more-specific denied paths inside it (~/.ssh, ~/.aws, ~/.hermes/.env, auth.json, config.yaml) stay blocked because they are separate denylist entries. The exception only matches when the denied prefix IS $HOME, so a non-root gateway still can't deliver another user's home. Diagnosis, reproduction, and the failing-case analysis are from @GodsBoy (#38108 / #38106). Implemented here as the minimal denylist fix rather than a staging/copy subsystem. Co-authored-by: GodsBoy --- gateway/platforms/base.py | 27 ++++++- tests/gateway/test_platform_base.py | 114 ++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 89806a7393..b8f7448515 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -967,14 +967,35 @@ def _media_delivery_denied_paths() -> List[Path]: def _path_under_denied_prefix(resolved: Path) -> bool: - """Return True if ``resolved`` lives under a deny-listed system path.""" + """Return True if ``resolved`` lives under a deny-listed system path. + + One narrow exception: when a denied prefix IS the running user's own home, + the home itself is not treated as denied. ``/root`` is on the system-path + denylist so that a non-root gateway can't deliver another user's home, but + on a root-run gateway ``$HOME=/root`` and the operator's own deliverables + (``/root/work/proposal.docx``) live directly under it. The credential + sub-directories inside home (``~/.ssh``, ``~/.aws``, ...) and Hermes + secrets (``~/.hermes/.env``, ``auth.json``) are *separate, more-specific* + denied paths, so they stay blocked regardless of this exception — it can + only un-block a plain file sitting in the running user's home tree, never a + credential location or another user's home. + """ + try: + home = Path(os.path.expanduser("~")).resolve(strict=False) + except (OSError, RuntimeError, ValueError): + home = None for denied in _media_delivery_denied_paths(): try: resolved_denied = denied.expanduser().resolve(strict=False) except (OSError, RuntimeError, ValueError): continue - if _path_is_within(resolved, resolved_denied) or resolved == resolved_denied: - return True + if not (_path_is_within(resolved, resolved_denied) or resolved == resolved_denied): + continue + # Allow the running user's own home tree; its credential sub-dirs are + # caught by their own (more-specific) denylist entries above. + if home is not None and resolved_denied == home: + continue + return True return False diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 10a924764a..3f8ecd9323 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -954,6 +954,120 @@ class TestMediaDeliveryDefaultMode: out = BasePlatformAdapter.filter_local_delivery_paths([str(notes)]) assert out == [str(notes.resolve())] + def test_root_home_deliverable_is_accepted(self, tmp_path, monkeypatch): + """The motivating bug (#38106): a root-run gateway has ``$HOME=/root``, + which is on the system-prefix denylist. A plain deliverable the agent + produced in its working dir (``/root/work/proposal.docx``) must still + deliver — the home itself is not a credential location. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "root" + workdir = fake_home / "work" + workdir.mkdir(parents=True) + doc = workdir / "proposal.docx" + doc.write_bytes(b"PK\x03\x04") + monkeypatch.setenv("HOME", str(fake_home)) + # $HOME is itself on the denied-prefix list, mirroring /root. + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(fake_home),), + ) + + assert ( + BasePlatformAdapter.validate_media_delivery_path(str(doc)) + == str(doc.resolve()) + ) + + def test_root_home_credential_subdir_still_blocked(self, tmp_path, monkeypatch): + """The $HOME exception must NOT un-block credential sub-dirs inside + home. ``/root/.ssh/id_rsa`` stays denied because ``~/.ssh`` is a + separate, more-specific denylist entry — even when $HOME is itself a + denied prefix. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "root" + ssh_dir = fake_home / ".ssh" + ssh_dir.mkdir(parents=True) + key = ssh_dir / "id_rsa" + key.write_bytes(b"-----BEGIN OPENSSH PRIVATE KEY-----") + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(fake_home),), + ) + + assert BasePlatformAdapter.validate_media_delivery_path(str(key)) is None + + def test_root_home_hermes_env_still_blocked(self, tmp_path, monkeypatch): + """``~/.hermes/.env`` stays blocked under the $HOME exception — it is a + more-specific denied path, not reachable just because home is allowed. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "root" + hermes_dir = fake_home / ".hermes" + hermes_dir.mkdir(parents=True) + env_file = hermes_dir / ".env" + env_file.write_text("OPENROUTER_API_KEY=sk-...") + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(fake_home),), + ) + monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_dir) + + assert BasePlatformAdapter.validate_media_delivery_path(str(env_file)) is None + + def test_other_users_home_still_blocked_for_nonroot(self, tmp_path, monkeypatch): + """The exception only un-blocks the *running user's own* home. A + non-root gateway ($HOME=/home/me) must not deliver another user's home + (``/root/...``) — that prefix stays denied because it isn't $HOME. + """ + self._patch_roots(monkeypatch) + + my_home = tmp_path / "home" / "me" + my_home.mkdir(parents=True) + other_home = tmp_path / "root" + other_home.mkdir() + other_file = other_home / "secret.docx" + other_file.write_bytes(b"PK\x03\x04") + monkeypatch.setenv("HOME", str(my_home)) + # Both my home and the other home are denied prefixes; only my home is + # the running user's $HOME, so the other home must stay blocked. + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(my_home), str(other_home)), + ) + + assert ( + BasePlatformAdapter.validate_media_delivery_path(str(other_file)) is None + ) + + def test_root_home_workdir_symlink_to_credential_blocked(self, tmp_path, monkeypatch): + """A symlink in the workdir pointing at a credential is rejected on its + resolved target, even under the $HOME exception. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "root" + ssh_dir = fake_home / ".ssh" + ssh_dir.mkdir(parents=True) + key = ssh_dir / "id_rsa" + key.write_bytes(b"-----BEGIN OPENSSH PRIVATE KEY-----") + workdir = fake_home / "work" + workdir.mkdir() + link = workdir / "innocent.pdf" + link.symlink_to(key) + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(fake_home),), + ) + + assert BasePlatformAdapter.validate_media_delivery_path(str(link)) is None + # --------------------------------------------------------------------------- # should_send_media_as_audio From 693f4c7e9ce583ed740daaa5aa8eb738390c643b Mon Sep 17 00:00:00 2001 From: CryptoByz Date: Thu, 4 Jun 2026 06:03:49 -0700 Subject: [PATCH 04/52] fix(gateway): clear zombie agent slot when session_reset races in-flight run A session_reset (/new, /cc) that bumps the run generation while an agent turn is in flight left the dead agent in the _running_agents slot: the in-flight run's own release is generation-guarded and correctly returns False, and the outer finally's sentinel-only check also missed the leftover real agent. The session then silently dropped every subsequent message as 'agent busy' until a full gateway restart. (#28686) - _process_message_or_command outer finally now calls the unconditional, idempotent _release_running_agent_state(key) on all exit paths instead of the sentinel-vs-else branch that could strand a dead agent. - _handle_reset_command evicts the slot right after bumping the generation, so the zombie is cleared at reset time regardless of how the in-flight run unwinds. Co-authored-by: CryptoByz --- gateway/run.py | 26 ++++++----- tests/gateway/test_session_state_cleanup.py | 51 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 049b07a80b..6d2f659876 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8524,18 +8524,14 @@ class GatewayRunner: logger.debug("goal continuation hook failed: %s", _goal_exc) return _agent_result finally: - # If _run_agent replaced the sentinel with a real agent and - # then cleaned it up, this is a no-op. If we exited early - # (exception, command fallthrough, etc.) the sentinel must - # not linger or the session would be permanently locked out. - if self._running_agents.get(_quick_key) is _AGENT_PENDING_SENTINEL: - self._release_running_agent_state(_quick_key) - else: - # Agent path already cleaned _running_agents; make sure - # the paired metadata dicts are gone too. - self._running_agents_ts.pop(_quick_key, None) - if hasattr(self, "_busy_ack_ts"): - self._busy_ack_ts.pop(_quick_key, None) + # Unconditional release covers every exit path. _release_running_agent_state + # is idempotent (pop-on-absent is harmless) and, called without a + # run_generation guard, always clears the slot regardless of which + # generation it holds. This evicts the zombie left when session_reset + # bumps the generation (N -> N+1) mid-flight: gen-N's guarded release + # inside _run_agent returns False, and the old sentinel-only check here + # missed the leftover real agent — locking the session out forever (#28686). + self._release_running_agent_state(_quick_key) async def _prepare_inbound_message_text( self, @@ -10032,6 +10028,12 @@ class GatewayRunner: # Get existing session key session_key = self._session_key_for_source(source) self._invalidate_session_run_generation(session_key, reason="session_reset") + # Evict the running-agent slot now that the generation is bumped. The + # in-flight run's own guarded release (run_generation=old) will return + # False and leave its dead agent behind; clearing here keeps the slot + # from becoming a zombie that silently drops all later messages (#28686). + # Idempotent, so the run's finally calling it again is harmless. + self._release_running_agent_state(session_key) # Snapshot the old entry so on_session_finalize can report the # expiring session id before reset_session() rotates it. diff --git a/tests/gateway/test_session_state_cleanup.py b/tests/gateway/test_session_state_cleanup.py index ffbb465b7a..dfde65eb38 100644 --- a/tests/gateway/test_session_state_cleanup.py +++ b/tests/gateway/test_session_state_cleanup.py @@ -228,3 +228,54 @@ class TestSessionDbCloseOnShutdown: flaky_db.close.assert_called_once() healthy_db.close.assert_called_once() + + +class TestSessionResetZombieRace: + """Regression for #28686 — a session_reset racing the in-flight run's + guarded release must not leave a dead agent locking the slot forever. + """ + + def test_generation_guard_blocks_then_unconditional_release_evicts(self): + runner = _make_runner() + runner._session_run_generation = {} + key = "agent:main:telegram:private:1" + + gen_n = runner._begin_session_run_generation(key) + dead_agent = MagicMock() + runner._running_agents[key] = dead_agent + runner._running_agents_ts[key] = 1.0 + runner._busy_ack_ts[key] = 1.0 + + # session_reset bumps the generation while gen-N is still in flight. + runner._invalidate_session_run_generation(key, reason="session_reset") + + # gen-N's own guarded release is correctly blocked — slot would be a + # zombie if nothing else cleared it (the pre-fix behaviour). + assert runner._release_running_agent_state(key, run_generation=gen_n) is False + assert runner._running_agents.get(key) is dead_agent + + # The fix: unconditional release (no run_generation) always clears it. + assert runner._release_running_agent_state(key) is True + assert key not in runner._running_agents + assert key not in runner._running_agents_ts + assert key not in runner._busy_ack_ts + + def test_normal_completion_is_not_evicted_by_outer_release(self): + """Guarded release with the current generation succeeds; the outer + unconditional release that follows is a harmless no-op. + """ + runner = _make_runner() + runner._session_run_generation = {} + key = "agent:main:telegram:private:2" + + gen = runner._begin_session_run_generation(key) + runner._running_agents[key] = MagicMock() + runner._running_agents_ts[key] = 1.0 + runner._busy_ack_ts[key] = 1.0 + + assert runner._release_running_agent_state(key, run_generation=gen) is True + assert key not in runner._running_agents + # Outer finally runs the unconditional release after — nothing stranded. + assert runner._release_running_agent_state(key) is True + assert key not in runner._running_agents_ts + assert key not in runner._busy_ack_ts From 30412a9771cc81861c58a94fdb64fc49036ef307 Mon Sep 17 00:00:00 2001 From: kyssta-exe Date: Wed, 3 Jun 2026 04:10:57 +0000 Subject: [PATCH 05/52] fix(cron): re-validate stale cron-output entries before deletion (#37721) quick() and dry_run() previously trusted the stored category from tracked.json without re-validating at delete time. Stale entries from before #34840 could carry category="cron-output" for cron control-plane paths (e.g. cron/jobs.json), causing quick() to delete the live scheduler registry. Fix: - Fix guess_category() to only classify cron/output/** as cron-output (was classifying ALL cron/* paths, missing the #34840 fix). - Re-validate cron-output entries via guess_category() at delete time in quick() and dry_run(); stale entries that are no longer classified as cron-output are skipped and removed from tracked.json. - Add _is_protected_cron_path() as a hard defense-in-depth guard that blocks deletion of cron/cronjobs directories and known control-plane files (jobs.json, .tick.lock) regardless of stored category. - Update test_cron_subtree_categorised to match fixed guess_category (only cron/output/* is cron-output, not all of cron/). Tests: add 5 regression tests in TestStaleCronEntryMigration. --- plugins/disk-cleanup/disk_cleanup.py | 57 ++++++++++ tests/plugins/test_disk_cleanup_plugin.py | 129 ++++++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/plugins/disk-cleanup/disk_cleanup.py b/plugins/disk-cleanup/disk_cleanup.py index 8d984273e7..fddb62dacb 100755 --- a/plugins/disk-cleanup/disk_cleanup.py +++ b/plugins/disk-cleanup/disk_cleanup.py @@ -145,6 +145,33 @@ ALLOWED_CATEGORIES = { } +# Paths under $HERMES_HOME that must NEVER be deleted by quick(), +# regardless of what the stored category says. This is a defense-in-depth +# guard against stale tracked.json entries from before #34840. +_PROTECTED_CRON_PATHS: set[str] = set() + + +def _is_protected_cron_path(p: Path) -> bool: + """Return True if *p* is a cron control-plane file/directory that must + never be deleted. + + This only matches the directory itself and known control-plane files + (``jobs.json``, ``.tick.lock``) — it does NOT blanket-protect + everything under ``cron/`` because ``cron/output/`` is disposable. + """ + # Lazily build the set once per process so HERMES_HOME is resolved + # exactly once. + if not _PROTECTED_CRON_PATHS: + hermes_home = get_hermes_home() + for parent in ("cron", "cronjobs"): + base = hermes_home / parent + _PROTECTED_CRON_PATHS.add(str(base)) + _PROTECTED_CRON_PATHS.add(str(base / "jobs.json")) + _PROTECTED_CRON_PATHS.add(str(base / ".tick.lock")) + resolved = str(p.resolve()) + return resolved in _PROTECTED_CRON_PATHS + + def fmt_size(n: float) -> str: for unit in ("B", "KB", "MB", "GB", "TB"): if n < 1024: @@ -226,6 +253,14 @@ def dry_run() -> Tuple[List[Dict], List[Dict]]: cat = item["category"] size = item["size"] + # Re-validate stale "cron-output" entries (fixes #37721). + if cat == "cron-output": + re_cat = guess_category(p) + if re_cat != "cron-output": + # Stale entry — would be skipped by quick(); omit from + # dry-run output too. + continue + if cat == "test": auto.append(item) elif cat == "temp" and age > 7: @@ -269,6 +304,28 @@ def quick() -> Dict[str, Any]: age = (now - datetime.fromisoformat(item["timestamp"])).days + # ---- stale-state migration (fixes #37721) ---- + # Old tracked.json entries may carry a "cron-output" category for + # paths that are NOT under cron/output/ (e.g. cron/jobs.json). + # guess_category() was fixed in #34840, but existing entries are + # never re-validated. Re-classify here so stale entries for cron + # control-plane state are not deleted. + if cat == "cron-output": + re_cat = guess_category(p) + if re_cat != "cron-output": + _log( + f"SKIP stale cron-output entry: {p} " + f"(re-classified as {re_cat!r})" + ) + # Drop the stale entry — it was misclassified. + continue + + # Hard safety net: never delete cron control-plane state even if + # the category somehow slipped through re-validation above. + if _is_protected_cron_path(p): + _log(f"SKIP protected cron path: {p}") + continue + should_delete = ( cat == "test" or (cat == "temp" and age > 7) diff --git a/tests/plugins/test_disk_cleanup_plugin.py b/tests/plugins/test_disk_cleanup_plugin.py index 4f7f66e028..783644d388 100644 --- a/tests/plugins/test_disk_cleanup_plugin.py +++ b/tests/plugins/test_disk_cleanup_plugin.py @@ -170,6 +170,135 @@ class TestGuessCategory: assert dg.guess_category(p) is None +class TestStaleCronEntryMigration: + """Regression tests for #37721 — stale cron-output entries in tracked.json.""" + + def test_quick_skips_stale_cron_output_for_jobs_json(self, _isolate_env): + """A stale tracked.json entry with category="cron-output" for + cron/jobs.json must NOT be deleted by quick(). + + This is the exact scenario from #37721: an old tracked.json has + {"path": ".../cron/jobs.json", "category": "cron-output"} which + would pass the delete filter but must be skipped because + guess_category() now returns None for non-output cron paths. + """ + dg = _load_lib() + cron_dir = _isolate_env / "cron" + cron_dir.mkdir() + jobs_json = cron_dir / "jobs.json" + jobs_json.write_text('{"jobs": []}') + + # Simulate a stale tracked.json entry from before #34840 by + # directly writing the tracked file (track() would reject it). + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" + tracked_file.parent.mkdir(parents=True, exist_ok=True) + tracked_file.write_text(json.dumps([{ + "path": str(jobs_json), + "category": "cron-output", + "timestamp": "2025-01-01T00:00:00+00:00", # very old + "size": 123, + }])) + + summary = dg.quick() + assert summary["deleted"] == 0, "cron/jobs.json must not be deleted" + assert jobs_json.exists(), "jobs.json must still exist" + # The stale entry should have been dropped from tracking. + remaining = json.loads(tracked_file.read_text()) + assert len(remaining) == 0 + + def test_quick_skips_stale_cron_output_for_cron_dir(self, _isolate_env): + """Stale entry for the cron/ directory itself must not be deleted.""" + dg = _load_lib() + cron_dir = _isolate_env / "cron" + cron_dir.mkdir() + output_dir = cron_dir / "output" + output_dir.mkdir() + (output_dir / "run.md").write_text("x") + + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" + tracked_file.parent.mkdir(parents=True, exist_ok=True) + tracked_file.write_text(json.dumps([{ + "path": str(cron_dir), + "category": "cron-output", + "timestamp": "2025-01-01T00:00:00+00:00", + "size": 0, + }])) + + summary = dg.quick() + assert summary["deleted"] == 0, "cron/ dir must not be deleted" + assert cron_dir.exists() + + def test_quick_skips_protected_cron_paths_defense_in_depth(self, _isolate_env): + """Defense-in-depth: even if guess_category returned cron-output + (hypothetically), protected cron paths are never deleted.""" + dg = _load_lib() + cron_dir = _isolate_env / "cron" + cron_dir.mkdir() + tick_lock = cron_dir / ".tick.lock" + tick_lock.write_text("") + + # Manually inject a stale entry with "test" category (would normally + # be auto-deleted) — the protected path guard must still block it. + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" + tracked_file.parent.mkdir(parents=True, exist_ok=True) + tracked_file.write_text(json.dumps([{ + "path": str(tick_lock), + "category": "test", + "timestamp": "2025-01-01T00:00:00+00:00", + "size": 0, + }])) + + summary = dg.quick() + assert summary["deleted"] == 0, ".tick.lock must not be deleted" + assert tick_lock.exists() + + def test_dry_run_omits_stale_cron_output(self, _isolate_env): + """dry_run() should also skip stale cron-output entries.""" + dg = _load_lib() + cron_dir = _isolate_env / "cron" + cron_dir.mkdir() + jobs_json = cron_dir / "jobs.json" + jobs_json.write_text("[]") + + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" + tracked_file.parent.mkdir(parents=True, exist_ok=True) + tracked_file.write_text(json.dumps([{ + "path": str(jobs_json), + "category": "cron-output", + "timestamp": "2025-01-01T00:00:00+00:00", + "size": 123, + }])) + + auto, prompt = dg.dry_run() + assert len(auto) == 0, "stale cron-output for jobs.json must not appear" + assert len(prompt) == 0 + + def test_legitimate_cron_output_still_deleted(self, _isolate_env): + """A valid cron-output entry under cron/output/ must still be deleted.""" + dg = _load_lib() + output_dir = _isolate_env / "cron" / "output" / "job_1" + output_dir.mkdir(parents=True) + run_md = output_dir / "run.md" + run_md.write_text("x") + + # Old enough to be deleted (>14 days) + from datetime import datetime, timezone, timedelta + old_ts = (datetime.now(timezone.utc) - timedelta(days=20)).isoformat() + + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" + tracked_file.parent.mkdir(parents=True, exist_ok=True) + tracked_file.write_text(json.dumps([{ + "path": str(run_md), + "category": "cron-output", + "timestamp": old_ts, + "size": 10, + }])) + + summary = dg.quick() + assert summary["deleted"] == 1, "valid old cron-output should be deleted" + assert not run_md.exists() + + class TestTrackForgetQuick: def test_track_then_quick_deletes_test(self, _isolate_env): dg = _load_lib() From 98903d03139d4af4c5b612c92906e3429ad1c0f2 Mon Sep 17 00:00:00 2001 From: rexdotsh <65942753+rexdotsh@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:44:03 +0530 Subject: [PATCH 06/52] fix(tui): reuse live session on resume --- tests/tui_gateway/test_protocol.py | 102 ++++++++++++++ tui_gateway/server.py | 185 +++++++++++++++++--------- ui-tui/src/app/useSessionLifecycle.ts | 61 +++++---- ui-tui/src/gatewayTypes.ts | 4 + 4 files changed, 261 insertions(+), 91 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 2c20e77a12..96c49dac0c 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -394,6 +394,108 @@ def test_session_resume_handles_multimodal_list_content(server, monkeypatch): ] +def test_session_resume_reuses_existing_live_session(server, monkeypatch): + """Repeated resume must not allocate duplicate live agents.""" + + target = "20260409_010101_abc123" + created_sids: list[str] = [] + first_agent_started = threading.Event() + agent_can_finish = threading.Event() + + class _DB: + def get_session(self, _sid): + return {"id": target} + + def get_session_by_title(self, _title): + return None + + def reopen_session(self, _sid): + return None + + def get_messages_as_conversation(self, _sid, include_ancestors=False): + return [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "yo"}, + ] + + class _Worker: + def close(self): + pass + + def make_agent(sid, key, session_id=None): + created_sids.append(sid) + first_agent_started.set() + assert agent_can_finish.wait(timeout=1) + return types.SimpleNamespace(model="test/model", session_id=session_id or key) + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr(server, "_make_agent", make_agent) + monkeypatch.setattr(server, "_SlashWorker", lambda _key, _model: _Worker()) + monkeypatch.setattr( + server, + "_start_notification_poller", + lambda _sid, _session: threading.Event(), + ) + monkeypatch.setattr(server, "_notify_session_boundary", lambda *_args, **_kwargs: None) + monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) + monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + server, + "_session_info", + lambda _agent, _session=None: {"model": "test/model"}, + ) + + fake_approval = types.SimpleNamespace( + load_permanent_allowlist=lambda: None, + register_gateway_notify=lambda *_args, **_kwargs: None, + ) + + with patch.dict(sys.modules, {"tools.approval": fake_approval}): + first_holder = {} + + def resume_first(): + first_holder["resp"] = server.handle_request( + { + "id": "first", + "method": "session.resume", + "params": {"session_id": target, "cols": 100}, + } + ) + + first_thread = threading.Thread(target=resume_first) + first_thread.start() + assert first_agent_started.wait(timeout=1) + + second_holder = {} + + def resume_second(): + second_holder["resp"] = server.handle_request( + { + "id": "second", + "method": "session.resume", + "params": {"session_id": target, "cols": 120}, + } + ) + + second_thread = threading.Thread(target=resume_second) + second_thread.start() + agent_can_finish.set() + + first_thread.join(timeout=1) + second_thread.join(timeout=1) + assert not first_thread.is_alive() + assert not second_thread.is_alive() + first = first_holder["resp"] + second = second_holder["resp"] + + assert "error" not in first + assert "error" not in second + assert second["result"]["session_id"] == first["result"]["session_id"] + assert len(server._sessions) == 1 + assert [s.get("session_key") for s in server._sessions.values()].count(target) == 1 + assert created_sids == [first["result"]["session_id"]] + + def test_make_agent_accepts_list_system_prompt(server, monkeypatch): captured = {} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 338218cd8f..40695facbd 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -128,6 +128,7 @@ _cfg_lock = threading.Lock() _cfg_cache: dict | None = None _cfg_mtime: float | None = None _cfg_path = None +_session_resume_lock = threading.Lock() try: _slash_timeout = float(os.environ.get("HERMES_TUI_SLASH_TIMEOUT_S") or "45") except (ValueError, TypeError): @@ -2979,6 +2980,10 @@ def _(rid, params: dict) -> dict: target = params.get("session_id", "") if not target: return _err(rid, 4006, "session_id required") + try: + cols = int(params.get("cols", 80)) + except (TypeError, ValueError): + cols = 80 db = _get_db() if db is None: return _db_unavailable_error(rid, code=5000) @@ -2989,33 +2994,55 @@ def _(rid, params: dict) -> dict: target = found["id"] else: return _err(rid, 4007, "session not found") - sid = uuid.uuid4().hex[:8] - _enable_gateway_prompts() - try: - db.reopen_session(target) - history = db.get_messages_as_conversation(target) - display_history = db.get_messages_as_conversation( - target, include_ancestors=True - ) - messages = _history_to_messages(display_history) - tokens = _set_session_context(target) + with _session_resume_lock: + live = _find_live_session_by_key(target) + if live is not None: + sid, session = live + payload = _live_session_payload( + sid, + session, + cols=cols, + touch=True, + transport=current_transport() or _stdio_transport, + ) + payload["resumed"] = target + return _ok(rid, payload) + + sid = uuid.uuid4().hex[:8] + _enable_gateway_prompts() try: - agent = _make_agent(sid, target, session_id=target) - finally: - _clear_session_context(tokens) - _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) - except Exception as e: - return _err(rid, 5000, f"resume failed: {e}") - return _ok( - rid, - { - "session_id": sid, - "resumed": target, - "message_count": len(messages), - "messages": messages, - "info": _session_info(agent, _sessions.get(sid)), - }, - ) + db.reopen_session(target) + history = db.get_messages_as_conversation(target) + display_history = db.get_messages_as_conversation( + target, include_ancestors=True + ) + messages = _history_to_messages(display_history) + tokens = _set_session_context(target) + try: + agent = _make_agent(sid, target, session_id=target) + finally: + _clear_session_context(tokens) + _init_session(sid, target, agent, history, cols=cols) + if sid in _sessions: + _sessions[sid]["display_history"] = display_history + except Exception as e: + return _err(rid, 5000, f"resume failed: {e}") + session = _sessions.get(sid) or {} + return _ok( + rid, + { + "session_id": sid, + "resumed": target, + "message_count": len(messages), + "messages": messages, + "info": _session_info(agent, session), + "inflight": None, + "running": False, + "session_key": target, + "started_at": float(session.get("created_at") or time.time()), + "status": "idle", + }, + ) @method("session.cwd.set") @@ -3106,6 +3133,15 @@ def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict: } +def _find_live_session_by_key(session_key: str) -> tuple[str, dict] | None: + for sid, session in list(_sessions.items()): + if session.get("_finalized"): + continue + if str(session.get("session_key") or "") == session_key: + return sid, session + return None + + def _fallback_session_info(session: dict) -> dict: agent = session.get("agent") if agent is not None: @@ -3119,6 +3155,39 @@ def _fallback_session_info(session: dict) -> dict: } +def _live_session_payload( + sid: str, + session: dict, + *, + cols: int | None = None, + touch: bool = False, + transport: Transport | None = None, +) -> dict: + with session["history_lock"]: + if cols is not None: + session["cols"] = cols + if transport is not None: + session["transport"] = transport + if touch: + session["last_active"] = time.time() + history = list(session.get("display_history") or session.get("history") or []) + inflight = _inflight_snapshot(session) + running = bool(session.get("running")) + payload = { + "info": _fallback_session_info(session), + "message_count": len(history), + "messages": _history_to_messages(history), + "running": running, + "session_id": sid, + "session_key": session.get("session_key") or sid, + "started_at": float(session.get("created_at") or time.time()), + "status": _session_live_status(sid, session), + } + if inflight: + payload["inflight"] = inflight + return payload + + @method("session.active_list") def _(rid, params: dict) -> dict: """Return live TUI sessions in this gateway process. @@ -3152,27 +3221,9 @@ def _(rid, params: dict) -> dict: if err: return err - with session["history_lock"]: - session["last_active"] = time.time() - history = list(session.get("display_history") or session.get("history") or []) - inflight = _inflight_snapshot(session) - running = bool(session.get("running")) - status = _session_live_status(sid, session) - payload = { - "info": _fallback_session_info(session), - "message_count": len(history), - "messages": _history_to_messages(history), - "running": running, - "session_id": sid, - "session_key": session.get("session_key") or sid, - "started_at": float(session.get("created_at") or time.time()), - "status": status, - } - if inflight: - payload["inflight"] = inflight return _ok( rid, - payload, + _live_session_payload(sid, session, touch=True), ) @@ -3558,28 +3609,32 @@ def _(rid, params: dict) -> dict: @method("session.close") def _(rid, params: dict) -> dict: sid = params.get("session_id", "") - session = _sessions.pop(sid, None) - if not session: + current = _sessions.get(sid) + if not current: return _ok(rid, {"closed": False}) - _finalize_session(session) - try: - from tools.approval import unregister_gateway_notify + with _session_resume_lock: + session = _sessions.pop(sid, None) + if not session: + return _ok(rid, {"closed": False}) + _finalize_session(session) + try: + from tools.approval import unregister_gateway_notify - unregister_gateway_notify(session["session_key"]) - except Exception: - pass - try: - agent = session.get("agent") - if agent and hasattr(agent, "close"): - agent.close() - except Exception: - pass - try: - worker = session.get("slash_worker") - if worker: - worker.close() - except Exception: - pass + unregister_gateway_notify(session["session_key"]) + except Exception: + pass + try: + agent = session.get("agent") + if agent and hasattr(agent, "close"): + agent.close() + except Exception: + pass + try: + worker = session.get("slash_worker") + if worker: + worker.close() + except Exception: + pass return _ok(rid, {"closed": True}) diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index c2c1ddac8a..e95dafef2b 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -302,38 +302,47 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { return } - closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => - gw - .request('session.resume', { cols: colsRef.current, session_id: id }) - .then(raw => { - const r = asRpcResult(raw) + const previousSid = getUiState().sid - if (!r) { - sys('error: invalid response: session.resume') + gw.request('session.resume', { cols: colsRef.current, session_id: id }) + .then(raw => { + const r = asRpcResult(raw) - return patchUiState({ status: 'ready' }) - } + if (!r) { + sys('error: invalid response: session.resume') - resetSession() - setSessionStartedAt(Date.now()) + return patchUiState({ status: 'ready' }) + } - const resumed = toTranscriptMessages(r.messages) + const info = r.info ?? null + const running = Boolean(r.running || r.status === 'working' || r.status === 'waiting') - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - writeActiveSessionFile(r.resumed ?? r.session_id) - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: usageFrom(r.info ?? null) - }) - setTimeout(() => scrollRef.current?.scrollToBottom(), 0) + resetSession() + setSessionStartedAt(r.started_at ? r.started_at * 1000 : Date.now()) + + const resumed = [...toTranscriptMessages(r.messages), ...liveSessionInflightMessages(r.inflight)] + + setHistoryItems(info ? [introMsg(info), ...resumed] : resumed) + writeActiveSessionFile(r.resumed ?? r.session_id) + patchUiState({ + busy: running, + info, + sid: r.session_id, + status: statusFromLiveSession(r.status, running), + usage: usageFrom(info) }) - .catch((e: Error) => { - sys(`error: ${e.message}`) - patchUiState({ status: 'ready' }) - }) - ) + hydrateLiveSessionInflight(r.inflight) + + if (previousSid && previousSid !== r.session_id) { + void closeSession(previousSid) + } + + setTimeout(() => scrollRef.current?.scrollToBottom(), 0) + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ status: 'ready' }) + }) }) }, [closeSession, colsRef, gw, panel, resetSession, rpc, scrollRef, setHistoryItems, setSessionStartedAt, sys] diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index c56a1aebfa..0a946d9b62 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -122,11 +122,15 @@ export interface SessionCreateResponse { } export interface SessionResumeResponse { + inflight?: null | SessionInflightTurn info?: SessionInfo message_count?: number messages: GatewayTranscriptMessage[] resumed?: string + running?: boolean session_id: string + started_at?: number + status?: LiveSessionStatus } export type LiveSessionStatus = 'idle' | 'starting' | 'waiting' | 'working' From bd6d0987629ecf6d3602d739997564d18de5225f Mon Sep 17 00:00:00 2001 From: rexdotsh <65942753+rexdotsh@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:59:23 +0530 Subject: [PATCH 07/52] fix(tui): keep resumed live history current --- tests/tui_gateway/test_protocol.py | 99 ++++++++++++++++++++++++++++++ tui_gateway/server.py | 9 ++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 96c49dac0c..1d31dd3c97 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -496,6 +496,105 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch): assert created_sids == [first["result"]["session_id"]] +def test_session_resume_live_payload_uses_current_history_with_ancestors(server, monkeypatch): + """Live resume should not reuse a stale ancestor-inclusive snapshot.""" + + target = "20260409_010101_child" + ancestor_history = [{"role": "user", "content": "ancestor"}] + current_history = [ + {"role": "user", "content": "current"}, + {"role": "assistant", "content": "current reply"}, + ] + + class _DB: + def get_session(self, _sid): + return {"id": target} + + def get_session_by_title(self, _title): + return None + + def reopen_session(self, _sid): + return None + + def get_messages_as_conversation(self, _sid, include_ancestors=False): + if include_ancestors: + return ancestor_history + current_history + return list(current_history) + + class _Worker: + def close(self): + pass + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr( + server, + "_make_agent", + lambda _sid, key, session_id=None: types.SimpleNamespace( + model="test/model", session_id=session_id or key + ), + ) + monkeypatch.setattr(server, "_SlashWorker", lambda _key, _model: _Worker()) + monkeypatch.setattr( + server, + "_start_notification_poller", + lambda _sid, _session: threading.Event(), + ) + monkeypatch.setattr(server, "_notify_session_boundary", lambda *_args, **_kwargs: None) + monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) + monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + server, + "_session_info", + lambda _agent, _session=None: {"model": "test/model"}, + ) + + fake_approval = types.SimpleNamespace( + load_permanent_allowlist=lambda: None, + register_gateway_notify=lambda *_args, **_kwargs: None, + ) + + with patch.dict(sys.modules, {"tools.approval": fake_approval}): + first = server.handle_request( + { + "id": "first", + "method": "session.resume", + "params": {"session_id": target, "cols": 100}, + } + ) + + assert "error" not in first + sid = first["result"]["session_id"] + assert first["result"]["messages"] == [ + {"role": "user", "text": "ancestor"}, + {"role": "user", "text": "current"}, + {"role": "assistant", "text": "current reply"}, + ] + + with server._sessions[sid]["history_lock"]: + server._sessions[sid]["history"] = current_history + [ + {"role": "user", "content": "new live turn"}, + {"role": "assistant", "content": "new live reply"}, + ] + + second = server.handle_request( + { + "id": "second", + "method": "session.resume", + "params": {"session_id": target, "cols": 120}, + } + ) + + assert "error" not in second + assert second["result"]["session_id"] == sid + assert second["result"]["messages"] == [ + {"role": "user", "text": "ancestor"}, + {"role": "user", "text": "current"}, + {"role": "assistant", "text": "current reply"}, + {"role": "user", "text": "new live turn"}, + {"role": "assistant", "text": "new live reply"}, + ] + + def test_make_agent_accepts_list_system_prompt(server, monkeypatch): captured = {} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 40695facbd..113e29a1ae 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3016,6 +3016,9 @@ def _(rid, params: dict) -> dict: display_history = db.get_messages_as_conversation( target, include_ancestors=True ) + display_history_prefix = display_history[ + : max(0, len(display_history) - len(history)) + ] messages = _history_to_messages(display_history) tokens = _set_session_context(target) try: @@ -3024,7 +3027,7 @@ def _(rid, params: dict) -> dict: _clear_session_context(tokens) _init_session(sid, target, agent, history, cols=cols) if sid in _sessions: - _sessions[sid]["display_history"] = display_history + _sessions[sid]["display_history_prefix"] = display_history_prefix except Exception as e: return _err(rid, 5000, f"resume failed: {e}") session = _sessions.get(sid) or {} @@ -3170,7 +3173,9 @@ def _live_session_payload( session["transport"] = transport if touch: session["last_active"] = time.time() - history = list(session.get("display_history") or session.get("history") or []) + history = list(session.get("display_history_prefix") or []) + list( + session.get("history") or [] + ) inflight = _inflight_snapshot(session) running = bool(session.get("running")) payload = { From 8077e7d2fbbbfb9ae7c6130154c22586a5663630 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:41:57 +0530 Subject: [PATCH 08/52] fix(tui): narrow resume lock to avoid blocking session.close The salvaged fix held _session_resume_lock across _make_agent (MCP discovery + AIAgent construction, seconds), serializing it against session.close. Since session.close runs on the main RPC dispatch thread (not a _LONG_HANDLER), a close racing a mid-build resume would stall all fast-path RPCs (approval.respond, session.interrupt). Restructure to double-checked locking: build the agent outside the lock, then re-check _find_live_session_by_key under the lock before _init_session. A losing concurrent resume discards its just-built agent (no worker/poller wired yet) and reuses the winner. Updated the concurrent-resume regression test to assert the real invariant (one surviving live session + loser agent closed) rather than the implementation detail of a single _make_agent call. --- tests/tui_gateway/test_protocol.py | 22 +++++++- tui_gateway/server.py | 90 ++++++++++++++++++++---------- 2 files changed, 80 insertions(+), 32 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 1d31dd3c97..daa3a91459 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -399,6 +399,7 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch): target = "20260409_010101_abc123" created_sids: list[str] = [] + closed_sids: list[str] = [] first_agent_started = threading.Event() agent_can_finish = threading.Event() @@ -422,11 +423,20 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch): def close(self): pass + class _Agent: + def __init__(self, sid, session_id): + self.sid = sid + self.model = "test/model" + self.session_id = session_id + + def close(self): + closed_sids.append(self.sid) + def make_agent(sid, key, session_id=None): created_sids.append(sid) first_agent_started.set() assert agent_can_finish.wait(timeout=1) - return types.SimpleNamespace(model="test/model", session_id=session_id or key) + return _Agent(sid, session_id or key) monkeypatch.setattr(server, "_get_db", lambda: _DB()) monkeypatch.setattr(server, "_make_agent", make_agent) @@ -490,10 +500,18 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch): assert "error" not in first assert "error" not in second + # Both resumes resolve to the SAME single live session — the core invariant. assert second["result"]["session_id"] == first["result"]["session_id"] assert len(server._sessions) == 1 assert [s.get("session_key") for s in server._sessions.values()].count(target) == 1 - assert created_sids == [first["result"]["session_id"]] + winner = first["result"]["session_id"] + # The agent build happens outside the resume lock, so a racing resume may + # build a redundant agent; double-checked locking keeps only one live + # session and closes any loser's agent (no worker/poller is wired for it). + assert winner in created_sids + survivors = [sid for sid in created_sids if sid not in closed_sids] + assert survivors == [winner] + assert all(sid == winner for sid in server._sessions) def test_session_resume_live_payload_uses_current_history_with_ancestors(server, monkeypatch): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 113e29a1ae..5ac7ccf5d6 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2994,6 +2994,7 @@ def _(rid, params: dict) -> dict: target = found["id"] else: return _err(rid, 4007, "session not found") + # Fast path: if the session is already live, reuse it under the lock. with _session_resume_lock: live = _find_live_session_by_key(target) if live is not None: @@ -3008,44 +3009,73 @@ def _(rid, params: dict) -> dict: payload["resumed"] = target return _ok(rid, payload) - sid = uuid.uuid4().hex[:8] - _enable_gateway_prompts() + # Build the agent OUTSIDE the lock — _make_agent can block for seconds + # (MCP discovery, prompt/skill build, AIAgent construction). Holding + # _session_resume_lock across it would stall session.close on the main + # dispatch thread (it's not a _LONG_HANDLER), blocking fast-path RPCs. + sid = uuid.uuid4().hex[:8] + _enable_gateway_prompts() + try: + db.reopen_session(target) + history = db.get_messages_as_conversation(target) + display_history = db.get_messages_as_conversation( + target, include_ancestors=True + ) + display_history_prefix = display_history[ + : max(0, len(display_history) - len(history)) + ] + messages = _history_to_messages(display_history) + tokens = _set_session_context(target) try: - db.reopen_session(target) - history = db.get_messages_as_conversation(target) - display_history = db.get_messages_as_conversation( - target, include_ancestors=True - ) - display_history_prefix = display_history[ - : max(0, len(display_history) - len(history)) - ] - messages = _history_to_messages(display_history) - tokens = _set_session_context(target) + agent = _make_agent(sid, target, session_id=target) + finally: + _clear_session_context(tokens) + except Exception as e: + return _err(rid, 5000, f"resume failed: {e}") + + # Double-checked locking: another concurrent resume may have created the + # live session while we were building. Re-check under the lock; if it won, + # discard our just-built agent and reuse theirs (no worker/poller wired yet). + with _session_resume_lock: + live = _find_live_session_by_key(target) + if live is not None: try: - agent = _make_agent(sid, target, session_id=target) - finally: - _clear_session_context(tokens) + if hasattr(agent, "close"): + agent.close() + except Exception: + pass + other_sid, other_session = live + payload = _live_session_payload( + other_sid, + other_session, + cols=cols, + touch=True, + transport=current_transport() or _stdio_transport, + ) + payload["resumed"] = target + return _ok(rid, payload) + try: _init_session(sid, target, agent, history, cols=cols) if sid in _sessions: _sessions[sid]["display_history_prefix"] = display_history_prefix except Exception as e: return _err(rid, 5000, f"resume failed: {e}") session = _sessions.get(sid) or {} - return _ok( - rid, - { - "session_id": sid, - "resumed": target, - "message_count": len(messages), - "messages": messages, - "info": _session_info(agent, session), - "inflight": None, - "running": False, - "session_key": target, - "started_at": float(session.get("created_at") or time.time()), - "status": "idle", - }, - ) + return _ok( + rid, + { + "session_id": sid, + "resumed": target, + "message_count": len(messages), + "messages": messages, + "info": _session_info(agent, session), + "inflight": None, + "running": False, + "session_key": target, + "started_at": float(session.get("created_at") or time.time()), + "status": "idle", + }, + ) @method("session.cwd.set") From ee7948ea6e3b7b6187d40702a76204927b6688e3 Mon Sep 17 00:00:00 2001 From: rexdotsh <65942753+rexdotsh@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:34:44 +0530 Subject: [PATCH 09/52] fix(deps): exclude dev tooling from all extra --- pyproject.toml | 1 - tests/test_project_metadata.py | 11 +++++++++++ uv.lock | 8 -------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 602279dadf..0ce6375288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,7 +230,6 @@ all = [ # where the user is expected to have a toolchain available. "hermes-agent[cron]", "hermes-agent[cli]", - "hermes-agent[dev]", "hermes-agent[pty]", "hermes-agent[mcp]", "hermes-agent[homeassistant]", diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 29f32c6bbc..4ad532c7c2 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -88,6 +88,17 @@ def test_lazy_installable_extras_excluded_from_all(): ) +def test_dev_extra_excluded_from_all(): + """End-user installs should not pull test/lint/debug tooling.""" + optional_dependencies = _load_optional_dependencies() + + assert "dev" in optional_dependencies + assert not any( + spec == "hermes-agent[dev]" + for spec in optional_dependencies["all"] + ) + + def test_messaging_extra_includes_qrcode_for_weixin_setup(): optional_dependencies = _load_optional_dependencies() diff --git a/uv.lock b/uv.lock index 49f290f1f0..b10a215c9f 100644 --- a/uv.lock +++ b/uv.lock @@ -1423,20 +1423,13 @@ acp = [ all = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, - { name = "debugpy" }, { name = "fastapi" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, { name = "mcp" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-timeout" }, - { name = "ruff" }, - { name = "setuptools" }, { name = "simple-term-menu" }, { name = "starlette" }, - { name = "ty" }, { name = "uvicorn", extra = ["standard"] }, { name = "youtube-transcript-api" }, ] @@ -1629,7 +1622,6 @@ requires-dist = [ { name = "hermes-agent", extras = ["cli"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["cron"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["cron"], marker = "extra == 'termux'" }, - { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" }, From 1f347ee543b650bd788b69af65830719703d74d9 Mon Sep 17 00:00:00 2001 From: Jeff Date: Thu, 4 Jun 2026 08:04:01 -0700 Subject: [PATCH 10/52] fix(uv): move venv aside instead of gutting it in place on Windows rebuild hermes update can brick a Windows install. When 'hermes update --force' runs past the concurrent-process guard, rebuild_venv runs while the venv is still in use: shutil.rmtree(ignore_errors=True) deletes site-packages + certifi's cert bundle but can't remove the locked python.exe, leaving a half-gutted venv that uv venv then refuses to overwrite. Every later HTTPS call dies with FileNotFoundError for the missing cacert and there is no recovery. --clear alone (the c136eb4de retry path) does not fix the real lock case: when the locked interpreter is *inside* the venv being rebuilt, neither rmtree nor uv venv --clear can delete it. os.replace of the parent directory *is* allowed on Windows (a running .exe is tracked by handle, not path), so we move the old venv aside atomically to .old, rebuild with --clear in its place, and the still-running gateway/desktop keep using the moved-aside copy until they restart. If the venv genuinely can't be moved, we abort cleanly and leave it fully intact; if the rebuild fails, we restore the moved-aside copy. Folds in the call-site guards from #38511 (@f3rs3n): - rebuild_venv() returns False (and restores the backup) if uv exits 0 without producing an interpreter. - both hermes update venv-rebuild call sites abort with RuntimeError instead of continuing into dependency install when rebuild_venv() returns False. Also gitignore /venv.old/ so the update autostash (git stash --include-untracked) doesn't sweep the moved-aside venv on every run. Root-cause fix for #37881. Supersedes the --clear-only retry from c136eb4de. Co-authored-by: f3rs3n <32328813+f3rs3n@users.noreply.github.com> --- .gitignore | 1 + hermes_cli/main.py | 10 +- hermes_cli/managed_uv.py | 85 ++++++++---- tests/hermes_cli/test_managed_uv.py | 159 ++++++++++------------ tests/hermes_cli/test_update_autostash.py | 35 +++++ 5 files changed, 175 insertions(+), 115 deletions(-) diff --git a/.gitignore b/.gitignore index f97db5994d..b0abe3140b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store /venv/ +/venv.old/ /_pycache/ *.pyc* __pycache__/ diff --git a/hermes_cli/main.py b/hermes_cli/main.py index aba90fb3eb..9c91e7a905 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8030,7 +8030,10 @@ def _update_via_zip(args): # may point to a Python without FTS5. Rebuild it so the new managed # uv provides a fresh interpreter with FTS5 guaranteed. if fresh_bootstrap and uv_bin: - rebuild_venv(uv_bin, PROJECT_ROOT / "venv") + if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"): + raise RuntimeError( + "venv rebuild failed; aborting update before dependency install" + ) pip_cmd = [sys.executable, "-m", "pip"] if not uv_bin: @@ -10573,7 +10576,10 @@ def _cmd_update_impl(args, gateway_mode: bool): # may point to a Python without FTS5. Rebuild it so the new managed # uv provides a fresh interpreter with FTS5 guaranteed. if fresh_bootstrap and uv_bin: - rebuild_venv(uv_bin, PROJECT_ROOT / "venv") + if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"): + raise RuntimeError( + "venv rebuild failed; aborting update before dependency install" + ) pip_cmd = [sys.executable, "-m", "pip"] if not uv_bin: diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index 31bbbc8b93..722cf5ba33 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -106,41 +106,69 @@ def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> b fresh interpreter from the current managed uv. Returns ``True`` on success. - On Windows, ``shutil.rmtree(..., ignore_errors=True)`` can silently leave - the venv directory partially intact when another process is holding an - open handle to a file inside it (typical culprits: a running - ``hermes.exe`` REPL, the gateway, AV scanners). If we don't notice that - and just call ``uv venv``, uv refuses with - ``Caused by: A directory already exists at: venv`` and the *whole - update* falls back to installing on top of the stale venv — which has - historically produced partial installs where a freshly added dependency - (e.g. ``pathspec``) silently fails to land. Retry with ``--clear`` to - force uv past that condition before giving up. + The old venv is moved aside *atomically* (``os.replace`` to ``.old``) + before recreating — never deleted in place. On Windows a still-running + ``hermes.exe`` (gateway/desktop) holds ``venv\\Scripts\\python.exe`` open; + ``shutil.rmtree(ignore_errors=True)`` would delete everything it *can* + (site-packages, certifi's cert bundle) and silently leave a half-gutted + venv that the following ``uv venv`` then refuses to overwrite ("directory + already exists") — bricking the install with no recovery (every later HTTPS + call dies with ``FileNotFoundError`` for the missing cert bundle). + ``--clear`` alone does not fix this: when the locked interpreter is *inside* + the venv being rebuilt, neither ``rmtree`` nor ``uv venv --clear`` can + delete the held ``python.exe``. ``os.replace`` of the parent directory *is* + allowed (Windows tracks a running ``.exe`` by handle, not path), so the + rebuild completes while the running process keeps using the moved-aside copy + until it restarts. If the venv genuinely cannot be moved, we abort cleanly + and leave it fully intact; and if the rebuild itself fails we move the old + venv back so Hermes is never left with no venv at all. """ + backup: Optional[Path] = None if venv_dir.exists(): print(f" → Rebuilding venv (old Python may lack FTS5)...") - shutil.rmtree(venv_dir, ignore_errors=True) + backup = venv_dir.with_name(venv_dir.name + ".old") + shutil.rmtree(backup, ignore_errors=True) # clear any stale backup + try: + # Atomic move — fails (without partial deletion) if a process still + # holds files inside the venv, which is exactly the Windows + # file-lock case that previously bricked the install. + os.replace(venv_dir, backup) + except OSError as exc: + logger.warning("venv rebuild aborted — venv in use: %s", exc) + print( + " ✗ venv rebuild aborted — the venv is in use; stop the " + f"gateway/desktop and retry ({exc})" + ) + return False - def _run_uv_venv(extra_args: list[str]) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [uv_bin, "venv", str(venv_dir), "--python", python_version, *extra_args], - capture_output=True, - text=True, - check=False, - ) + result = subprocess.run( + [uv_bin, "venv", str(venv_dir), "--python", python_version, "--clear"], + capture_output=True, + text=True, + check=False, + ) - result = _run_uv_venv([]) - - # If uv refused because the directory still exists (rmtree above was - # blocked by an open file handle, common on Windows), retry with - # --clear so uv overwrites it. Match on stderr because uv's exit code - # alone doesn't distinguish "dir exists" from real failures. - if result.returncode != 0 and "already exists" in (result.stderr or "").lower(): - print(" → venv dir not fully removed (likely an open file handle); retrying with --clear...") - result = _run_uv_venv(["--clear"]) + def _restore_backup() -> None: + if backup is not None and backup.exists(): + shutil.rmtree(venv_dir, ignore_errors=True) + try: + os.replace(backup, venv_dir) + print(" ↩ Restored previous venv after failed rebuild.") + except OSError: + pass if result.returncode == 0: venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python" + # uv can exit 0 yet leave no usable interpreter (e.g. a half-written + # venv). Don't report success on a venv that has no python — restore the + # moved-aside copy so the caller can abort without losing a working env. + if not venv_python.exists(): + logger.warning("venv rebuild reported success but %s is missing", venv_python) + print(f" ✗ venv rebuild failed: Python interpreter missing at {venv_python}") + _restore_backup() + return False + if backup is not None: + shutil.rmtree(backup, ignore_errors=True) py_ver = subprocess.run( [str(venv_python), "--version"], capture_output=True, @@ -150,6 +178,9 @@ def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> b print(f" ✓ venv rebuilt ({py_ver})") return True else: + # Rebuild failed — restore the old venv so we never leave Hermes with no + # venv (the bricked-install failure mode this function exists to avoid). + _restore_backup() logger.warning("venv rebuild failed: %s", result.stderr) print(f" ✗ venv rebuild failed: {result.stderr.strip()}") return False diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index f1394f6efd..aff6b8de74 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -108,121 +108,108 @@ class TestEnsureUv: # --------------------------------------------------------------------------- class TestRebuildVenv: - def test_removes_old_venv_and_creates_new(self, tmp_path): + def test_moves_old_venv_aside_and_creates_new(self, tmp_path): + """The old venv is moved aside to .old (never rmtree'd in place), + uv is invoked with --clear, the moved-aside backup is removed on + success, and the rebuilt interpreter is reported.""" venv_dir = tmp_path / "venv" venv_dir.mkdir() (venv_dir / "old_file").write_text("stale") uv_bin = str(tmp_path / "bin" / "uv") + call_log: list[list[str]] = [] def fake_run(cmd, **kwargs): - m = MagicMock(returncode=0) - if cmd[1] == "venv": - # Simulate uv creating the venv dir - venv_dir.mkdir(exist_ok=True) - bin_dir = venv_dir / "bin" + call_log.append(list(cmd)) + m = MagicMock(returncode=0, stderr="", stdout="") + if len(cmd) >= 2 and cmd[1] == "venv": + # Simulate uv creating the venv dir with a python interpreter + bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin") bin_dir.mkdir(parents=True, exist_ok=True) - (bin_dir / "python").write_text("#!/bin/sh\necho Python 3.11.0") + python_name = "python.exe" if os.name == "nt" else "python" + (bin_dir / python_name).write_text("#!/bin/sh\necho Python 3.11.0") elif "--version" in cmd: m.stdout = "Python 3.11.0" return m - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ - patch("hermes_cli.managed_uv.shutil.rmtree") as mock_rmtree: + with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run): from hermes_cli.managed_uv import rebuild_venv result = rebuild_venv(uv_bin, venv_dir) - assert result is True - mock_rmtree.assert_called_once_with(venv_dir, ignore_errors=True) + + assert result is True + # uv venv was invoked exactly once, always with --clear. + venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"] + assert len(venv_calls) == 1, f"expected 1 venv call, got {venv_calls}" + assert "--clear" in venv_calls[0] + # The moved-aside backup is cleaned up after a successful rebuild. + assert not (tmp_path / "venv.old").exists() + + def test_aborts_without_deleting_when_venv_in_use(self, tmp_path): + """If os.replace fails (Windows file lock — venv in use), we must abort + cleanly WITHOUT deleting the venv and WITHOUT invoking uv.""" + venv_dir = tmp_path / "venv" + venv_dir.mkdir() + (venv_dir / "locked") .write_text("held open") + uv_bin = str(tmp_path / "bin" / "uv") + call_log: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + call_log.append(list(cmd)) + return MagicMock(returncode=0, stderr="", stdout="") + + with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ + patch("hermes_cli.managed_uv.os.replace", side_effect=OSError("in use")): + from hermes_cli.managed_uv import rebuild_venv + result = rebuild_venv(uv_bin, venv_dir) + + assert result is False + # venv left fully intact, uv never invoked. + assert venv_dir.exists() and (venv_dir / "locked").exists() + assert [c for c in call_log if len(c) >= 2 and c[1] == "venv"] == [] + + def test_restores_backup_when_rebuild_fails(self, tmp_path): + """If uv venv exits non-zero, the moved-aside venv is restored so we + never leave Hermes with no venv at all.""" + venv_dir = tmp_path / "venv" + venv_dir.mkdir() + (venv_dir / "marker").write_text("original") + uv_bin = str(tmp_path / "bin" / "uv") + + def fake_run(cmd, **kwargs): + return MagicMock(returncode=1, stderr="boom", stdout="") + + with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run): + from hermes_cli.managed_uv import rebuild_venv + result = rebuild_venv(uv_bin, venv_dir) + + assert result is False + # Original venv restored from the .old backup. + assert venv_dir.exists() and (venv_dir / "marker").read_text() == "original" + assert not (tmp_path / "venv.old").exists() def test_rebuild_failure_returns_false(self, tmp_path): venv_dir = tmp_path / "venv" uv_bin = str(tmp_path / "bin" / "uv") - with patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \ - patch("hermes_cli.managed_uv.shutil.rmtree"): + with patch("hermes_cli.managed_uv.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1, stderr="nope") from hermes_cli.managed_uv import rebuild_venv result = rebuild_venv(uv_bin, venv_dir) assert result is False - def test_retries_with_clear_when_dir_already_exists(self, tmp_path): - """On Windows, rmtree can silently fail when an open handle holds a - file in the venv (running hermes.exe, gateway, AV scanner). uv then - refuses with ``Caused by: A directory already exists at: venv``. - Make sure we don't give up — retry with ``--clear`` to force uv past - the stale directory and rebuild successfully.""" - venv_dir = tmp_path / "venv" - venv_dir.mkdir() - (venv_dir / "stale_open_handle").write_text("rmtree couldn't delete me") - - uv_bin = str(tmp_path / "bin" / "uv") - call_log: list[list[str]] = [] - - def fake_run(cmd, **kwargs): - call_log.append(list(cmd)) - m = MagicMock() - if cmd[1] == "venv" and "--clear" not in cmd: - # First attempt: uv refuses because dir still exists - m.returncode = 1 - m.stderr = ( - "error: Failed to create virtual environment\n" - " Caused by: A directory already exists at: venv\n" - "hint: Use the `--clear` flag or set `UV_VENV_CLEAR=1` to replace the existing directory\n" - ) - m.stdout = "" - return m - if cmd[1] == "venv" and "--clear" in cmd: - # Retry: succeeds. Simulate uv writing the python shim. - m.returncode = 0 - m.stderr = "" - m.stdout = "" - bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin") - bin_dir.mkdir(parents=True, exist_ok=True) - python_name = "python.exe" if os.name == "nt" else "python" - (bin_dir / python_name).write_text("#!/bin/sh\necho Python 3.11.0") - return m - if "--version" in cmd: - m.returncode = 0 - m.stdout = "Python 3.11.0" - m.stderr = "" - return m - m.returncode = 0 - return m - - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ - patch("hermes_cli.managed_uv.shutil.rmtree"): - from hermes_cli.managed_uv import rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - - assert result is True, "rebuild should succeed after --clear retry" - # We expect exactly two ``uv venv`` calls: one without --clear, one with. - venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"] - assert len(venv_calls) == 2, f"expected 2 venv calls, got {venv_calls}" - assert "--clear" not in venv_calls[0], "first call should not pass --clear" - assert "--clear" in venv_calls[1], "retry must pass --clear" - - def test_does_not_retry_when_first_failure_is_not_dir_exists(self, tmp_path): - """If uv venv fails for some other reason (e.g. interpreter download - failed, disk full), we should NOT silently retry with --clear — - that would mask a real problem. Just surface the original failure.""" + def test_rebuild_success_without_python_returns_false(self, tmp_path): + """uv can exit 0 yet leave no interpreter; that must not count as success + (guard adapted from #38511).""" venv_dir = tmp_path / "venv" uv_bin = str(tmp_path / "bin" / "uv") - call_log: list[list[str]] = [] - def fake_run(cmd, **kwargs): - call_log.append(list(cmd)) - m = MagicMock(returncode=1, stderr="error: No space left on device", stdout="") - return m - - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ - patch("hermes_cli.managed_uv.shutil.rmtree"): + with patch("hermes_cli.managed_uv.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") from hermes_cli.managed_uv import rebuild_venv result = rebuild_venv(uv_bin, venv_dir) - - assert result is False - venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"] - assert len(venv_calls) == 1, "should not retry on non-dir-exists failures" - assert "--clear" not in venv_calls[0] + assert result is False + # Returned before the `python --version` probe ran (only the uv venv call). + assert mock_run.call_count == 1 # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index adcd24bf3c..b291790775 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -423,6 +423,41 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path): assert ".[all]" in install_cmds[0] +def test_cmd_update_aborts_when_fresh_managed_uv_rebuild_fails(monkeypatch, tmp_path): + """A failed fresh managed-uv venv rebuild must not continue into pip install + (guard adapted from #38511).""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False) + + recorded = [] + + def fake_run(cmd, **kwargs): + recorded.append(cmd) + # Tolerant matching: the update flow's exact git invocations vary by + # checkout, so key off the verb. Branch detection must return a real name + # and rev-list a parseable count, or the flow aborts early before it ever + # reaches the venv rebuild this test exercises. + if isinstance(cmd, (list, tuple)) and cmd and cmd[0] == "git": + if "rev-parse" in cmd: + return SimpleNamespace(stdout="main\n", stderr="", returncode=0) + if "rev-list" in cmd: + return SimpleNamespace(stdout="1\n", stderr="", returncode=0) + if "pull" in cmd: + return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + with patch("hermes_cli.managed_uv.ensure_uv", return_value=("/usr/bin/uv", True)), \ + patch("hermes_cli.managed_uv.rebuild_venv", return_value=False), \ + pytest.raises(RuntimeError, match="venv rebuild failed"): + hermes_main.cmd_update(SimpleNamespace()) + + install_cmds = [c for c in recorded if "pip" in c and "install" in c] + assert install_cmds == [] + + def test_install_with_optional_fallback_honors_custom_group(monkeypatch): """Termux update path should target .[termux-all] when requested.""" calls = [] From d1367355d514b5ce3af6056ca660ab28e9d632e4 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:04:01 -0700 Subject: [PATCH 11/52] chore(release): map jeffrobodie@gmail.com -> jeffrobodie-glitch for salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 5ca6a2e934..6fa874afd6 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -46,6 +46,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "zhaolei.vc@bytedance.com": "zhaoleibd", + "jeffrobodie@gmail.com": "jeffrobodie-glitch", "kyssta-exe@users.noreply.github.com": "kyssta-exe", "copii.list@gmail.com": "stremtec", "solaiagent@gmail.com": "solaitken", From a3fb48b2ceb382ade3ecbb99e2cfa475b4c8abcb Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Thu, 4 Jun 2026 22:12:21 +0530 Subject: [PATCH 12/52] fix(state): keep /branch sessions visible after parent reopen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /branch (aka /fork) sessions vanished from /resume and /sessions. Both surfaces funnel through list_sessions_rich(include_children=False), which hid any session with a parent_session_id unless identified as a branch via a heuristic — parent.end_reason == 'branched' AND child.started_at >= parent.ended_at. Two ways that heuristic failed: 1. CLI/gateway branches: once the parent was reopened (e.g. resumed) and re-ended with a different end_reason (tui_shutdown overwriting 'branched'), the heuristic stopped matching and the branch was hidden permanently. 2. TUI branches (tui_gateway session.branch): the TUI never ends the parent as 'branched' — it creates the child while the parent is still live — so the heuristic NEVER matched and TUI branches were hidden from the moment they were created (this is the macOS desktop app's primary symptom). Fix: persist a stable '_branched_from' marker in the branch session's model_config at creation time across ALL THREE branch paths (CLI cli.py, gateway gateway/run.py, and TUI tui_gateway/server.py), and OR a json_extract(model_config, '$._branched_from') IS NOT NULL check into the list_sessions_rich filter. The marker is immutable across the parent's lifecycle, so the branch stays visible regardless of how/whether the parent is ended. The legacy end_reason heuristic is kept (OR'd) so pre-existing branches remain visible. Subagent/compression children (no marker, parent not 'branched') stay correctly hidden. Fixes #20856. Approach by liuhao1024 (PR #20864); reimplemented on current main, extended to the TUI branch path (which the original missed), with regression tests for the reopen+re-end scenario and the TUI marker persistence. --- cli.py | 7 +++- gateway/run.py | 7 +++- hermes_state.py | 20 ++++++--- tests/test_hermes_state.py | 38 +++++++++++++++++ tests/tui_gateway/test_protocol.py | 65 ++++++++++++++++++++++++++++++ tui_gateway/server.py | 6 +++ 6 files changed, 136 insertions(+), 7 deletions(-) diff --git a/cli.py b/cli.py index 03ed1df00c..d429e14fc9 100644 --- a/cli.py +++ b/cli.py @@ -7166,7 +7166,11 @@ class HermesCLI: except Exception: pass - # Create the new session with parent link + # Create the new session with parent link. + # Persist a stable ``_branched_from`` marker in model_config so + # list_sessions_rich() can keep the branch visible in /resume and + # /sessions even after the parent is reopened and re-ended with a + # different end_reason (e.g. tui_shutdown overwriting 'branched'). try: self._session_db.create_session( session_id=new_session_id, @@ -7175,6 +7179,7 @@ class HermesCLI: model_config={ "max_iterations": self.max_turns, "reasoning_config": self.reasoning_config, + "_branched_from": parent_session_id, }, parent_session_id=parent_session_id, ) diff --git a/gateway/run.py b/gateway/run.py index 6d2f659876..7887ec23c3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -13956,12 +13956,17 @@ class GatewayRunner: parent_session_id = current_entry.session_id - # Create the new session with parent link + # Create the new session with parent link. + # Persist a stable ``_branched_from`` marker in model_config so + # list_sessions_rich() keeps the branch visible in /resume and + # /sessions even after the parent is reopened and re-ended with a + # different end_reason (e.g. tui_shutdown overwriting 'branched'). try: self._session_db.create_session( session_id=new_session_id, source=source.platform.value if source.platform else "gateway", model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None, + model_config={"_branched_from": parent_session_id}, parent_session_id=parent_session_id, ) except Exception as e: diff --git a/hermes_state.py b/hermes_state.py index 1a3a4ff4e5..9c67779a64 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1598,13 +1598,23 @@ class SessionDB: params = [] if not include_children: - # Show root sessions and branch sessions (whose parent ended with - # end_reason='branched' before the child was created), while still - # hiding sub-agent runs and compression continuations (which also - # carry a parent_session_id but were spawned while the parent was - # still live — i.e., started_at < parent.ended_at). + # Show root sessions and branch sessions, while still hiding + # sub-agent runs and compression continuations (which also carry a + # parent_session_id but were spawned while the parent was still + # live — i.e., started_at < parent.ended_at). + # + # Branch sessions are identified two ways, OR'd for robustness: + # 1. A stable ``_branched_from`` marker in model_config, written + # by /branch at creation time. This survives the parent being + # reopened and re-ended with a different end_reason (e.g. + # tui_shutdown overwriting 'branched'), which otherwise hides + # the branch — see issue #20856. + # 2. The legacy heuristic (parent ended with 'branched' before the + # child started), covering branch sessions created before the + # marker existed. where_clauses.append( "(s.parent_session_id IS NULL" + " OR json_extract(s.model_config, '$._branched_from') IS NOT NULL" " OR EXISTS (SELECT 1 FROM sessions p" " WHERE p.id = s.parent_session_id" " AND p.end_reason = 'branched'" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index f5e4f69ae6..8d0b55775a 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2716,6 +2716,44 @@ class TestListSessionsRich: ids = [s["id"] for s in sessions] assert "branch" in ids, "Branch session should be visible in default list" + def test_branch_session_visible_after_parent_reopen_and_reend(self, db): + """Branch sessions stay visible after the parent is reopened and re-ended. + + Regression for issue #20856: /branch (aka /fork) sessions vanished from + /resume and /sessions once the parent was reopened (e.g. resumed) and + re-ended with a different end_reason — tui_shutdown overwriting + 'branched' — which broke the legacy end_reason heuristic. The stable + _branched_from marker in model_config keeps them visible. + """ + import json as _json + + db.create_session("parent", "cli") + db.end_session("parent", "branched") + db.create_session( + "branch", + "cli", + model_config={"_branched_from": "parent"}, + parent_session_id="parent", + ) + db.append_message("branch", "user", "Exploring the alternative approach") + + # Marker is persisted at creation time. + branch_row = db.get_session("branch") + cfg = _json.loads(branch_row["model_config"]) if branch_row["model_config"] else {} + assert cfg.get("_branched_from") == "parent" + + # Visible immediately after branching. + assert "branch" in [s["id"] for s in db.list_sessions_rich()] + + # Parent reopened + re-ended with a different reason (the bug trigger). + db.reopen_session("parent") + db.end_session("parent", "tui_shutdown") + + # Branch must STILL be visible — the marker survives the parent's + # end_reason churn, unlike the legacy 'branched' heuristic. + ids = [s["id"] for s in db.list_sessions_rich()] + assert "branch" in ids, "Branch should stay visible after parent re-end" + def test_subagent_session_still_hidden(self, db): """Sub-agent children (parent NOT ended with 'branched') remain hidden.""" db.create_session("root", "cli") diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index daa3a91459..9a6b7d30bd 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -613,6 +613,71 @@ def test_session_resume_live_payload_uses_current_history_with_ancestors(server, ] +def test_session_branch_persists_branched_from_marker(server, monkeypatch): + """TUI /branch must persist a _branched_from marker so the branch stays + visible in /resume and /sessions. + + Regression for issue #20856: the TUI branch leaves the parent live (it + never ends it with end_reason='branched'), so list_sessions_rich's legacy + heuristic never surfaces it — the stable model_config marker is the only + thing that keeps a TUI branch visible. + """ + create_calls = [] + + class _DB: + def get_session_title(self, _key): + return "parent-title" + + def get_next_title_in_lineage(self, base): + return f"{base} 2" + + def create_session(self, new_key, **kwargs): + create_calls.append((new_key, kwargs)) + return new_key + + def append_message(self, **_kwargs): + return None + + def set_session_title(self, _key, _title): + return None + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr(server, "_resolve_model", lambda: "test/model") + monkeypatch.setattr(server, "_new_session_key", lambda: "20260101_000001_child0") + monkeypatch.setattr( + server, + "_make_agent", + lambda _sid, key, session_id=None: types.SimpleNamespace( + model="test/model", session_id=session_id or key + ), + ) + monkeypatch.setattr(server, "_init_session", lambda *_a, **_k: None) + monkeypatch.setattr(server, "_set_session_context", lambda *_a, **_k: []) + monkeypatch.setattr(server, "_clear_session_context", lambda *_a, **_k: None) + monkeypatch.setattr(server, "_session_cwd", lambda _s: "/tmp/branch-cwd") + + parent_sid = "parent01" + parent_key = "20260101_000000_parent" + server._sessions[parent_sid] = { + "session_key": parent_key, + "history": [{"role": "user", "content": "hello"}], + "history_lock": threading.Lock(), + "cols": 80, + } + + resp = server.handle_request( + {"id": "b1", "method": "session.branch", "params": {"session_id": parent_sid}} + ) + + assert "error" not in resp, resp + assert len(create_calls) == 1 + new_key, kwargs = create_calls[0] + assert new_key == "20260101_000001_child0" + assert kwargs["parent_session_id"] == parent_key + # The marker — without it the branch is invisible in /resume and /sessions. + assert kwargs["model_config"] == {"_branched_from": parent_key} + + def test_make_agent_accepts_list_system_prompt(server, monkeypatch): captured = {} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5ac7ccf5d6..ace784135f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3702,6 +3702,12 @@ def _(rid, params: dict) -> dict: new_key, source="tui", model=_resolve_model(), + # Stable _branched_from marker so list_sessions_rich() keeps the + # branch visible in /resume and /sessions. The TUI branch leaves + # the parent live (no end_reason='branched'), so the legacy + # end_reason heuristic never matches it — the marker is the only + # thing that surfaces TUI branches. See issue #20856. + model_config={"_branched_from": old_key}, parent_session_id=old_key, cwd=_session_cwd(session), ) From acce1a2452f8b85343db1b057c1d98717c421522 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 4 Jun 2026 14:01:15 -0400 Subject: [PATCH 13/52] feat(desktop): polish credentials settings and messaging env routing (#39217) * feat(desktop): polish credentials settings and messaging env routing Align Provider API Keys and Tools & Keys with Advanced ListRow inputs, add Tools & Keys sidebar subnav, move platform env vars to Messaging via channel_managed discovery, strip toolset emojis, and condense cron actions. Co-authored-by: Cursor * fix(desktop): align Messaging credential inputs with settings ListRow style Remove monospace inputs and use CREDENTIAL_CONTROL_CLASS + ListRow layout to match Provider API Keys and Tools & Keys. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../desktop/src/app/command-palette/index.tsx | 14 +- .../src/app/cron/cron-job-actions-menu.tsx | 108 +++++++ apps/desktop/src/app/cron/index.tsx | 53 ++-- apps/desktop/src/app/messaging/index.tsx | 126 +++++--- .../src/app/settings/credential-key-ui.tsx | 226 +++++++++++++++ .../src/app/settings/env-credentials.tsx | 162 +---------- .../src/app/settings/env-var-actions-menu.tsx | 130 +++++++++ apps/desktop/src/app/settings/helpers.test.ts | 22 +- apps/desktop/src/app/settings/helpers.ts | 7 + apps/desktop/src/app/settings/index.tsx | 30 +- .../src/app/settings/keys-settings.tsx | 158 +++------- .../src/app/settings/providers-settings.tsx | 269 +----------------- .../src/app/settings/toolset-config-panel.tsx | 46 ++- apps/desktop/src/app/skills/index.test.tsx | 11 + apps/desktop/src/app/skills/index.tsx | 13 +- hermes_cli/tools_config.py | 16 ++ hermes_cli/web_server.py | 67 ++++- tests/hermes_cli/test_tools_config.py | 8 + tests/hermes_cli/test_web_server.py | 24 +- web/src/pages/SkillsPage.tsx | 4 +- 20 files changed, 826 insertions(+), 668 deletions(-) create mode 100644 apps/desktop/src/app/cron/cron-job-actions-menu.tsx create mode 100644 apps/desktop/src/app/settings/credential-key-ui.tsx create mode 100644 apps/desktop/src/app/settings/env-var-actions-menu.tsx diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 5875f1eb3f..7fd015efec 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -27,6 +27,7 @@ import { Palette, Plus, Settings, + Settings2, Sun, Users, Wrench, @@ -105,7 +106,18 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: strin tab: 'providers&pview=keys' }, { icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' }, - { icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'Tools & Keys', tab: 'keys' }, + { + icon: KeyRound, + keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'], + label: 'Tools & Keys', + tab: 'keys&kview=tools' + }, + { + icon: Settings2, + keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'], + label: 'Tools & Keys settings', + tab: 'keys&kview=settings' + }, { icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' }, { icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' }, { icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' } diff --git a/apps/desktop/src/app/cron/cron-job-actions-menu.tsx b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx new file mode 100644 index 0000000000..9e576c9ea7 --- /dev/null +++ b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx @@ -0,0 +1,108 @@ +import type * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { triggerHaptic } from '@/lib/haptics' + +interface CronJobActions { + busy?: boolean + isPaused: boolean + title: string + onDelete: () => void + onEdit: () => void + onPauseResume: () => void + onTrigger: () => void +} + +interface CronJobActionsMenuProps + extends CronJobActions, Pick, 'align' | 'sideOffset'> { + children: React.ReactNode +} + +export function CronJobActionsMenu({ + align = 'end', + busy = false, + children, + isPaused, + onDelete, + onEdit, + onPauseResume, + onTrigger, + sideOffset = 6, + title +}: CronJobActionsMenuProps) { + return ( + + {children} + + { + triggerHaptic('selection') + onPauseResume() + }} + > + + {isPaused ? 'Resume' : 'Pause'} + + + { + triggerHaptic('selection') + onTrigger() + }} + > + + Trigger now + + + { + triggerHaptic('selection') + onEdit() + }} + > + + Edit + + + { + triggerHaptic('warning') + onDelete() + }} + variant="destructive" + > + + Delete + + + + ) +} + +interface CronJobActionsTriggerProps extends Omit, 'size' | 'variant'> { + title: string +} + +export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) { + return ( + + ) +} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index fe5ef0d5cb..40e06e237d 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -27,12 +27,13 @@ import { updateCronJob } from '@/hermes' import { AlertTriangle, Clock } from '@/lib/icons' -import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { OverlayView } from '../overlays/overlay-view' +import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu' + const DEFAULT_DELIVER = 'local' const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [ @@ -563,47 +564,27 @@ function CronJobRow({ )} -
- + - - - - - - - - - - - + event.stopPropagation()} + title={jobTitle(job)} + /> +
) } -function IconAction({ children, className, ...props }: Omit, 'size' | 'variant'>) { - return ( - - ) -} - function EmptyState({ actionLabel, description, diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 6a2dbdcee6..b667c852be 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -18,6 +18,8 @@ import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' +import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui' +import { ListRow } from '../settings/primitives' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { PageSearchShell } from '../page-search-shell' @@ -108,6 +110,47 @@ const FIELD_COPY: Record Required -
+
{requiredFields.length > 0 ? ( requiredFields.map(field => ( 0 && (
Recommended -
+
{optionalFields.map(field => ( {showAdvanced && ( -
+
{advancedFields.map(field => ( -
- - {field.is_set && Saved} -
-
- onEdit(field.key, event.target.value)} - placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder} - type={field.is_password ? 'password' : 'text'} - value={edits[field.key] || ''} - /> - {field.url && ( - - )} - {field.is_set && ( - - )} -
- {copy.help &&

{copy.help}

} -
+ + onEdit(field.key, event.target.value)} + placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder} + type={field.is_password ? 'password' : 'text'} + value={edits[field.key] || ''} + /> + {field.url && ( + + )} + {field.is_set && ( + + )} +
+ } + description={copy.help} + title={ + + + {field.is_set && Saved} + + } + /> ) } diff --git a/apps/desktop/src/app/settings/credential-key-ui.tsx b/apps/desktop/src/app/settings/credential-key-ui.tsx new file mode 100644 index 0000000000..4c916b3c04 --- /dev/null +++ b/apps/desktop/src/app/settings/credential-key-ui.tsx @@ -0,0 +1,226 @@ +import { type ChangeEvent, type KeyboardEvent } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ExternalLink, Loader2, Save } from '@/lib/icons' +import { cn } from '@/lib/utils' +import type { EnvVarInfo } from '@/types/hermes' + +import { CONTROL_TEXT } from './constants' +import { prettyName, withoutKey } from './helpers' +import { ListRow } from './primitives' +import type { EnvRowProps } from './types' + +export type KeyRowProps = Omit + +/** Matches Advanced / config field controls (ListRow + Input). */ +export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT) + +export const isKeyVar = (key: string, info: EnvVarInfo) => + info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key) + +export const friendlyFieldLabel = (key: string, info: EnvVarInfo) => + info.description?.trim() || + key + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b\w/g, c => c.toUpperCase()) + +export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string => + isKeyVar(key, info) ? `Paste ${label} key` : /URL$/i.test(key) ? 'https://…' : 'Optional' + +// A single credential field: a set key shows as a filled read-only input +// (redacted value) that edits in place on click. Save appears once typed; a set +// key also offers Remove, and Esc cancels without closing the overlay. +export function KeyField({ + info, + placeholder, + rowProps, + varKey +}: { + info: EnvVarInfo + placeholder?: string + rowProps: KeyRowProps + varKey: string +}) { + const { edits, onClear, onSave, saving, setEdits } = rowProps + const editing = edits[varKey] !== undefined + const draft = edits[varKey] ?? '' + const dirty = draft.trim().length > 0 + const busy = saving === varKey + const masked = info.redacted_value ?? '••••••••' + const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) + const cancel = () => setEdits(c => withoutKey(c, varKey)) + const update = (e: ChangeEvent) => setEdits(c => ({ ...c, [varKey]: e.target.value })) + + const keydown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && dirty) { + void onSave(varKey) + } else if (e.key === 'Escape' && editing) { + e.preventDefault() + e.stopPropagation() + cancel() + } + } + + const editType = info.is_password ? 'password' : 'text' + + if (info.is_set && !editing) { + return ( + + ) + } + + return ( +
+
+ + {dirty && ( + + )} +
+ {editing && ( +
+ {info.is_set && ( + <> + + or + + )} + esc to cancel +
+ )} +
+ ) +} + +function CredentialDocsLink({ href }: { href: string }) { + return ( + e.stopPropagation()} + rel="noreferrer" + target="_blank" + > + Get a key + + + ) +} + +/** One credential row — same ListRow layout as Advanced config fields. */ +export function CredentialKeyCard({ + info, + label, + placeholder, + rowProps, + varKey +}: { + info: EnvVarInfo + label: string + placeholder: string + rowProps: KeyRowProps + varKey: string +}) { + const docsUrl = info.url?.trim() + const description = info.description?.trim() + + return ( + } + below={docsUrl ? : undefined} + description={description} + title={label} + /> + ) +} + +/** Provider API key group — primary + optional advanced fields as ListRows. */ +export function ProviderKeyRows({ + group, + rowProps +}: { + group: ProviderKeyRowGroup + rowProps: KeyRowProps +}) { + const docsUrl = group.docsUrl?.trim() + const description = group.description?.trim() + const docsBelow = docsUrl ? : undefined + + return ( + <> + + } + below={docsBelow} + description={description} + title={group.name} + /> + {group.advanced.map(([key, info]) => { + const fieldLabel = isKeyVar(key, info) ? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, '')) : friendlyFieldLabel(key, info) + + return ( + + } + key={key} + title={fieldLabel} + /> + ) + })} + + ) +} + +export function credentialRowLabel(varKey: string, info: EnvVarInfo): string { + if (isKeyVar(varKey, info)) { + return prettyName(varKey.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, '')) + } + + return prettyName(varKey) +} + +export interface ProviderKeyRowGroup { + advanced: [string, EnvVarInfo][] + description?: string + docsUrl?: string + name: string + primary: [string, EnvVarInfo] +} diff --git a/apps/desktop/src/app/settings/env-credentials.tsx b/apps/desktop/src/app/settings/env-credentials.tsx index 5bcfd8f9ba..f0ea858ad1 100644 --- a/apps/desktop/src/app/settings/env-credentials.tsx +++ b/apps/desktop/src/app/settings/env-credentials.tsx @@ -1,15 +1,10 @@ import { useEffect, useState } from 'react' -import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' -import { Input } from '@/components/ui/input' import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes' -import { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons' -import { cn } from '@/lib/utils' +import { type IconComponent } from '@/lib/icons' import { notify, notifyError } from '@/store/notifications' import type { EnvVarInfo } from '@/types/hermes' -import { CONTROL_TEXT } from './constants' import { asText, includesQuery, redactedValue, withoutKey } from './helpers' import { Pill } from './primitives' import type { EnvRowProps } from './types' @@ -32,150 +27,6 @@ export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, ) } -function EnvActions({ - varKey, - info, - saving, - onEdit, - onClear, - onReveal, - isRevealed, - showReveal = true -}: EnvActionsProps) { - return ( -
- {info.url && ( - - )} - {info.is_set && showReveal && ( - - )} - - {info.is_set && ( - - )} -
- ) -} - -export function EnvVarRow({ - varKey, - info, - edits, - revealed, - saving, - setEdits, - onSave, - onClear, - onReveal, - compact = false -}: EnvRowProps) { - const isEditing = edits[varKey] !== undefined - const isRevealed = revealed[varKey] !== undefined - const value = isRevealed ? revealed[varKey] : info.redacted_value - const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) - - if (compact && !isEditing) { - return ( -
-
-
{varKey}
-
{info.description}
-
- -
- ) - } - - return ( -
-
-
-
- {varKey} - - {info.is_set && } - {info.is_set ? 'Set' : 'Not set'} - -
-

{info.description}

-
- -
- - {!isEditing && info.is_set && ( -
- {value || '---'} -
- )} - - {isEditing && ( -
- setEdits(c => ({ ...c, [varKey]: e.target.value }))} - placeholder={info.is_set ? 'Replace current value' : 'Enter value'} - type={info.is_password ? 'password' : 'text'} - value={edits[varKey]} - /> - - -
- )} -
- ) -} - export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) { return (
@@ -336,17 +187,6 @@ interface CategoryHeadingProps { title: string } -interface EnvActionsProps { - varKey: string - info: EnvVarInfo - saving: string | null - onEdit: () => void - onClear: (key: string) => void - onReveal: (key: string) => void - isRevealed: boolean - showReveal?: boolean -} - interface UseEnvCredentials { rowProps: Omit saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }> diff --git a/apps/desktop/src/app/settings/env-var-actions-menu.tsx b/apps/desktop/src/app/settings/env-var-actions-menu.tsx new file mode 100644 index 0000000000..709d3aee91 --- /dev/null +++ b/apps/desktop/src/app/settings/env-var-actions-menu.tsx @@ -0,0 +1,130 @@ +import type * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Eye, EyeOff, ExternalLink, Trash2 } from '@/lib/icons' +import { triggerHaptic } from '@/lib/haptics' +import { cn } from '@/lib/utils' + +interface EnvVarActionsMenuProps + extends Pick, 'align' | 'sideOffset'> { + children: React.ReactNode + clearDisabled?: boolean + docsUrl?: string | null + isRevealed?: boolean + isSet: boolean + label: string + onClear?: () => void + onEdit: () => void + onReveal?: () => void + showReveal?: boolean +} + +export function EnvVarActionsMenu({ + align = 'end', + children, + clearDisabled = false, + docsUrl, + isRevealed = false, + isSet, + label, + onClear, + onEdit, + onReveal, + showReveal = true, + sideOffset = 6 +}: EnvVarActionsMenuProps) { + const hasClear = isSet && onClear + const hasReveal = isSet && showReveal && onReveal + const hasDocs = Boolean(docsUrl?.trim()) + + return ( + + {children} + + {hasDocs && ( + { + event.preventDefault() + triggerHaptic('selection') + window.open(docsUrl!, '_blank', 'noopener,noreferrer') + }} + > + + Docs + + )} + + {hasReveal && ( + { + triggerHaptic('selection') + onReveal() + }} + > + {isRevealed ? : } + {isRevealed ? 'Hide value' : 'Reveal value'} + + )} + + { + triggerHaptic('selection') + onEdit() + }} + > + + {isSet ? 'Replace' : 'Set'} + + + {hasClear && ( + <> + + { + triggerHaptic('warning') + onClear() + }} + variant="destructive" + > + + Clear + + + )} + + + ) +} + +interface EnvVarActionsTriggerProps extends Omit, 'size' | 'variant'> { + label: string +} + +export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActionsTriggerProps) { + return ( + + ) +} diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 097b9cfed4..ff793e4a00 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { HermesConfigRecord } from '@/types/hermes' -import { getNested, providerGroup, setNested } from './helpers' +import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers' describe('settings helpers', () => { it('reads and writes nested config paths', () => { @@ -21,6 +21,26 @@ describe('settings helpers', () => { expect(({} as Record).polluted).toBeUndefined() }) + describe('stripToolsetLabel', () => { + it('removes leading emoji prefixes from registry labels', () => { + expect(stripToolsetLabel('⏰ Cron Jobs')).toBe('Cron Jobs') + expect(stripToolsetLabel('⚡ Code Execution')).toBe('Code Execution') + expect(stripToolsetLabel('❓ Clarifying Questions')).toBe('Clarifying Questions') + expect(stripToolsetLabel('🌐 Browser Automation')).toBe('Browser Automation') + expect(stripToolsetLabel('🎨 Image Generation')).toBe('Image Generation') + }) + + it('leaves plain titles unchanged', () => { + expect(stripToolsetLabel('Terminal & Processes')).toBe('Terminal & Processes') + }) + }) + + describe('toolsetDisplayLabel', () => { + it('strips emoji from toolset rows', () => { + expect(toolsetDisplayLabel({ name: 'cronjob', label: '⏰ Cron Jobs' })).toBe('Cron Jobs') + }) + }) + describe('providerGroup', () => { it('maps a provider env var to its labeled group', () => { expect(providerGroup('XAI_API_KEY')).toBe('xAI') diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 1c4f61f9a5..d08bc5a607 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -8,6 +8,13 @@ export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase(). export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) +/** Strip leading emoji from toolset titles (CLI registry prefixes labels with icons). */ +export const stripToolsetLabel = (label: string): string => + label.replace(/^[\p{Emoji}\p{Extended_Pictographic}\s]+/u, '').trim() || label + +export const toolsetDisplayLabel = (toolset: Pick): string => + stripToolsetLabel(asText(toolset.label || toolset.name)) + export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : []) export const withoutKey = (record: Record, key: string) => { diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index a2580723ca..24b989ab8d 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -3,7 +3,7 @@ import { useRef } from 'react' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { triggerHaptic } from '@/lib/haptics' -import { Archive, Globe, Info, KeyRound, Sparkles, Wrench, Zap } from '@/lib/icons' +import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons' import { notifyError } from '@/store/notifications' import { useRouteEnumParam } from '../hooks/use-route-enum-param' @@ -16,7 +16,7 @@ import { AppearanceSettings } from './appearance-settings' import { ConfigSettings } from './config-settings' import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' -import { KeysSettings } from './keys-settings' +import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings' import { McpSettings } from './mcp-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { SessionsSettings } from './sessions-settings' @@ -37,12 +37,18 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang // Providers subnav (Accounts vs API keys) lives in its own param so each // sub-view is deep-linkable and survives a refresh. const [providerView, setProviderView] = useRouteEnumParam('pview', PROVIDER_VIEWS, 'accounts') + const [keysView, setKeysView] = useRouteEnumParam('kview', KEYS_VIEWS, 'tools') const openProviderView = (view: ProviderView) => { setActiveView('providers') setProviderView(view) } + const openKeysView = (view: KeysView) => { + setActiveView('keys') + setKeysView(view) + } + const importInputRef = useRef(null) const exportConfig = async () => { @@ -129,6 +135,24 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang label="Tools & Keys" onClick={() => setActiveView('keys')} /> + {activeView === 'keys' && ( +
+ openKeysView('tools')} + /> + openKeysView('settings')} + /> +
+ )} ) : activeView === 'keys' ? ( - + ) : activeView === 'mcp' ? ( ) : ( diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx index a09950f1cd..9918451f32 100644 --- a/apps/desktop/src/app/settings/keys-settings.tsx +++ b/apps/desktop/src/app/settings/keys-settings.tsx @@ -1,155 +1,75 @@ -import { useMemo, useState } from 'react' +import { useMemo } from 'react' -import { Settings2, Wrench } from '@/lib/icons' -import { cn } from '@/lib/utils' import type { EnvVarInfo } from '@/types/hermes' -import { EnvVarRow, useEnvCredentials } from './env-credentials' +import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui' +import { useEnvCredentials } from './env-credentials' import { asText } from './helpers' import { LoadingState, SettingsContent } from './primitives' +// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx). +export const KEYS_VIEWS = ['tools', 'settings'] as const + +export type KeysView = (typeof KEYS_VIEWS)[number] + // Providers live on their own page; messaging-platform credentials live on the // dedicated Messaging page (and are hidden here via `channel_managed`). This // view covers tool API keys plus server/setting env vars (API server, webhook, -// gateway), which fold into the Settings tab. -const KEY_TABS = [ - { icon: Wrench, id: 'tool', label: 'Tools' }, - { icon: Settings2, id: 'setting', label: 'Settings' } -] as const +// gateway), which fold into the Settings subnav. -type KeyCategoryId = (typeof KEY_TABS)[number]['id'] - -const CATEGORY_LABELS: Record = { - setting: 'Settings', - tool: 'Tools' +// Backend categories that surface under each subnav. Platform credentials use the +// `messaging` category but are flagged ``channel_managed`` and configured on +// the Messaging page; only gateway-wide ``messaging`` rows (e.g. GATEWAY_PROXY) +// appear here alongside ``setting``. +const VIEW_CATEGORIES: Record = { + settings: ['setting', 'messaging'], + tools: ['tool'] } -// Backend categories that surface under each tab. Server/gateway vars carry the -// `messaging` category server-side but belong with general settings here, since -// the platform-credential half of `messaging` is owned by the Messaging page. -const TAB_CATEGORIES: Record = { - setting: ['setting', 'messaging'], - tool: ['tool'] -} - -function tabForCategory(category: string): KeyCategoryId | null { - for (const tab of KEY_TABS) { - if (TAB_CATEGORIES[tab.id].includes(category)) { - return tab.id - } - } - - return null -} - -function CategoryTabs({ - active, - counts, - onSelect -}: { - active: KeyCategoryId - counts: Record - onSelect: (id: KeyCategoryId) => void -}) { - return ( -
- {KEY_TABS.map(tab => { - const isActive = active === tab.id - const count = counts[tab.id] - - return ( - - ) - })} -
- ) -} - -export function KeysSettings() { +export function KeysSettings({ view }: KeysSettingsProps) { const { rowProps, vars } = useEnvCredentials() - const [activeCategory, setActiveCategory] = useState('tool') const groups = useMemo(() => { if (!vars) { return [] } - return KEY_TABS.map(t => t.id).flatMap(tab => { - const cats = TAB_CATEGORIES[tab] + return KEYS_VIEWS.flatMap(v => { + const cats = VIEW_CATEGORIES[v] const entries = Object.entries(vars) .filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category))) .sort(([a], [b]) => a.localeCompare(b)) - return entries.length === 0 ? [] : [{ category: tab, label: CATEGORY_LABELS[tab], entries }] + return entries.length === 0 ? [] : [{ category: v, entries }] }) }, [vars]) - // Tab badge counts reflect how many keys are set per tab. Channel-managed - // credentials are owned by the Messaging page and excluded here. - const categoryCounts = useMemo>(() => { - const counts: Record = { setting: 0, tool: 0 } - - if (!vars) { - return counts - } - - for (const info of Object.values(vars)) { - if (!info.is_set || info.channel_managed) { - continue - } - - const tab = tabForCategory(asText(info.category)) - - if (tab) { - counts[tab] += 1 - } - } - - return counts - }, [vars]) - if (!vars) { return } - const visible = groups.filter(g => g.category === activeCategory) + const visible = groups.filter(g => g.category === view) return ( - - {visible.map(group => ( -
-
- {group.entries.map(([key, info]: [string, EnvVarInfo]) => ( - - ))} -
-
+
+ {group.entries.map(([key, info]: [string, EnvVarInfo]) => { + const label = credentialRowLabel(key, info) + + return ( + + ) + })} +
))} {visible.length === 0 && ( @@ -160,3 +80,7 @@ export function KeysSettings() {
) } + +interface KeysSettingsProps { + view: KeysView +} diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index a29f5440a9..759d61a44d 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react' -import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { FEATURED_ID, @@ -9,43 +9,25 @@ import { sortProviders } from '@/components/desktop-onboarding-overlay' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { listOAuthProviders } from '@/hermes' -import { ChevronDown, ExternalLink, KeyRound, Loader2, Save } from '@/lib/icons' +import { ChevronDown, KeyRound } from '@/lib/icons' import { cn } from '@/lib/utils' import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding' import type { EnvVarInfo, OAuthProvider } from '@/types/hermes' +import { isKeyVar, ProviderKeyRows } from './credential-key-ui' import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' -import { providerGroup, providerMeta, providerPriority, withoutKey } from './helpers' +import { providerGroup, providerMeta, providerPriority } from './helpers' import { LoadingState, SettingsContent } from './primitives' -import type { EnvRowProps } from './types' // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. export const PROVIDER_VIEWS = ['accounts', 'keys'] as const export type ProviderView = (typeof PROVIDER_VIEWS)[number] -const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key) - -const friendlyFieldLabel = (key: string, info: EnvVarInfo) => - info.description?.trim() || - key - .replace(/_/g, ' ') - .toLowerCase() - .replace(/\b\w/g, c => c.toUpperCase()) - -// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not -// keys — so don't reuse the "Paste key" placeholder that makes them read as a -// duplicate key input. URL-ish vars get a URL hint; everything else stays optional. -const advancedPlaceholder = (key: string, info: EnvVarInfo): string => - isKeyVar(key, info) ? 'Paste key' : /URL$/i.test(key) ? 'https://…' : 'Optional' - -// Group the env catalog by provider so the keys view can render one collapsible -// row per vendor: a primary key field inline, with any secondary / advanced vars -// (base URL overrides, alt tokens) revealed when the row is focused/expanded. -// Mirrors what Cursor's API-keys section does. Groups without a key field (e.g. -// Nous Portal's lone base-URL override) and the "Other" bucket are skipped. +// Group the env catalog by provider — one ListRow per vendor plus optional +// advanced overrides (base URL, region, etc.). Groups without a key field and +// the "Other" bucket are skipped. function buildProviderKeyGroups(vars: Record): ProviderKeyGroup[] { const buckets = new Map() @@ -94,228 +76,6 @@ function buildProviderKeyGroups(vars: Record): ProviderKeyGr return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) } -// A single credential field: a set key shows as a filled read-only input -// (redacted value) that edits in place on click. Save appears once typed; a set -// key also offers Remove, and Esc cancels without closing the overlay. -function KeyField({ - compact = false, - info, - label, - placeholder, - rowProps, - varKey -}: { - compact?: boolean - info: EnvVarInfo - label?: string - placeholder?: string - rowProps: KeyRowProps - varKey: string -}) { - const { edits, onClear, onSave, saving, setEdits } = rowProps - const editing = edits[varKey] !== undefined - const draft = edits[varKey] ?? '' - const dirty = draft.trim().length > 0 - const busy = saving === varKey - const masked = info.redacted_value ?? '••••••••' - const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) - const cancel = () => setEdits(c => withoutKey(c, varKey)) - const update = (e: ChangeEvent) => setEdits(c => ({ ...c, [varKey]: e.target.value })) - - // Enter saves; Esc cancels in place without bubbling to the overlay's window - // Escape listener (which would otherwise close the whole settings panel). - const keydown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && dirty) { - void onSave(varKey) - } else if (e.key === 'Escape' && editing) { - e.preventDefault() - e.stopPropagation() - cancel() - } - } - - // Advanced overrides render quieter (xs) than the primary key field so the key - // stays the visual anchor. Padding-driven sizing — no fixed heights. - const inputSize = compact ? 'xs' : 'sm' - const editType = info.is_password ? 'password' : 'text' - - // A set value reads as a single filled, read-only field (showing the redacted - // value). Clicking it drops into edit mode in place — no Replace/Cancel chrome. - const control = - info.is_set && !editing ? ( - - ) : ( -
-
- - {dirty && ( - - )} -
- {editing && ( -
- {info.is_set && ( - <> - - or - - )} - esc to cancel -
- )} -
- ) - - // Standard stacked form field: small muted label above, input below. Same shape - // for the primary key and every advanced override — just smaller when compact. - // Empty advanced inputs (not labels) fade back, brightening on hover/focus/set. - const dim = compact && !info.is_set - - return ( -
- {label && ( - - )} - {dim ? ( -
{control}
- ) : ( - control - )} -
- ) -} - -function ProviderKeyCard({ - expanded, - group, - onExpand, - onToggle, - rowProps -}: { - expanded: boolean - group: ProviderKeyGroup - onExpand: () => void - onToggle: () => void - rowProps: KeyRowProps -}) { - // Expandable when there's anything to reveal — advanced overrides and/or a - // "Get a key" docs link (which lives at the bottom of the expanded panel). - const expandable = group.advanced.length > 0 || Boolean(group.docsUrl) - - return ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onToggle() - } - } - : undefined - } - role={expandable ? 'button' : undefined} - tabIndex={expandable ? 0 : undefined} - > -
-
- - {group.name} - {expandable && ( - - )} -
-
e.stopPropagation()} - onFocus={() => { - if (expandable && !expanded) { - onExpand() - } - }} - > - -
-
- {expandable && expanded && ( -
e.stopPropagation()}> - {group.advanced.map(([key, info]) => ( - - ))} - {group.docsUrl && ( - e.stopPropagation()} - rel="noreferrer" - target="_blank" - > - Get a key - - - )} -
- )} -
- ) -} - // Deliberately a near-1:1 replica of the first-run onboarding picker // (`Picker` in desktop-onboarding-overlay): same recommended card, same // provider rows, same "Other providers" disclosure, same OpenRouter quick-key @@ -405,8 +165,6 @@ function NoProviderKeys() { export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) { const { rowProps, vars } = useEnvCredentials() const [oauthProviders, setOauthProviders] = useState([]) - // Single-open accordion for the per-provider "advanced options" panels. - const [openProvider, setOpenProvider] = useState(null) // The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we // re-read connection state when the user finishes (or dismisses) a sign-in // they launched from this page — otherwise the cards keep their stale status. @@ -450,16 +208,9 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps return ( {keyGroups.length > 0 ? ( -
+
{keyGroups.map(group => ( - setOpenProvider(group.name)} - onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))} - rowProps={rowProps} - /> + ))}
) : ( @@ -476,8 +227,6 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps ) } -type KeyRowProps = Omit - interface ProviderKeyGroup { advanced: [string, EnvVarInfo][] description?: string diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index 7ec34e5dea..d766f92675 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -4,11 +4,12 @@ import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes' -import { Check, ExternalLink, Eye, EyeOff, Loader2, Save, Trash2 } from '@/lib/icons' +import { Check, Loader2, Save } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes' +import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu' import { Pill } from './primitives' interface ToolsetConfigPanelProps { @@ -108,35 +109,20 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {

{envVar.prompt}

)}
-
- {envVar.url && ( - - )} - {isSet && ( - - )} - - {isSet && ( - - )} -
+ {!editing && ( + void handleClear()} + onEdit={() => setEditing(true)} + onReveal={() => void handleReveal()} + > + event.stopPropagation()} /> + + )}
{isSet && revealed !== null && ( diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx index 1243cc1d87..9f195f786d 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/skills/index.test.tsx @@ -74,6 +74,17 @@ describe('SkillsView toolset management', () => { await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false)) }) + it('renders toolset titles without leading emoji', async () => { + getToolsets.mockResolvedValue([ + toolset({ name: 'cronjob', label: '⏰ Cron Jobs', description: 'cron tools' }) + ]) + + await renderSkills() + + expect(screen.getByText('Cron Jobs')).toBeTruthy() + expect(screen.queryByText(/⏰/)).toBeNull() + }) + it('keeps the configured pill alongside the switch', async () => { await renderSkills() diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 74cbc53d73..7661efef9a 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -14,7 +14,7 @@ import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { PAGE_INSET_X } from '../layout-constants' import { PageSearchShell } from '../page-search-shell' -import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers' +import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers' import { ToolsetConfigPanel } from '../settings/toolset-config-panel' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' @@ -52,14 +52,17 @@ function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[] return true } + const label = toolsetDisplayLabel(toolset) + return ( includesQuery(toolset.name, q) || + includesQuery(label, q) || includesQuery(toolset.label, q) || includesQuery(toolset.description, q) || toolNames(toolset).some(name => includesQuery(name, q)) ) }) - .sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name))) + .sort((a, b) => toolsetDisplayLabel(a).localeCompare(toolsetDisplayLabel(b))) } interface SkillsViewProps extends React.ComponentProps<'section'> { @@ -167,10 +170,10 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p notify({ kind: 'success', title: enabled ? 'Toolset enabled' : 'Toolset disabled', - message: `${asText(toolset.label || toolset.name)} applies to new sessions.` + message: `${toolsetDisplayLabel(toolset)} applies to new sessions.` }) } catch (err) { - notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`) + notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`) } finally { setSavingToolset(null) } @@ -264,7 +267,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
{visibleToolsets.map(toolset => { const tools = toolNames(toolset) - const label = asText(toolset.label || toolset.name) + const label = toolsetDisplayLabel(toolset) const expanded = expandedToolset === toolset.name return ( diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 57b1adb750..50f1f9f860 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -82,6 +82,22 @@ CONFIGURABLE_TOOLSETS = [ ("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"), ] + +def gui_toolset_label(label: str) -> str: + """Strip leading emoji/icons from toolset titles for GUI surfaces. + + Registry labels use `` ``; plugin toolsets prefix with ``🔌``. + CLI/TUI keeps the raw ``label`` — only HTTP APIs call this helper. + """ + text = (label or "").strip() + if not text: + return text + parts = text.split(None, 1) + if len(parts) == 2 and parts[0] and not any(ch.isascii() and ch.isalnum() for ch in parts[0]): + return parts[1].strip() + return text + + # Toolsets that are OFF by default for new installs. # They're still in _HERMES_CORE_TOOLS (available at runtime if enabled), # but the setup checklist won't pre-select them for first-time users. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index fcd97cc76b..233245de33 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2833,18 +2833,66 @@ def _channel_managed_env_keys() -> frozenset[str]: return frozenset() +# Cross-cutting gateway / relay knobs stay on the Keys → Settings tab even though +# they use the ``messaging`` category in OPTIONAL_ENV_VARS. Platform-scoped vars +# (``DISCORD_*``, ``MATRIX_*``, …) are owned by the Messaging UI instead. +_MESSAGING_KEYS_PAGE_KEYS = frozenset({ + "GATEWAY_ALLOW_ALL_USERS", + "GATEWAY_PROXY_KEY", + "GATEWAY_PROXY_URL", +}) + + +def _platform_env_prefixes(platform_id: str) -> tuple[str, ...]: + """Env-var prefixes owned by a messaging platform card.""" + aliases: dict[str, tuple[str, ...]] = { + "email": ("EMAIL_",), + "homeassistant": ("HASS_",), + "qqbot": ("QQ_", "QQBOT_"), + "sms": ("TWILIO_",), + "wecom": ("WECOM_BOT_", "WECOM_SECRET"), + "wecom_callback": ("WECOM_CALLBACK_",), + } + if platform_id in aliases: + return aliases[platform_id] + return (platform_id.upper().replace("-", "_") + "_",) + + +def _discover_platform_env_vars(platform_id: str) -> tuple[str, ...]: + """All messaging-category env vars for a platform (override + plugin + prefix).""" + prefixes = _platform_env_prefixes(platform_id) + keys: list[str] = [] + for name, info in OPTIONAL_ENV_VARS.items(): + if info.get("category") != "messaging": + continue + if name in _MESSAGING_KEYS_PAGE_KEYS: + continue + if not any(name.startswith(prefix) for prefix in prefixes): + continue + keys.append(name) + return tuple(sorted(set(keys))) + + +def _merge_platform_env_vars( + platform_id: str, + override: dict[str, Any], + plugin_entry: Any | None, +) -> tuple[str, ...]: + """Canonical env-var list for a messaging platform card.""" + discovered = _discover_platform_env_vars(platform_id) + if "env_vars" in override: + return tuple(dict.fromkeys((*override["env_vars"], *discovered))) + if plugin_entry is not None and plugin_entry.required_env: + return tuple(dict.fromkeys((*tuple(plugin_entry.required_env), *discovered))) + return discovered + + def _build_catalog_entry( platform_id: str, plugin_entry: Any | None = None ) -> dict[str, Any]: override = _PLATFORM_OVERRIDES.get(platform_id, {}) - if "env_vars" in override: - env_vars: tuple[str, ...] = tuple(override["env_vars"]) - elif plugin_entry is not None and plugin_entry.required_env: - env_vars = tuple(plugin_entry.required_env) - else: - prefix = platform_id.upper() + "_" - env_vars = tuple(k for k in OPTIONAL_ENV_VARS if k.startswith(prefix)) + env_vars = _merge_platform_env_vars(platform_id, override, plugin_entry) if "required_env" in override: required_env = tuple(override["required_env"]) @@ -6663,6 +6711,7 @@ async def get_toolsets(): _get_effective_configurable_toolsets, _get_platform_tools, _toolset_has_keys, + gui_toolset_label, ) from toolsets import resolve_toolset @@ -6680,7 +6729,9 @@ async def get_toolsets(): tools = [] is_enabled = name in enabled_toolsets result.append({ - "name": name, "label": label, "description": desc, + "name": name, + "label": gui_toolset_label(label), + "description": desc, "enabled": is_enabled, "available": is_enabled, "configured": _toolset_has_keys(name, config), diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 008ffe1fd4..5b24d2b6eb 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -21,6 +21,7 @@ from hermes_cli.tools_config import ( _toolset_needs_configuration_prompt, CONFIGURABLE_TOOLSETS, TOOL_CATEGORIES, + gui_toolset_label, _visible_providers, tools_command, ) @@ -79,6 +80,13 @@ def test_get_platform_tools_uses_default_when_platform_not_configured(): assert enabled.isdisjoint(_DEFAULT_OFF_TOOLSETS) +def test_gui_toolset_label_strips_leading_emoji(): + assert gui_toolset_label("🔍 Web Search & Scraping") == "Web Search & Scraping" + assert gui_toolset_label("👁️ Vision / Image Analysis") == "Vision / Image Analysis" + assert gui_toolset_label("🔌 My Plugin") == "My Plugin" + assert gui_toolset_label("Terminal & Processes") == "Terminal & Processes" + + def test_configurable_toolsets_include_messaging(): assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 1b898526d0..592d62c44f 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -791,6 +791,24 @@ class TestWebServerEndpoints: for key, info in data.items(): assert info["channel_managed"] is (key in channel_keys) + def test_platform_scoped_messaging_env_vars_are_channel_managed(self): + from hermes_cli.web_server import ( + _MESSAGING_KEYS_PAGE_KEYS, + _build_catalog_entry, + _channel_managed_env_keys, + ) + + discord = _build_catalog_entry("discord") + assert "DISCORD_HOME_CHANNEL" in discord["env_vars"] + assert "DISCORD_ALLOW_ALL_USERS" in discord["env_vars"] + + managed = _channel_managed_env_keys() + assert "DISCORD_HOME_CHANNEL" in managed + assert "BLUEBUBBLES_ALLOW_ALL_USERS" in managed + assert "MATTERMOST_ALLOW_ALL_USERS" in managed + assert "GATEWAY_PROXY_URL" not in managed + assert "GATEWAY_PROXY_URL" in _MESSAGING_KEYS_PAGE_KEYS + def test_reveal_env_var(self, tmp_path): """POST /api/env/reveal should return the real unredacted value.""" from hermes_cli.config import save_env_value @@ -1919,7 +1937,7 @@ class TestNewEndpoints: assert resp.json() == [ { "name": "web", - "label": "🔍 Web Search & Scraping", + "label": "Web Search & Scraping", "description": "web_search, web_extract", "enabled": True, "available": True, @@ -1928,7 +1946,7 @@ class TestNewEndpoints: }, { "name": "skills", - "label": "📚 Skills", + "label": "Skills", "description": "list, view, manage", "enabled": True, "available": True, @@ -1937,7 +1955,7 @@ class TestNewEndpoints: }, { "name": "memory", - "label": "💾 Memory", + "label": "Memory", "description": "persistent memory across sessions", "enabled": False, "available": False, diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index d7a9ef6692..e26c807fe2 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -432,9 +432,7 @@ export default function SkillsPage() { <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> {filteredToolsets.map((ts) => { const TsIcon = toolsetIcon(ts.name); - const labelText = - ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || - ts.name; + const labelText = ts.label.trim() || ts.name; return ( <Card key={ts.name} className="relative rounded-none"> From 1eeb7da2e6e5463018cb4be9aff1ec97bb09488a Mon Sep 17 00:00:00 2001 From: ethernet <arilotter@gmail.com> Date: Thu, 4 Jun 2026 17:06:45 -0400 Subject: [PATCH 14/52] fix(desktop): slash commands bypass queue when busy and chip id suffix leak (#39289) Two fixes for desktop app slash command handling: 1. Slash commands submitted while the agent is busy now execute immediately instead of being queued. Previously submitDraft() unconditionally queued any draft when busy, but slash commands are client-side operations or self-contained gateway RPCs that should run regardless of busy state (matching TUI behavior). executeSlashCommand already has its own per-command busy guard for commands that genuinely need an idle session. 2. Slash command trigger items no longer leak the "|index" suffix from their item.id into the serialized chip text. The toItem callback now sets rawText in metadata so hermesDirectiveFormatter.serialize takes the direct-insertion path instead of the legacy @type:id fallback. This also means slash commands enter the composer as plain text (not chips), matching selectSkinSlashCommand and TUI behavior. --- .../chat/composer/hooks/use-slash-completions.ts | 9 ++++++++- apps/desktop/src/app/chat/composer/index.tsx | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts index 62c982d157..a56a6e3269 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -16,6 +16,7 @@ interface SlashItemMetadata extends Record<string, string> { command: string display: string meta: string + rawText?: string } function textValue(value: unknown, fallback = ''): string { @@ -91,7 +92,13 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }): const metadata: SlashItemMetadata = { command, display, - meta + meta, + // Provide rawText so hermesDirectiveFormatter.serialize uses the + // direct-insertion path instead of the legacy @type:id fallback. + // Without this, the item.id (which includes a "|index" suffix for + // trigger-adapter uniqueness) leaks into the serialized chip text + // and the submitted command. + rawText: command } return { diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 97e7d78a2d..7f14286e80 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' import { chatMessageText } from '@/lib/chat-messages' +import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' @@ -1037,7 +1038,19 @@ export function ChatBar({ if (queueEdit) { exitQueuedEdit('save') } else if (busy) { - if (hasComposerPayload) { + // Slash commands should execute immediately even while the agent is + // busy — they're client-side operations (/yolo, /skin, /new, /help, + // etc.) or self-contained gateway RPCs (/status, /compress). onSubmit + // routes them to executeSlashCommand, which has its own per-command + // busy guard for commands that genuinely need an idle session (skill + // /send directives). Queuing them would make every slash command wait + // for the current turn to finish, which is how the TUI never behaves. + if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) { + const submitted = draft + triggerHaptic('submit') + clearDraft() + void onSubmit(submitted) + } else if (hasComposerPayload) { queueCurrentDraft() } else { // Stop button: an explicit interrupt must actually halt the running From d29caf382868f8f5fb5e0c09f632f70f27e6e64e Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:44:03 -0600 Subject: [PATCH 15/52] fix(desktop): satisfy slash metadata typecheck --- .../src/app/chat/composer/hooks/use-slash-completions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts index a56a6e3269..f334415809 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -16,7 +16,7 @@ interface SlashItemMetadata extends Record<string, string> { command: string display: string meta: string - rawText?: string + rawText: string } function textValue(value: unknown, fallback = ''): string { From dfd6bcf1ff9ceae6fb893cadfc201bbd54cc0658 Mon Sep 17 00:00:00 2001 From: Austin Pickett <pickett.austin@gmail.com> Date: Thu, 4 Jun 2026 19:10:44 -0400 Subject: [PATCH 16/52] fix(desktop): restore accordion expand for credential settings rows (#39327) * fix(desktop): restore accordion expand for credential settings rows Reintroduce collapsible provider and tool key rows so descriptions, docs links, and advanced fields stay hidden until a row is expanded. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(desktop): add credential settings accordion screenshots for PR 39327 Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> --- .../39327/providers-collapsed.png | Bin 0 -> 37199 bytes .../39327/providers-expanded.png | Bin 0 -> 40402 bytes .../pr-screenshots/39327/tools-collapsed.png | Bin 0 -> 34039 bytes .../pr-screenshots/39327/tools-expanded.png | Bin 0 -> 36975 bytes .../src/app/settings/credential-key-ui.tsx | 235 ++++++++++++++---- .../src/app/settings/keys-settings.tsx | 12 +- .../src/app/settings/providers-settings.tsx | 12 +- 7 files changed, 206 insertions(+), 53 deletions(-) create mode 100755 .github/pr-screenshots/39327/providers-collapsed.png create mode 100755 .github/pr-screenshots/39327/providers-expanded.png create mode 100755 .github/pr-screenshots/39327/tools-collapsed.png create mode 100755 .github/pr-screenshots/39327/tools-expanded.png diff --git a/.github/pr-screenshots/39327/providers-collapsed.png b/.github/pr-screenshots/39327/providers-collapsed.png new file mode 100755 index 0000000000000000000000000000000000000000..523bd1b845ca7ea5df4f54dd58b18715e2feb682 GIT binary patch literal 37199 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 Yv>YSo$-mr&e>ouiQ0@WpzQK$C1yi@A@Bjb+ literal 0 HcmV?d00001 diff --git a/.github/pr-screenshots/39327/providers-expanded.png b/.github/pr-screenshots/39327/providers-expanded.png new file mode 100755 index 0000000000000000000000000000000000000000..ab8c4213f20b298d50b9078c6d9d31d64332c4b6 GIT binary patch literal 40402 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 GLSTY`)yk;= literal 0 HcmV?d00001 diff --git a/.github/pr-screenshots/39327/tools-collapsed.png b/.github/pr-screenshots/39327/tools-collapsed.png new file mode 100755 index 0000000000000000000000000000000000000000..d45ac3e5eb3f168503c75846a3387f0eadb3794b GIT binary patch literal 34039 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%0AZ{=gN6~60 z#V@X*xK9{X7F#{XR6n1nt6VhaUYFbM&_QJokoMNjW_vU?d%nIPO-T1&Y#GiQ{DH9s z1NbbkHmEI4S5&Rm_~@|>$)KOloT0W-sfMWS5{<R8ge301@8?d<c~cVct#$?lt@9(T z;EtYhobJVeC(Lwh92g;!v>UlRX!h&FaF~3oxZ_NVV``2C#!%=Gv<5bsaOUT#FUY5% zCFLFtKqGKO5ZQW)FYD({q$XzOM>I2@p*w1@iS8{eW`6!@jo;gsGK(N}Y}VN8cba)< zsX6%9H&$Gs`bN!0kmtya&fQUdrpCc18o-4g6=XD*rYk5&IPZi9me0Y)+S6y+vUb6Y zGXbFuY`4VE(k`UqIg1mNwHtw~HB6WCjydGkv;NbWSt-pw$^W)LH7v5(HqMPtd93DT zcn4^X2zmBc1zv7;CDO7xkH|Vs)zV(L;9g&2T8ck0Ks38M7ntv+mbo#t{&FQodmFSA zhEEd3rl|g{fD_uJr(f1kA>KA|HRW`{g*$8VYn8m;GwT|gs94>j&k4aqi>(df=jP^i zl(@9Cv{W0p!iJVpPG{__UNxf+XGzsI0ZEVWWcuWY9%|6V%~uQ-bar^%d#2xN$vp-x zKKvG3r|8ANxYtKy9)AD+fmz7S-OSz4bgoOefzCC?&F>c&PJm`{?z8R&^VZ-^^Gk|D z6^y{i|Ir0Beu4OE&KgR-(#L(Q$eDl4$Os&zXHfZ{VB^_VRatTOoY5WO!avPMc^P4* z@l)7m`Lw`i4a$h?9DC-ze!f55{5Kyz{TJH&{|9uxwf*)C*Rv#-&S~et0{|odQbK)s z`ugd&JM8RS^7!%KzVz`btaXD3KfmY9L@Jkhlk<<CZI({VphuQ<AlOoOuqM9bF!4LY z^u>>##jpB&+Q!EX_NywRy2_b4Be{2?Qf8fkHzsYa3mm$vj^ryw-7J+Rh41f!NfMuZ zu-$U7>5R(h?vCZ62xI#-<jE4^;s*O{fy6DQ&Y2wGTFs_Fi3#`{YJDJG+RVLQv}n64 zco-eHGkb;0Eq5J<6Y*M;C?3Uc22-$rwbvMz`t>z<+P9Lor58?iS;*+a&G}Cf(c(m| znR<7dmoL8@>RF;&0vp^%3St1bjLiU;Zb77Xz2#s-nvvVF9s(dcJ3D)tyoRf5MH)R5 zQ{YN&Vm$dhs9hOFmK5vZta+QBnD|7dkeWex%3=EjK?ih8b9;TX!$lu~bI27&E{N*d zzN5bSP{qvrz0n+)&rDqfg%{r9)pm+OS^CzkAN?=HOq_ZXexZYRnq#2BhriAU3T{ID z%!|--d)P*##FxV8X!zd-vwI{we1E4kU_!{7QtbQt=UY%nd##ROrkxE;fp-G{jwy^t z8ZR*-jOgf;KH~?lY_k~NRQY;{D_U-U*}B;;KZk=ClM5zsR^(1aj+{!7fL6Kb2KL*} zpRX1=qs3w6RzsDRX@vYmZC_b8j^2N2=p!-asNXv~t?RYZkwJ{yvaLt^#qVvzV~b4n z)m5I&1_CG;8EqBtXfH2l-!>!wX5E{h62vMF(8X-EZR||f+11z2o37Dla9*=yzhRHp zm*SO&9Hk4KGTA$S_N<G$A?PmT%y{C)t=Z%S!p>i~5DU}au3LlwCbHUgUy<(8rAym* z9%vz~?0|%$AlJq@w|I6qxVTLCo^7^ZGuEdOn-dkYd!Jg405YF%?67&o4EwS*kUkA( z7W%z^nVtR4ohw(uIPRK*{6aw%c9v&FD_sxvXiyqHI@jz^H0eeGe0}-y<!jKyc^B25 zSLp@C9Es5A%{#eEk&%&=N+g6}BG0{h4%4;i8*5WFylr(SKnj-u(a816CtOsSS)Yc3 zwbQzBXdTuBIBy_;)hrKQe~Q+y)`1|SSU`Qhjby5t-4pz@xSk7<+nmk!j%H2tSetNN zpAr=~x#o3{_l#*6g`$VIsD5#y^a@&RV^EBPvZ?9mV8maI?zj?14J^_D{@H`wuXNLW zjUBZ3?4P5|OB&@jsSVm+xdQ|cps^%FIV89#5AY!POwC}B;DhxyN4=r%4Z|xHp@@>- zzs~~tqrF**uXKAD-a=o+c@o0ku%^{L!C*0C7Dfm@a@UGSM;tN}5o>tD<$bPfX8AtQ zvgwIdZethDzxi4dJQyeDz!0ZaxdUhrS-{(U;;Ab`jjj6;o&4bha0a{vL2Nf}Oq5Q4 zNA4zCq;OiG>R#>E49XDFHmVI%=YlCA-e@w`yxGrtbIwdp^${>Be>T5eIFw0z18_yI z%pkYnq|}g9QTLXXC&bnnZ;SD=+#T|8Ch|jQsJj3DN;FU^jF~t$V&}pK+{;hUD5S&q z5J6-*S%3384~f83b)rmns22YvLTcrWl+(<DUrxr?609y{)uh>vl%o>Mp{;uN5VYt_ z_4#aU*n%<f2DwAkg`S7ITbQ|hfH;OBQKZGyA3v_IM%#`e-`i<vYW^OO-Yk$$5Wu~d z$Ac$R=aR)?S3IiSEna7%Vaspa>9D7cX&iQ3xZ<@jqoOg9F!(Qe)FUbAwJvLTUa<Ww z`Fnfy-5R!E-<)tOI|`>|k?H`sXK>Ko(1)<FMn3;g>@Bd8ij%*K^X3;g*l^}c2y!@0 zJ?J98px1%d&G*#Uk43TuF0ASm1Dlt5@SyL8bBXvi;UM!(%8wdcCNe6Wyk9&_Ioo&5 z=zrk7C%J`pv5xYfz0<k3?~mG1OP8{C?@4GK^izTT9yOqAKE(6NAG)-yO~S8TyG9|! zC=orJKq&XlIm>6!bNQL)hUj)D>}pD?eU)K}wAb1vK4UNL%Kil2nB6_B#SdprwT6;` z(hWaL%!5+n$}+<H)k+-H^Rd8OYj7|(3q)D}n#jxA$noJKQvQ9ptg@X|AwKyqZXMRd z%x@r)BtkpU!IWkWbFvAI%35+Vqf|96)9~TLtfNDLNmkZ>fXgg<9x+%k^AgM1IX7pH zGWu>5^`WDvCnAv%GznWZ<o9-Q@Ya1yqQ%{rpfLA;jpE++nm<8CviSF=jM^{tlRFD_ zt_i#!kP8af!((EP(2xBV1oq(LBU~AVO+K!J^4t6O$3)OVZi}547?zeYUeDAKy`H5! zs0Y!(z0zI3WOr#>+9?q|1=4uUMAO>*1ciH|8Y-a;cX(uBTde<PvB{uY+u#tOhif$9 z?X0%rRx&AlW@(wto2=>hdb}(>?wyW~4)4ud$%j<j*!q`HT>!h(d&rn5>^JCKQ$Rat zfGLELqnfGpluI|I=s}Q4&ikOX31RZ&Rhes(H-4M;m&j=qg&z#Mpen1PZLC?eqGWSJ z-l^KRb8>PjMYE17LHmo%LREU8QBk(j+lS-kfucM-4LbP(1LJ*zgX1CANf1dX35i{J zaG+KD`htdJsgzfJcHBSF%yKi?N*e6XcbclPN^C(c%bIx^+@)|qPrzP`JwQC#-`(x= z*{H9Jm=|c-od<nyev@r$Q`59V2CWkyGw0O$ku>TcGpt1x9yUCmH4vk%+5?lMW9=)L zwf(k#vc#;>Yx&2EjmX4Espntc<U$ga)%d;N=!S#97=X=}<u|R<l&d8?aA<yLkgW*K z;hQNo5UkHzwA#bzOctvo?=KJWnQ153)e;X63Hn$RV$lj@hQQHviMQ*UXGDVK@j#^= zKdQdhDA`h8{<BOToi)90)Pa%+*!@AvUN)RgCnLdq_io;rP~l8ZYaN`~1v6HfdO+G_ zxJs0N=&>Qc)RpL_h71ZmIwK(dY<Y@=91;)w-FBcDO<>t<q`Wn2)CQs98Ey>Ac(UQH zhyQwwfk6<3{JAXziUSP>I_3vH_(dg7^@F`V;FSvBy^ED=kvn<%Vgg#DH&~a-MlJ9= z?@wJ=0v?#kN2Tm;4l0O^U9|A|^#PTgxM1!%s)vl6uhcjxrcdDMd>o!DhC^kijWIFd z{7Kbz;d#g}FEQ0~hG=EEd;*jsEi5byB8ix3gAvlPV!ak`t$qW?DbP?5+xzkL-UV$d zz<eO4axdyDEP_)(TkWQ_*No@58C8kU5O4w1ynsRLJ%yC~y8VAmcjFs0ZNIr{292Zc z=LG{X$*B<r3umf=^Ud^9fMaG-*Ak1(+rvi>?bd4KrkhmZf>mhIg13}#=Lz3=&8Z#y zg-dYMskgc4k~kCMl!E1l&xs*JWy{(b)D*wZuw|{cr_+!x!8~w3G_0zEDgu1t9#XM_ zcxOY1b7+zl)qCXZ;oyL=Bns(uZs+8;if;C|st3v3yJtvEXBD8!+{?&3^lvLxFcUrP z!ou;^3y&G~WbXQJq)o<@8q=|enYNlWSZyW5EvnFIjM>kF632A=JN*JbIGkpLn|#Ri z!Tl}5P670~xnI<M{;bd{>+_tlF>4D-n^gn=Z=1}S3PQ(NsqVGrfv(b1CEiZCX8Jeo zZk0fd6CM>cBwY%5o-mOnk>fGFBMTTn{xg0a9@zG>usBYtY9x(JP6f?p9Yezjl6d48 zLmZg=wA-|WVi4q*&4)i!p)-or9~ABe=90^^-b>wYfHrE@cf1+(=|D~rQ<1Gyl_ppQ zuL6~6_VA4*ELPllwc1Nd>ln+pBs?f^n5lEMeI^mOv+=%ccCqH^>jGUEfF0&@s+Ik{ zgR#H)vWW4C=4n3Y5L6+%eX)LNE<cy5P{I$Ut5^Ac8Ckd-`8)SM<skML!QQ7oF0oqZ zi;S$UHUp$V*TQB6_MP4j(?ZpqF~mi5LFebCo<33R(c(6e2i2)tx}!XWZ^5Q@zfw4| zrk^^eE?%MqlB3!J31@j;7y_W)CLvKbIoI3@dVZ^})3sR0k!gH&C6Jk2`gY%(r`xaT zhS2MBcLlS<&0rOb?@Z~HICjr7r~ht{S(BEz^XTJOq$h|v^_4AkPv!~JHL;-`_%~m_ zo;V3%o97vCEiu@nqSwa9of5Kq4<4w&YL9tV8YgX$6JL&cYC;2~-6bff<!?)NUsW1_ zs*R(CZ$3hHDO-kDKD2AcbAEW)EN5iC*}VA{ux&BicPy7I@gMcpfki3$H=`8w%xwaJ zDQNwy$_UV?3E<MKzy5a_8udb}YF;hxCJiU;3Z-N?nGlix7y@PnXw=l1?M-L!P$GV4 zCe|*G<~w7?V}$tNOyUkd9943JE7rx2h}nNX5AXNuqiWH%FsBVL1?Mb$F`o_0{j>%v z^`l^$z3}GSg$ExP2bJl)7~ybs+H*+l$3Xxob8rm$E{F!|t`6eI_5_goR$w5XS(Vl7 zUg$A-`Flp!@;>kY<#lx9I-mjPoq!!NU%A4>${Lewv>%tZ2xY>EW|@d5)_#9ki2sFz z$#Z7691JuKSvrjw>E77_;>hf5OAm(x1m(y<3s5tX-vZz+Lrfm=qow{khp~Iwykhee zZYiexTZZR*i(&e!(3-j6opk`5fJCFKcW}5{a^Yv2#m<^m6ifRhHo2zxRE*zZZDw#( z<MJKG%qCmQ$$b$pW=)2hzQq|800K-|SSX4)?JgSJrjHbK;0zw?u=MtwAwV6)OeP=G zhhqxdq8I1b#^54F9sQ3}Kmdj2ivww9%6-q23JuTtD><~VSGip>D@Wlw^1DBzo%=<- zGROR1t2pjW3}st7-?a8nT?ma4S5Lohbn_1q(206im1EyMk;!+ciPrL4e227I{*lG5 zVFnCc!OVX43(;P|rXA+{+s2I6Tt&s6SLzjF8XD?u(wYz#7bnJi#{IwJ<bes$GC6AU zjCeqlSuJ5#TCBn5ChAG1He^(^5{<C9ez*+PRq0HZ6FRc0H%s-46I~7kzD`d!R&yJT z<8~})eI#$Je-yyD`n%Es!D!1dk*6W$X020u2Vgv&F;M#gGu>w=gB$*X1&oLc_Ulsr z_{N>>|2uX5e_7w}`&z7a(g6TnyhH8s!~`fBjzOV9`c{Vn&=FSzOeQ91kBJt;zF59o zxYBUUIcZiyxn5bGLz>NB@nEI0W@-K!fyyluXN0`b?u-8<u75B^&qAfEv2jt;%@-1P zYspJ-R)rjml2v~HsotH<cW#y5Y(UJskd&Onjg@7H1u8Zc6nrcrj5Uc<r1rKxe|~;E zsMkMa0tE#kzJFovjh<!Xd%I0_g<$2VH&M*Co;`f{@TuspuW!CC-R?i;&|ir%0!$3o zuD@cD{8HSR(j@Ek0K(I?kroux=yf?JrXleAo3{;F`5F**j-2nq!<n*P#H2EQji!SE zU_q=>4t2r`i76n+q><(cc>U9+!QTp4ri0X(@ACol{8W~a`p|BEQC#TM&=h?M)>Q4H z8iYXj_ISW!bOS6Tr`gKAuW!j*1VZpM!XOwFtvemzR}O-w$Ave(^k@Lwp@P~$=jI)u z*5_Fj^W&lr&Ify0t#VR!#H7H3pxJH07!Yjiv>twxU1`kRpB71%?u5{rSJ`3Q1rN<m zI~>|RR#a4MJxb)_@R)9vCM7e9fCsdc*0pDi^$P44Esh<KhC|bGU1~+~ROvHK-`aEi z7jqRBKxwzescZY_x7!a|S>&?o248Q;DpH#lMDe;6jRm5NosC%PdezfR-TKuDG@4Su zm60NCvptcj$QS|5C4_2;ub!CoC(is{@WbkGND3aj43GOj8o^bbd}3;vUp-=WzmoVk zZ{sF#wtpU*f{c@T&E-fPn0TSQ7^Y~f2(+f~oago?TV->1K8r0W^LCc3ROa7MdJ5K# z=S8t)l_?OirCN`VesF`efgv&OAMtz+2)oRk_I{Qkv&crz_B3(xScTN|KvSM?po0Z} z>^52HqVW7VCzC#}5~kjQ!V6$R5%a+-!dEWp%>y10uT*JRLLZ)MYFhf7Z^)p<!$f-v z?!T?P0G<;6qsN|~`I#bfE#^1c>9CC>AI+XTRRYo_7~HLQfO>*A?kQ(Rq3MZ?5P#4| zDN2#@hy;aeXvVE|EH<BQ>FQ-h?sVXwa(R+8Wjz1~JF|^;ot7Yb0qrk`$NF8aT1Wqu ziWpB#O&0})H_SlsaXc!Yf2U{@;ZqwwXSm@Y)z78vY@3;xNf0J4OrI$f`7A!eW5Szo zz|zzADH}230a-T|_8wT<ko8;Tl5D%O>o-kZ>_rPeP`{WZqNJ7jQG*w)*mF^AO~&=! zx_|Mg-I~t&`g-8NrVf9h4}ktuTR-w1<lkEx60QINY;>}UOIB81-eMI+O+zHa*Z<9- z&tIg?9JLUw@-ds}VI75?$!zGY+Mb8`l!)hTGlOnK)vJMtW@*Qs5!<zP@TlIn$LU6n z=TDjZT^JtzJ~wppNy|RxH+$@fYopq2&sZBYYOp^B)quth=-f+U5IsG5;@fv`q&^y^ z@qGB69A@U#`M|X9!=Dx!?XzK;4?s2er%(Q0?yLQ;+GVWkJ{yG&(}fGQFKxxEc2AoG zq!{rYwyyWYqn|bHur92hq*K2sMNE-)YUStPu-ayF|Ld>60QsPxbIw#%RxTY{I=Q%% zN2)*|EaKZsKgts}TgBz;j6}d4t2zAd6~QsQjusY~paro$+lcLo;ad>M3_b#?+6ahX zbKi}CQ9XkiXY?FK>ObYdBoawaVIACa2IQTqAF>-p-x!n{uj9dokWdvZ<N$PSjnY#L zH*;w_g}QY1Y@AF$XL@kZVMRB6Du7)j#_`o&>nW4YO<5royZbmyR@RH!nJe<n2ld{Y za^S%xrCtu_EG#oY=yQ$sba!14Y$|v7ho2W@bh}#%Ae061ssQX8&H1Uf9-oq)9-EX@ zK<%f#4^zs?ac}~n7{(i<Zzd;x>uL*&y{owEoLhyjT5Od9n`e3wL0?e%^^cz|fd{RW zxdYJR+8=B39XIm^X>8(hjHDzNO#kj|P-nVXqo+la&sL$1o+<F2Nnssk+^a<m99amP zLC6@K^026bKIF{B8_zp_5{X1s*#<~<nf%G;pPc|wm5pz3u#v{VXU2ev&Y$bZy4?|> z9<&%UGjrvNQ|oLec89XyJli12#nlKDJpC!6{;&4YKs4$wT2uq_yWJ4NlVu=tGwtaD zVr%WSS%h;YG4iIU8+IVwq}CxG{%`g6lSxp<F)(D>O^vLY-YNS>HxhFmK<4z;;DxTP zvkQ4opFS-$u94bo3IRe`;e9h^nWWo0t3W~&z+)*^7ZAzhbYp8UIOuMnNn&O0{<@4i zQ^DEE8~+nZGl-(BUSyWowV)V#3^re3XE#~N_G%;-!xKm=!v=mplZ+V$TfRydCGD!g z=<~Qv81~fD3v$lP0lM20V?ZHqQcOLx@&T><`SY{u0_!-t=h29J5lQtY*wECC=H)0+ zo%dA90Zk^5it5=)aA6|^H(ESaV8WKj8{dMCeC=PqzEH0W+I)-e7%WUpy^L!DSS9TN zp@gMj%<0SX)ws#Zu=nE5>M<GuviO)qrab^Kn*De574$$<1-p<?(4+|zB{es9qjJ&# z=t_izE6xigzq*btN&8Zn9-lVfG*@Rkd~3E|k`t5a+ydqZ{Y68b<-XPHR53@r6}T~7 ziAm$q?_b|8Fa#BZ1s<$_v=6o5^?U{Fs!x^SZG5(>!-yFb-(w~ec>hCv_`)Ft)Ay=3 zK574uAh?XsZjXPx%4}~o#>A_1Hj&6nR{Z3pUggGsNzz}}mP&{ux1C{|arve^8%z}w zR>WI&S6lhW>Y2!uyRv+C%ZP`Lm#C{kp3#C0M-+>7bqFv)UI76Z<)G9k-m14Bs<B)| zBXX{Gm${eU%iL{keVsF1FAZbp?9kLdFc9?X16HuPWZVLzD1*T*mK!s{GC=a$pvrvX z#%|_!$h&u{>_kB4^W}uCJEPXOw$4$lFur$t#P3mMoE2xK>a3w3N0c1Ciyb8&xGw_G zh{xo+;)X^O3UT-}K)2!H;cl+n(!Jj?UjExT5TqC~&D)$e(ZN(x^E0!SX(9T66yS^% z2Esq>`Gg5(HuQ(trk|Gvdyw>zXSmGQyO6bX*j8`i@=#}nkM=&;LXjYNpk37IrhFoA zsBiU3Zb}{HBG<E^*pztBCpYEfT7c-?to$b#<Q{?e!hB#Ak}ulVHDtDS-<&up&|ICU z3wAGrM}PddjmN{<fIO8R2Hf9c>dw;BDn*K0`bX+cM99Br&)H^S`I*wShxdV)&9lQ3 zJFh(oWUHl_NRD$}K-g-DoT>BPWNm(L7#&m0Z`SZ+_3Oast8=GLo;KyqwZtTi@MkU- z!&}@Khie_B&&{De1)ZvE3pvgq-3~*_R4KvM=c<iO6c3MrdG=b9r42q=-0Dw}e0(#O zHDhg)5}bMs0{{{zwoindrb2^sJs@o|kFVCg8MwdQNGS8?Wvut;p4T`^T9E?##zx7D z`${nqR)&wpojkqIufFAYvF8>{C~a?l*LV~V=427zx&y@YJLH|YM$DLrVAJnMdi%Vi zCn)=CnS!l{m59V0I{BmDh*sI`kc!uHG8`LUB3g`V*E0O?@_<-(W)Ii#?i2wm<i_&y zAE4vXv%=Wr*I(|&Czin4%rLHJU`7JKdD*>p%neFH&hO*k2P^qd?b*?{wScSP#wq1M z<{laOc%V0gGx^{^Jm!3PZ>>3Af8z-79#y$S)tZqoC@;>gHZ3VV-O`k;0CvWD;Y)k_ z$%7rZQT#};hK29+#-SH73nXT4%b<$V8-t$w>tyh3l?~VhHgG~?zTmL+X}TlW^aK*+ z`<v-|$9-yl)G+A!$FscR2w*I{a%2-B7vsZr^=io*RT9p7Z!FWh&f}<Bc=Gj`zyEGJ zm<yOySD%}g>FewS6_dFZ2=N_L(h@t%j3X3jo=2O0n1F<#g&D6G4mj)OiHhu@1k_fY zt7UJ7Uaf^3EXN|StM2pr-<7yb%qB`(R{xE^OrcX+$|?@a^tz&sIPH*oVVPgQmku{x zW9#3N+egFDd_bH9=_flAq-X{PlX3Z>hom`|`v=#YKWDo|B-(JgtH$Whdk2}%MX^jv zdo25G2GaEfk*sIMRDU3idI~@BdOqKi9m|8te|Z0XB0g|JbbRHY=O<#Rd2hTsy7BKR zAl`BlZB}>`ELI;gGvGV%(K(@42wQ~zCnnoWuD;ji(?@ifg;&p+F?5WFYPx9<%kzK> z+K>_1pr9bCY(q`Jrdy~!B5GcEUC^-6FP^|X@WC4_h@Uf+HiN8t5dw`aZGm$=B00Ia ztnxu?zm!;c8-FFdf3ecEzAZ2AIHgPP5TE$5vA`WLj}B$B{zoLeXlmH|WdywvXN{_y zDNt@vNzRZi*J!DU7?7`|39PC&1b-Py$SE51!{jn>&Fy?n<0RA9=AJDPyw_$pzkEf5 zTnhBLux?Lmt)mI>T?mNKScUJ}v+Rv#-1e{WsDgsmd<>g&**24p;{!s0uq2+2kIb?# zFK4uyHRFx!Gs?ZZlcmJ!KhMp03!N8Gbzh;yb#EJ^O<%gRLDmaW)wWDLF@CXiVDIWX zssd_23&dIiR&aN<7*14ub*ipYr;;j<v1}ox=&4Y3M&o4`#OtOg{iyd26@~<6(ZwE> zvWWlK%V?*X#`^EAjE>U;PXXx1DE%l>GTsmDugldLSZB`sIV0!uyj2eJGXD|+l__DB zbQz=%H%mlZOij7E7f)Ss)A7aO+j69u1E*_SWeH8AQ@X)TmP*m5{yr_{bSVE&*N<p? zi2>XyPBC%>9rK=A^hY*S=M6gpsUH63Wk$xEH}CGm4+ebeVq&0RtK!iV12@qZ)UG^j zNJeNN$P6k_H%8}yawXf+l9N6^HxJo?^io;Anbd%@$mHkY<7-+<u_xgzx!qKC$JTjB zE7KZ0x!Nqx(9NI)tl*Y?)oqXSuFrq40BAP-eNw0)5V2^y`yxFa=`cF=8sar7JmLGM zL*2Ju!so|c=|i4s`lMVjUTz|0d@f1arZ!`A22QyqQ2#()P*D27r(Z6_L$zAKe;zFR z%{s@lEq$jVW?SD$1_%cw(WLDde-eHJna0zFSN$oiTl>7temji9ZXN-T#&a_TolqPK zVJ`|<1u3h&Z$sc8!^1tXcG_3tyLS5V@a;{CBM=&zMkUN{PfAJ}JYo1G+FeFpZ+5@5 zDZZ~zkn@9mVm4|spr_7+Ync||AvwNx2j87O4l%%q=;q8gR(Q%%x1nm!-({s{_Cg{W z$vXT}<{m>ISfnn2-M;8ryV<F;L9kDa9b6r?tzXH-Dsv`aeHFGfj5y~8e;@CKj&^NW ztcQ_s#C>z_QPujI8O}DKs?ugo5bXY94KELz7F%e6L2?rs{k>DPY{a_i?`s>xi#BW2 zq-JK#XmBz-xXxwo)<)Hw_Vq6a029dgd5mWAFb@xOP58z@z@B!fzaH`ZE+y9g&S#Y? zV7ECrZHtHP=c3yAtVCjyE-D*ht+(&YDF1>gVswljWE2}X=2uEYAcN&bl<|`63NfpS zle`jW#N)6iP@M{%GxCdEuY^YQB}qbxdZ=Zh|DqR)+hQZ&^fN)_3;-DXhF1>Q0=guI zCjK$et_a}Sn|(WD4~VTc#9QvewJtDQG=1NAxosVvcCL9f4!Jnt#=*WRROrHUCnu-g zq@%6%kdb5{4d#1laITv^G%zrblLun+`McXa5fLD5?Ej9mNgVO#{A2(#)-nlbUZ<9N z8{Gp2Z17qQHKMi<bf>inJ%jj9+^=6e3MoK2kNtPLwK?0{_S(RMo34l{0;mRaRV3IS z-vrvh+}C`!jd;4Wg#`{Axag{nYe}^AIjRG288ag5T_~O^pDy-)a4Ph;By>eICx5W` zpc`UiZF!sY!txt`#P|87B@-JnAZ`KsrM))QQ}27;tcn5j=%?-fBUdyPlT%V*rGiu{ zX!}`cjJQX>`6Id9iuKUQ;N<_0FfAh$0M67VW4`Oh$U?yk|HGl_m7X@hUJqWreCbbG z`SZgZ1tJ+qP|j<;;~<9Ql7Wc(i>2kaZ-0clG@jo~d^3O)NL!#*LYYS$@=VW@y2bru zaJ`cGAD%S>bU@O@V}1+u?ym&RDkti>e20-Xlx<yrz9!p%fZ9B0oz_<m%modNJAvq6 z%1^~KFxXn!d#Yg4wM!^CIQY*9Ps!W^>%10hXn$F47FA{*AD5i0wRi2ymu#?!`u-)> z5oaJO-<&-t_TW)cp+<;B+}qZf*x1<8XgTof0S)}F#y!1$Z^~pKt&wqvD^BDqgfc4b z`}~4X(og}kj$el=D#T369xOT<8{7FjdH(?D`i%L&uL;ndKko#(m!QeGia>orptxPd zaa}RKn|&64BAU>_Xkxm3FDgUST@kzcx8(kQsylU;9xEd|d#(+D4Pe8(j?!!DJt~AO zy1EyMh}`EmsnppaV6mMW7sG!YqNe5m#O|26sfD*8^wutKzk!Ysz$L0BW(Y4*QflW) z@+gZG62yR;HP*KsR~Us{%H48sy?&&*iojG>Za83rpm+w293ARK-kk0U$@s-FgaF(A zQ)9(dq=E1hsNrV0Pb~s5V8|7?7xoUlHGzQVm^pn$Jhp>(ueQAW*70_CcUc8`Hw6U+ zus@v<$T#?91?*v93sq33<3c2Yyl;_3JwFX?oQ*8nP_?6W##E)<SIngMpR=}*{HFLI zaW+@J%Lw;?_E5HQrCK}w72`Qou>sHpYKM=!kGDMS2{MDrku@M@<)9^5cUGo1!G`xg zPyfpAI?%WgPM`-OVUyoSjpzXKXjY$^FE7bz2mUXHaodAhM^IiSgzj5j#sl7k6Gduk zC$v|_B|^7=I}M{_GvDfW!%Vz`sK>F^Kf>ohss4iI`Cl1&;$t~am@{r<888^|UbBb! ze`{~|c)X*0$_KRR{GvSmO@Hg3@cr6bSsA1@y)>=|-E*ckV~8`9pO60}X*@f}Y+T{- ze0*GW?H5b0#@W{r2$H>uiVEma3W<v&tko#H+YgJxayXoOswRWx8lFxuc&_s8;LOPH zBTo;GPnS_glr-@_XzuVZR%ho;RGyVtXR(t6g0~^Aa})~YV(P4I{ajw4S&xtvr5-~g zy5u@(2y+3yEb+$}0=GKACSK}mgzNt#-Tq%8ChC#@b@BKAbOu75LmI3}#hl<!oA9TO zrk?PP)RNI8kX>84t5!Z}|Hh8e=X6w`dm!QZ&-a%g!3M7pMrVvL#|;R_27TtO^@Lx) z*cGD^5J0h?WoH6>X>Zl54HXRok^pcLz+wFPaRDwMYE4T>XaHEByE}FMN`tCQ!fHi- z!Zc@AasR8~s*+-B5OnAcF&SxT4u57x5A?&>db-LD`RKzvWfE9bzN6T4Ep2si|0^N) znNsmu!xBIhIhk;roqov`O;dU#We%syH>aAF2Ek8wXe>|lFDJglb6t>+L;kn+zB4MS zY+IMsR@%sE6p`Eth-8(V?N&*ppyUh^m7H@jq9C9^fi_WsWROf1QIR4PIcE?kkqiYz z4sY(l6UM#cynEig=f@jwJbp2tYKOJ=nrqJag|%dr+bi{V;Ys70G=Lw873)&=bKmo( zJ3pMGp;4HEMfDAL{d-z-NhbpOiS*8x&-7wv$!M(G@T_C=-xVTyPCx>mrjz<+KGCSG z<T3ZXJ?r)BbC8WHA#sU9@>Qzo1Pt%HhUi3-E!ny+=H}+#1M}ehcP!##f1~x-LCtwp zhSsN?L#jew4S1-lXk#M6o%TjwFLS|vgY5YAeSD?XHKQ@3>8_XJmoFnq7bMXyn2cB- z_0i2qQe1Ct(XFy=o)fd9Id@LJ4Sgf*9YGu?v|l#PKzPwe#EvB?gIF3cW5fyPG}8o| zBcd{C>2tKLD-|UV_7Yp%wNxF-vF4DJT4<=qGDoJt)0)`}GdWg+FvlNhsMrGVYByM{ zE+#x0b}|NX-ltj~-1kdWr}#SBFq-2zTdem`6M;V+c8Q5hx_|%vR{o>N>Z>B5;hlth zYFjGCKK|a@sNoa|lD1>4i<Kr7W5i`><vDpjJ~;@{uN?i9C)Y9Osp-MhTmfu9@{i@Q zFBjmgdV`re5mcL@*vl}bcqK&n`wW4QQjlj5peJ^$hWaJ@GsVHnCr}c<UAZD!`VMbI zcd5uY{XG8t&W?AiQ7~s)$LV7a@2#B!AfH<<v85Gn#oRHp`8pyhRq35|`8<&ABSHx0 z#AV10U&(N}Y!SZTI$#O~T)TFN5Zs(d%Zy?x9-Nmg3*(mObweo<A-g378s6b5q4s32 zGq+&rm7=x^WfaRA^wiG(GW@o0EzF42F%Bt}ykec_5l8kCJib)|0G^hwe|Ei#kK4;~ zQiiAo%=CK7&SfpG26B8GyD#~D6W91_DCl`%(dMSpG9cB&thRI$*=-bB&@nk2S6aO+ z_8x~ycN-?v4FQPIY9C3TZ>mQ3TZ5Ru#QH2?1}N7%s9_q~UWF0G9<-+ZsYp-a*O~xs zY*bu9#Zt}Y)}IJg1(pmd7khM_=q9%uf8G7WW}Um?i6P_N?&l%Dib~1+B3r9<Xnvuq zOR@M{*m)khK955MYz8lrpe_$M&EUIKy(eMIkjl8NHcP@G+%@V@){Cr!?1&&J>(;-N zEvkiNr$Rr0fQ5#~RLi^U+<rjWxR0J>benyuEntCgtyecTH>ag4l8<MLRQ7DErKZf2 zVIm6U|0*V*{@&r>e0y%8P>u5v>b~|*ikqIA>&phnG^39|#B++<BbG&;#|Z3FKRD1K zn#1<CkUNIvFjxU<$adaNX@u<*xk#i)=!retuO-%V0aZ(8f>7M0YzaPq0j>}KTOa`B z!u}iad4`|CVu~|C=kZ~3`qlMh3y+&*L=NU}VXQ=6O8&XT=RZaH|EK}_H+;a3@JS_1 z1l%y-XXfn`0?C5o6A10d*Gu8jaHGJ%-2C~^CN3KD3d(+C{Ok3ZsC|HG2`F+*q(4rj zZ-?Zm?(R?7CM{#t@7_fWYCv8$Tk@k<rhW&|dC$!v5%O}K@P6eB7SR<p&W?S_R3||W zr4%h~0@g+?UKYMRZ&=T|lMk#;EX9Jd@e(I3xMCcyC$fV#R0njTI4_lXF!}lIgC&tL zkXi;H^n)-o|KWr(L@H?C#W*aD9auU|wgKk`x_91?0qd@e?95KqJ$d{XZPHU%4syI} z;%5HRDNtL@^Ov?&DV*`4rP~B84q7#MfMBpP>g5GX!G!F=l=hJ1W|%c2W7S6yGod(* zivGO&McbcBd5&c&EBir2RZ?2x=C2T4uB1GJb?tQes9R(?{N6+l1cKmb1dMl=-X$x! z^HYtS)M2T&V|)0x6ayxqV-T?i>PL_=p&sctmfkdOXb|6}U+<vP4eUEO^Cw9wCDOM^ z({w7`te(O`wdm$c&EShAsmsYpuL-N?17Lp7BRF2k;iIQ|gzl%0htF5B{<Rn2`YI^= zWLAtNzT-P%y!}|U?{q{)KH}Fz9~GuLP%LHmIM0#ikDgCj7gzK7BkQLfbIQ>SOwKV2 z-uw!~NL-UYh~Y-eY9f#a=1?<)r@0J^vU`NzQ^-+)wpbTuxQiF!OmuSY;@`cR49Bei z#I4=myiGsqU6BPis`EJS`dNzt1MiUsZr|J5*^lCf+3gT)Q%rFBYXe^|7&=f;xc@@c zhzs+Ds`&R3P_UoLANeQ_Qr2kNb}xr%db?Y%4&~OjNHhQCFTbU$(+0X(k6uELq<#Oc zsPv{dq;X-$3ba?mBIwF)E>`$liZ&-N12Z83(xX}u;iodWFOt3jX#_P=2oywqoz}8Q zcg8b=zJ&FC8y^D(Db|6_i21?dX-X!1W{mAC!;P=rZXa$7V8}LulJAhCIE{S77R$eL zgZcp{-QBX1!+v;wyWkcWb$y)URB7u`ErJ%|TX)jkVg4gupM-FBZrSv64&nHEE?nS^ zi`(Nlko{H*c(Ai)XMcY?Y%4<FVza9Qb}a+;j7`nWHgugbpfS#1_f&F;+W2|hLbb;F zN0sgW(&m6bhg8&;`KOoH*7$4`dtAzAo;x8zkO#w}l9G>&4*U#RnEwpsJ$K@V%bbj) zX9ZG6g8<S0U)2`=8{*1ebKr8c1qy}QwQlEtm6-fa`(wYp@9#4&n3iN@X8si(L30M% zIQa?_Lfpq=W(fGoE`>hhyqk4ja2(FfP4#^-F-WmMzK1j$=BVJ5o1p9iO|m8z4uEJV z3ZKi}bXJgWOEH<wA|3|<$U*MLvs<h@>gzV*VuxXcO7uUF1u2?<RFe1e7~e5{SgBKZ zX(n#G>v#j#Gh$wVal(EXhTKu9eDM>)p|gQhxTAHSHFs}s@8BMY?n9m?LA93nW}y+q zQ~k|qS&TF$>4(9q>kg&Kg*O`r!NI5;UPf^03d^P3a}0fT8&rSL?b%*ih;?6&b(D|i z3oxyIs=Lodr%O$OL|d^&lr8o9T7jRJq5<LctFGRN8`liBNaqRiz+vC|c#>R6QnW(d zQEKMCfh?ToP;9`QVao;h#*as)Si{#AV$GwU9zpSGMx|iSE9&7mBC$g2z51}Ov=phJ zHa$Qq2$UzE-DV7U-9=3l82W5RE}t>=N7zr#o*hxiMSRy{d?9uI`0?Xm%C*_D!e36E zI(~d4yi!-TaYKPhiU?wI{04ZaG5G4`E?(v^@YJ?5w?ZbH|I`XpjMQ>y6A8XuS0Ffm zUlLJR{8Eo`o|*?XSwo*)Sc56xfZ`Z-FU3Er&UvxDQ~u*~;OsS6A>C50X>nlf5$%DK z80C{^T3!8#+Ii5>{yvoAFWbUi6OLb-gRGQ<f$}meCgoR1kV4-S3xbQ=9|!q)9Q1?q z`H;L)%P1z(ZKPvR110Y|bsR$3_wHKi=Z4VRP^ecq)e{hQK*_-(yrKN5g}%JqkU<=s z-8QM}k3Vt+^9u^7nEU*ii&&-iTP->w+aQbEUHo7eR*hy=!&~7#J=r==&;T(3{KVlv zY4!62u$zQA?84NBvOHEg<U~@~P#7)E&`e4FL~8@zxSO3o(Bun%LhFb-JQ8+cRGIa# zmiot}qO*x5;B%B{=k8uwj&=oF$5Vr2IQ}O0Z+nz1VP15{^!WH*>L}Mly0;!RLQZF| zw3J1{L0*p(YUj(r(sO0h&&^H85+cU%M9Z`acjapS2o%WO3=hO4?2W3f4r7Omcrb|J z98O8+dA&xqX4VEaZ{iEm&d~Lo6o)27cpUJZ&?ZNaEHijk{=Rl)Bk!GqH2QU2wn!wG z1Gv-a%HDMM@<MTGbPEDE1sw0FL%}!ExfQ9cnk0?J#(t!Pj(^_eWgi8<yLfobkqnnZ z@z*}lJXmRAb>^Yd$|)Y7C3sR7O^)o8muqC}%sR0L;4z*b4Zz%GQ|~w)doa_d4&^2% z7tt^kK*nB!$u>lR!6ie;6AT8!YqC{WLZxEC!|tIP*X+79_9@<P<l2Ka^^P-44729L zeK$aHtIO4db4tD|_fG=on~PQtRr7Nu)J0+jY4jT>W^g+4^75@He$}Z2Ank&@)3eG7 z8SiSbPryBcGT+4()I4v6oNzz}hLX*MmJj*2Xn^~J;SD`Dj}2frkyBrPr&Is!uA$~= z|Ay(;=_IjdbvCse*0sks4Daw(HuF?o_&qpU&=xdo&>gGnvJ!4-;Ffkz9=mC9Z{>sU zC*J7G)eY&LuAAb{Syx2%olVpH?k40SrGUTZ*X)@fwO8v*jr=^)H2t4PM|1ZUf}Ea# zd+H`-l85T(cxUP)3(Y2_Eb=m6uSlzU<+*wj_}>chsxn8^Zd2zMz0BQ7y0~9`898|x zEeSMEQ|FFs6udaA9_~IQ*e194h}=rJ%MZCO?YTH}E}<c|<<i$;-N+YaP`mTB>RNgi zAIbe>d$}1`%TA7;GtcB?Mn!FULs)v*g{Y2ISbit{b#1`(ZzQenFHd0Aug*)<XR=Mc z3}=pdorN5X0-`^bLW|O*p@t|#dKZ;4Mbd6%l<>US&CaPh>NXWz7ivb%Wc}Z{8w<PM zPT@O+QxS|TX}+N&uPZk0K;V!TpANaN>FTn#HC4BmF8*6oH@M)Oi)rF_d$p?UXB_6u zkK<&`scdpTft+rM5n8Q&dQoiYaL!}l#BND8=ka9Dx)5`pqE$n5%mz$$RORq$M>I=u zkgbegdv8bOSDdc8O?Yeg>S*pNsYXZEc6dh>oocNWzgj!4Ji0aJw0q|uRs|yT$Ja3V z+6B^59%M<Ryx&$Aq7yQR%AZHI%A{}Cg<`OCl9m%ZbQ)`aQooEAJa%OS8}UM4e*dxC z$Xg*V--2(vdbZj4fv}j^*;YrjEdpx;CHHn7`RGM0XV%BVwmy1K7puJ@ozuhmXK`qj zDbre~;J7hrN`9o;)cCk%Y$3Zhv$V5)dq?-)okONgCpuYti0?FGS_d{J=zR+3gxC`e z&-Z#vwvBYeZ8hPCz1FfH=oHECnhkQWR7#l*@UWhrmE;k!5SyK@TeS1?RlB_%;!!-b zTQ!g7MzKXw37gN&6W=Ug{POkm$d@(q7Mf}7pL}8TM1~zxZyG0g^2Fx0dVZ)&@yQV4 z(EdDCC9VJ4G1Vy*u@e2&BM-}pQG2(y7B$Z-{F$6&m&!*=c~pn}PkIhvD`u|yB-0}= zHN#duZ#u>AZkGpPox^AG`lhA^2Db7idTk0*R)VAAnLOtoOqk5-Qk5;pKEw}~k-itL z_d0L76F-HpbdFBDaw-PM#c|{UodJ2?GjSH(!7f)KWkXThd!>BSN$XR>R1Yt-JGjj# z9m9zX6D`#5K&#+8y<Mx-pwgk#u7`(tHCP)JWzi+ncY@zgo7IiDwwAtmqVx2bMC&t+ z5PNQqjch9EsFtbmDTQCPEUTFK1EV5lC7Vji&PI;9n>{}p5PaO;cHt%EVrBn%r`m-3 z#DTI=bf%^+x5=dXK!?-1PiQ@QG=6=C*KJ6XHuD4dDvv^zta35Zh$mM?u;<f8#{1sc zy)XA!EsKNDYPL+IzR}iQn?>eW$rK)M!Pyr|1u7fpl9pd}<ZtT}=P#LHi>_jHX0Sv{ z;0|<g_3kW+7|K88TR&WH#^SfN7%F+?KCz2VoH;<Q(6M2#@m^V^8c)?BQSa{wH{A&G zbFIyPS~QiJJCY06E+zzeqnUUZDswqkoC<X%@+ptaW|*Tx^W(g@LLM{Sitb1~@74uF zHjf#xCcD;mDZR(+hIhhme(#wo;`Mr#AD&5AZF+SWH`3+U+|6CpARd!0;Dnj1k=Z8> z78v>}9mQwE45}1aEXrq5w;YJyw|bA-)!uriM@HLRnpJ^udby|IgG(*6-a=7U?^QA^ zSVI}a<PBmu<YhMMJNc#dlSh<i6ZwW?xN92o65R(TsN`#M^Qyk+Onv0C(Wm6&;%V#k zo_*4q@-wd#@N=UWCQ?>LD4Bhh7NS$7SMuX{q~;oPa3m7vvOd4|R=o16im5)|p2vzT z;jMFXj3x5cq^bTFlyI(MbCgDO<Q*<?t~VQ*Df=C$AS>?k@w&+wJIjwfsHB7|hMM#4 zJQ#f+ULjtRX6m0?qK`c2D|!FhHV8Popw7EWV$;8&f?8crd%MeH7ka5*A>T~c^Jp{B z^GwM*HqAMtd6FLYTo<`edyOtbVcWS`pP9np)HhL|_dC3dxp!b4_*?AXXfJhOZNMG3 ze=<M$@E`teJ=8SgHh1&Qp`q7?_B#I<@B;)%CmxJD4arK%(uBlaCa$bR`Wcby{H`Eq zb=;vBvFyB7UGk>&Q`Y0hiPT4Sa#d63iB!0+6SsnWd?s1Ymku0I?i$Xx#LpyLH<V#n zOcDzY84RRi;a98Px3pZg=`VMXziUEn^!RV7jkel{HFb*fB)GgKH@m8D#CU}^<VC4G zaGwlC%jh6?YH`b_H@Nl7G_}|7U(*xE!xB|_G;QDRc$e>bgT4B;An}`6AM&}GR<ZD4 zcItQ@N%gzVI`6-Rje6GirgyGg3ie@RBR9L#Xs2X_BLf{q8jKrjFI}kmiIX!=#Bx%O zgy8qrL1C`wbmUp;G2DlXenZ3nGMtcL5W~=4n)ddssrb%&B#BBBnW~PlEUR!<Dtg?o z6mS~;lrR!=xW5k{^ww1NZ=dN9_+g!U;uHr%>R!G{FaMyb+x!cg(f1a)l@~6&C$Eqc zB7!k;^~_S9lX&+1FeC4|2R+;Z@o!?XX7CV{w^QF}Hu#WRH)D)le~I05fvVxaF97Ds z)kveEmHbM%f{>k$uHCy-GJH}eMt18)&CADIrm03n?xPxIr{dZozvi}VCV0r7AQ%5v z9zA~@+?#tRjQ<$!Mh@K@tX06z6MZKXGwZPT2-C{9<6SaJL2H!rQ1#OA`u+#!iqh1Q zl9Hl_ugOp6?Ts7tum)Fp_K{SsLZjh$J>19z-an#C>@$}JyMBGbu7l46N<~x<l^Ju) zE^$RBOP^B=qrCsfZI^I=I6iU<&n>WDY&F#ia=DruCZAcRGoYY4AJteusN4<JjO5+a zR8VL@KM9cB=;iS7*(JYw)dfONNw~15>@=fbgBBK87qVYao*|QO+ic1LIwjV10wMOT z#J(Ya#qwTG?4Uv1W=Ue==ZdNY-_`x$?wNAo6X$rn9Jf%T-lR>h74yOQ{q?l>DM9-f zPlDgR^!6~-9!669+vm<yrGs>%&gz%`jTx()D97b3A%(C&mn-LBsFoX>ERvJ6GVI_% zQq7He4aTmQNXg864vCc=ah_d^R|Sl;Z(~9{URJu~b}2`e*-cn?PM(*vo_Owp;c$F) zhFvCJTHhMqjJ~+?qTOUS`e$+u<_~9c%1E*0A&ir(!apeaUJv-18KBSKuf>I>4o!*E z8=exI?~kQt9Lh3tPxc*5y!H0!2#~o}3k@&omPFpbR(Z&OZwU8DjR~yj4K3fS-t$r3 zaWTGYzY@S4-IHWtchK{3KQ&3Gq{8Pr_MTJadSq`^vpXT6D6?{7N8n{$lwMEj(e4S? z_B<em!h`FwE66_$YcsEgm0Hsg%Js#l8`%B6cGgdyE>!vk2cJ32+tZ*dS@vMMk?$C7 z)iUwbL|q6MOaltI7tvM`pzbhw{T}e`0&lX0^%s+A?WF~o442q7T4(ES?7W1NVgI(( zR?ECDt<UO6O2szV{<zILOs99Ij`(c{*92Vi)hn^A1@C35(niZ7dJaClr5<>yXrd+k z*@L`s>c#UbsaU{r+9;Xhh{UF8+~7=1_Ty-wc=6pu{)(lp*omC=wKZnx@ejI-r6q4d zl?PXn;@_@~&+<!&xSAT9`0eXn8j|q-65PVkI7Ye1<d(6ab~AHjkGRil*CTn8ye><| zA;ximA#@T~2AN_c2t~=s$%S7<-$q9dlC_dp&r3`1O*qs^OvvPI-A8ZXL-Ho}=USeG ze0``RY^Tz2??6gPVPp*6+oBO`72TF3>PD2<@kqo2<VXKI{kgVC&nmP2LB6agG<nd} z@t0_MR8czT+Fd`0`eeFsgAzvfu3gI`>p8-$+t*n4+K_APigucyQ}V40K70GQj5G28 zNZ*F)aeKiGo121Za#xcaiFLew-^qubsm1&%%HZL3M0lCB&dT%d@y_pf7LkJ<797Vs z-crU8Y?-}%=sjp?qRz&0-FxsNS&zrBZ7<m87cvq$(}`I<$)PWGdZ|Qn>x+3z=G<Bb z?cM|Og;Y5$ZsJT+pEoX3;#rVN%D-T22>$~><@;;%=%0CC{iptvl|Rx^YRLR6tXca0 zcQX@(S_H>r2nz-n1_W>Kk^7}cQ#;&%qkb;JVW)^f%~Qw!CB994WI+j~586{`tU@<v z_WkpnnysR;PS;RIpQPMeav?<#qe}mWa3mYznwNZ2(s=R#(-VUFACAsso<KSql@g<h zhh$5A{l@nv>p?-v3T3KOEjOMUs4jdQA0NLJ?>#gThB7uD#1HaE`i>;t3IdakOEw<e z<V>boEcJpZ(m`;>XjKXIFq|0XWG8?IR>K+l^Ths+Rr?)3Ec?Yo=R>a04{xZB@I!{M zT9RINSX5+yK65ZE9QySs_mj<ny0L@psxLoA{;Kj*&-^~V4}&@JttvX)>2q(dTv$Q3 zal0KD?G9$BThSriV2|O#WpI?n0Td|2&5b(V5>^=HgGsjP4XV;vOJS_o3FLN`>Mzx~ zd$$wCKg&BcGgEjrzQneFT#_0Y%Db)f1Jd2pujF1arbK8?^US{Z#SOF_YI<6Cmq<jj z7S;3==bg>*{gaH&>cDnja+3?DciN71(-ce{=wCLeW7|^lwGPaYFL^kq^qfw!^8)71 z;0oFVonPK~(03N~Dpx<8Sdt28@K$zvT1;;4;vGMF?qIrBrKr+OiS<3KoWWq+sFK)c z@bD|pt5dUa6SNvBH>|=3pINcSv}lHl7e6(Xy2h4#OUX54(w-dHM7`+MhP8|Ve9BUL z#m<IG(iv1mwQE(fG5D>1di*BIE+P(W)%Se9t>FCm^s(nxa)#N`B`%A~m!#ZtJyj6# zCX~53tgP^1XQULFtuki)@Rxoq<E8hnUcG(W!CnV!Cel+2*7J)>cU<;4>z*9t@U7gC z(bm?^C_k0inp~YZTO+=!$Sk}?I@&9fWjK2+9g#W1T&4dUV|lCwx}+eM)g;xO?rywV z>v>?XrYM|#4mzEas1kM>7HKulUte~@C0ee=U%==nwKzx>3`9nk0RqBz`0YV%Y;o^K z8Uu=&IY#uR;~elLRc`b!+yTP<Kuj1|uHLu*+>MSOS=(_9lHS~i50X|-DM;{n6(FYX zwJSgIz77{N1uoeaSxJLA!&p2;g|Z7xe4C|Ej`^HwHlvq=&-&=T;tg@a#e>w%zu@zK z!70-QUG2*{NY$eq6)=6f{aqvlJLTpx(Jin4rBpZlc`<F&RUfPlgF`o{6;5&rq3E=c zDL^54;|2(#EM$#$Z%@R;oY|T`#VR-ba1i+yg>qY4ex60XR<_%ySNs7i%JzR8SN`XK z+rOWfLB6{*6u|0AD3pEym%?MDJ>|jOJ+buS1;nq;b3S1}1##0z2hC$o6J5vK)g@CP zZ6(QYaBPED-Kq0}?f{iwE&f40`Tm9+ouQW2Zzdtk%^injLoxm5$ymaJzP}MSJpU+S zyZ^AyS~#5nA0;?crF7kB43uwkeU-c*WlTbt6RfL&;2?%zBG4d%O)S{$K=~MfkbwZ2 z1DH%0bov(4(DIWoBm+KecRkvWE;{|0L4%Ah;uip7BU);9cmCL@#W?%tVP4LL!<70d zql&feK>92&@X){ZaiN{n?5)EQF}7RZphF!__N}TuDQQ;Wfd3_(Y6^H6A?L-gK(zv5 zRb(K7aIL9ypK-f41w4X=>aFav!9R9du`96|8~~F#*#kj;wQ7Ez(&uO<Dtln8cH2Em zCIH~JYs4Y4xxJ0r-HgtS0-y9rpuiudWEZD;0l5j0TRz8NnM8zUD0$5UZqv2Y0pmQ_ zbtd1tpY|QT@}pK(M-sjsf<i@kxwEjVlN?_=w4D2MD`UdLW5Gcbs9j}gmjUFIgN`%o zivzT&IOv;pEUl~vXt0udu=J9lI(B;Wp`gAvZD#s&Bg5dnO|uyy7L|xPe*E|XWBk(J zd9^WA3PQ+GQAgvIGKJU158!x#<pk?2#$_<5)Tmq?yq+4H=|U0(A>><IeX)jW=oW(? z$jcY6oEM@j?SUHt%7?40M>*^UF6Y9-JGeDPkh<ufZx!#E2S0$}^mJQulZI;GvKD%U z#<ft1_kcOv2t57S>CVQhKk0hLtRFaAz70Iciu&hWlfDl1{AY2Z_-~N7;@Yp~m36AX zLT#mnzVBE$((O}D3op9=QL~zsGXWiyB|6y#f1sr&>i<S1m1Fie5K6bZm}H~8AOAej z+z_4o`@y5^!aDuNz+ES9ntq;K#!<{v|8*a;G>Ly%*Zkk#*zBL3A{KNN5tDZSVdoaN z%+mr&7Fh6Q9?WR<1A|-k^KLJdKWb&u1oy|3D-fMvZmSQ1EvDhy<t8wX8|LF`#P|VE zj_3)Nkr^}G(3A#Cz?xj9ua3=*2t*Ig5q_~d?mt{@DZ2Q941zN2Yi-R<Ou7Y*nI>FH zEiI9J+U3xOhe^41GReGI#R1KBOgUb-C@oEX02~ctAY`n!qU)sq$_JpG8tZz%V;mUc z><6UrBIe6p&ybjkIG<Z}s7w}%Lk*O>eO&zPx5i+?!epN=bMrFtj~LopY&~}eFtOfn zhy+7<KDuD@daw$kAh4j?Mc?a|?Cg|-SwPx@+tDknnzpayhb{^t?-amGcBUo@$*}Gb zSBPL7z(bnE1@ES8VZr!9Ob}EzKrc{DAq)*;V=}EUfXp3`z{L4-!dISJU?R#-GYJn} z-Up}7E0$f3tKrXB6iqgYdZTu&9UHMlnHCj9?7g<0_`6#K7hqv6RthN76Fr<821@^| zd?pwdLj;+NHnD*epa(2Qsp<o6)m?qipn|RB2g&F=^pDP=Txfwh7iq*u!?fFaiv{&f zFmf9t+uE8|q{2yn$U>!-BhJ1n+1)8%Y%U;V8<v=_#gu?9l+90(x3|6<a5_Niu*Z3) z6miU;bQZgJsGu^T%NOy_U{e=EDfS?|Z;Xy{_Ru|9aX%0v7lQ)W7b8#seASK)SKf{9 zs+&!@<*SM1jXo!o;<_}$BlzgFIu>8OD|jW=?PxgRN}juSRYw{NUb@jf`)e-%gQ@QO z?TzE&h#@!>?nRvC((V#<21SI6Qj4hjmEW$|1M=1pc|VFYldX8eeGK@n3iT0j2hPJH zN)bxOI<Rsr>LG>YTn|(93=Qwva^!L-P4a+<J6R)Ejj$Cf?#8)&Eafly3mjCTFJ2!n zu5{PZ(gF%ArsY(UEnH@y;aUXfm^?N;sJ|U_D^3EAQi_YdCqZk@uB@nqel??_f$pRG z%U|v)WEWX`gV|)^hD^Ld`v!zz*!SU?wB~r8_w-L7l&K444zJfIG=6=elwPLixD1*b zEHC=2HotW4pyzmrfWkMCSLF`l!*1frGi2q0T2z4f#Gh~Gu<Yy}AgYr!rD-WhI)%_3 zjMh^xqIfk86_vWwjR{(5lFohhQ)N?A1cjv@yObEO$@=?Iv+_8D5?2=`rD6#&jmLSW zUQ%W{1MspMy1w$Z0+W=kw_VMXmdKP&`$;)oRi8IZpP>FZ`OB?c_T?h$<OqAOjis+N zqdb}+cd~nPwDk#Sb|>7IV#Cc{Ki&9V**YBs40okL&U)K5oO6isYeDV{+F}`v@960G zdfzDKMie0%k_~CD%V(i;9ql|Ji|71pwp7>litv>Ku0qRGkeCA@Ji>%FC*SIo4F-er zSCsdUaGueV;Egsyn*=X|PXXFL!_7So+-@6BIi~yh6+amF0e_n?#{)^iD9fF!l9!R! zh+s#8=sz6iRQ18Qi>?>|?U!z&)82`AjxOMVAx7HBR#Zx7G6-cT#%tE#Y76|1<-kWz z;2jQgZ2BGtH2i~_F~R(YO#Po4XTAe2G|YDE5T=Y+ZJtQJWdP<geM_%4)%<HjU5jiH zgvDw|e&_u?yAoiJ4dgi_9Q~K;$p3l1_fL=Te?1dSxd|s6%_|^K6B!+CQBd^i#V=H$ zK-fJPP3%{Pe&GFdg7pT|bKY}S0~u@&fiDjP4RvIQDY||F%_x-H4+{vcFO}_;K2xLF zuYl(~6R@Q+5OZf1$&dSgqn7XRBdlWz3v+YGl*R$fNaIOI>URnees+b8A0h$E?jf-P zLL>S7Z6Dngg(JUnlfFV(Opgj;=fFqVqEZ>dI`Z08kG=e&s}cqr@(#B}#2SEtLai23 zxziPZlFNqH8oMz67GQl^-9YgoIPkO`@Ww_6uh06{jR-1M0u!K1iaI&vbCv`lZDA-p zZjYGt4x9-(A~hfG=bO|4AH!0;qY>y6TC_9|k)u~@PjdEe+?9RqbOou(^l+eW?{jog zS7)PkTWlAA&E1f|N}%5jvrh2?UK`NcCU)IMBX5LxA=G65-=CzYRz{dybH((oS`8t< z@eFX0fvZ-vGr#~$lY=|H3wM0FP%FmjbBA=qJB)--x5ukyjV-m*#$48yniunaHhGzA zGZ<H8GpI4~nr#qhD#`79Tp8e`#5jPkaG5}oz<dRK(0_K+=Of3xw*f$3UV8D%%SxPX z&=dUm=n029w4oez?oDI;UNjeCise{f3x3Gh_KSjD;5b+k9U$1+is$BGBbzArW851B zVtCGF-W)CL>U!I^AFU0>trud&*-}%`J+EtOxbZmZC+sJ|SJMZofh5(y8m!*j+}NXC zAZxsFnS2qGGO?VORCnmmVc2T}ardX75CFGpgoLwvELv5VP9U?4%~_~8JF*3uA*R>+ zjq?Sh<UD~eLE5^%Ne{Ga6S*AVaCPECk#wTcMpw<b(3=T1zo`|$ll<)}4bDL&;h2B+ zHL!Sr2bkd8*bcfzat3(4hVXl<NTeQD_XH4(+FGwhp3lnsqH=W)m`TJQ0;bMP;Ur|z zMon+dIXJ|!9i5dQN_UES#?r##)tT07=rav`3`n*kb5m1g$`ArW{0vYjfJV#53WyHg zQyJWZ#9<ANI78|_dRlR3-6E(jl}sL^KmT758E{v#J$qqh)j3XdScpzrNGQ~nHX5BQ zSSA&dul>?LIM2T?Pp<os121PVv>;AFoU_3~`izl>@^2z8z>t;Tm+a-F`V+|moW=lo zyO08Sq2=*~N|4_JpjjCo?B$yv{qIk4|Bc%N|FbOm|DWPuKL0;D4jy{K1{GYKmoKM* zAR)uHoY=wRQL+C?AtQ2f(v<SZ5tPa3NJXY*c2h&c8`o&aiVGj=x{Z>{iiC3>+*GXh zoWMpo!`2)6d4*kule5kJx#>E@Z*#rSyX4UX>DdvRfjEATQnz^Dj{VvW_9Azr)&y(g z<x*=sH?~T}rG@iu?I}?tjg)uAJ&!ZL7-5KaL_+6>Q**stW`;cK+pHEU-Hhi)$mhH( z*A#}Z2I>c3^NED9gy=m%4Yv#(>a%C(R2CwcF(@UamdXkj^`l5jo}w%8&s-63Kgb`P z&xkyq>6$s0MVC&9{4ky{maQwo&01<cl_qfMnek>!h}BUHBe~P)O3Fx%9ta_?wEkE& zPWA4H)VW%9abDQXY{6^acvb$M=J9fsY4xJ_bH!TJdRFZ2DB+yR@2~IIurZJ$h(F~@ zgjl!uVTMd6(%J7yu`Q2$;;OW^JlG39u>1CElpkE$ugVR$-+!;l{b_Fo&%d`lUxFJj zA}l-RJ2hsXM5TT)-%PY6cZHZ2<}6Gqv@~}skGjWH`JJ)j=btjNJ}Fu=+v|Rvu<AMC z$bWHIx8%p37XASe+x;8x3>cikQapE-Cgm(zJo|saZMA=zIVoyrD3+MD!ZsyP<vX2E zNHCH*HiW5y-Z^;@wC7uTQBlg6PF%mjc&;bEsa;_B&cNmtVg9*~Y%jn5JxS|6T2!RG zB<u~Bo$TrJJ0~rEFxr-RyFH+2Etw3KUDp@g?$?qbq!kv<rbOtc?+-iaZb`U^Cmotw z?Cqsu$$2uHDKnm@r?`-4aaW>vjQ)d2UFG)3OY-T*KK)2p-M#aMKVdq*bLK_Kv;JLI zww0U)vYyDmbGwT5SvwaLEOxg<K^JQ^zdV3E>Xuu7hjEBR;u@F!wK+mUOq`4LYMLU8 z|D5fx7T^6Z69ekK(-EsDkZ*ibQ#kMX_1D=0D|XSK|CqPtxaQ|=JC%}qpJ}{BXxWNk zx=C+=C<ZStk27TGpTZ(2Poc<388feV<zV?S0!RoaZWHAb79REE`H4z2HYaz-j@=$c zP;A)A-4>)WwD^Mq#7*2*;o3gfU8qID)$3&jdv^}Q?D;wB=Y;Hv<g_M5#9<Jy^Tz4% zKkODAUDI-Y-z?6zp8G~nT*t4iN$Wf3Cnxt@lpuc!UEj#bC#L?}!>--$It}fAW{L25 z)xu_;G~ZZCfTBYFOqv($L9Rp-^ICAG!>~Z8YQcQMG1u0)F_lx=W3H`TvbNk3`P_9T zZXP}{#~ssqpBz{jG?{fW)mE^1D^lJ*-s%eXHNNe!)&-H#UkaCp$~bU=zt%M!p7L;& zO31)E%W~&rYQD${iNxC|5mV{r$1IRZ9z_?~#|}MjZrsAqNlWL18e}Zrh{&kN(dhSn z7onF9_V%XlSN^tBOp2<I3!xdH2|xH!QToSH)2Yhx!;}KYmFCu$m#+ol%vdvmxCESp z;_URc5<cwfE6sJVF8BFnY)rOGr9a!OtI>r`zE4_@Pt2;;bO@5~yYbhTL4hcx$#`jd zQGS`Jge2oR!h~S74m!#=Q~gGctg!3X`T1;?0aG^9hoUN84xe6*o{p3B-7r$Ex^%~9 zH+y8H(p9|%D~uehx@m4Lth4qRd+PB(C3D(XNdqiSYhxoao2E>1wp7P5E;yL#!lyoV z=IxD*v$P#wf6Anw`1!WN&EM5%&q8_oPM26P{7JOd{bYXoL;ixYB0Q&AXhvXvW`SmL zY;X}WR0)5o_cT&!I(FgTCoU5{=;g}DcdS0_sxMJE!%rYMDh5Z<F=VvhTF#{EU%EHj zk%zLFw#esZ32?lT%f(L<#qb<|s+FcFGf0gbLik$POvU4MZOew#^wu8Sa#&yYCH$E~ z(v3=VxW)3}Vv(wdhS|l!5~6VNgyWqSvz89@lw+^JY-jLda-wTL17@Xx+%`z~z-2hD zYbNe;x(03c!R%*+D5E*`?sIwzPTUNdD&nqjs`J-wOFO^ha;Q}5Khw=3^DO_TL62ky z+y3GeH20Rn-HXUd>QA4!aMIGvvY3Dya6Iu@wq`IhrqLdA>)Xt^VD;u19J(pXwIydO zDXO?)2&>e;I(x0}yhgE}-mnZ8GWOSP(j}QS9n*+`%lk|jg?f4o)|Ts-=wN*IcPyhz z+@X2B>w8slOkQZG#Jql&TcodGQ#T*jSbH{*Jgy;X+Q(g|Zbp3~*j}Aa;mYDI6N?_$ zjnS)_Vu*^>&Sq#?$waHV?pEA0IHJA(%N_?R>R1Jh*>GSFgX(4S)nmVz6EjRd7dQ^w z&ur|hXdKL%ZL(0c5*iK4BtEOlXi}RxzLLFFwOH3ziBGZBGn03v`*zwAk6ij#;<*#S z>Pm$<Y*Z5QDh<{%jjC=%YWxgQ(E<7+p6VYlg)3u<j`{kS$RuvAyO{zvY{-s<ma&>C zx+?pI%A^yuXlIShVnqlMn#`g4(gFCc2UFd(GzNy452O3|nv9s&)(NM{kDQbhNT_Vg z)R~Ay>pz<Nwr7wbo6@G!mx>)qsci`m9OfU5i@s1ClbFZl=aRRw!6im%O&(p$(7PqQ z7H!))O_5wxLZzX4%aE(;sa}RCUfQTK1(*JxGRoUBhx%+X!&1f7eUJ1OSgul%Pk1>~ z$4j+LpgPu6cfr_XU724qd%l)1G_5{;IKEM8csD*O+DjuU$fK7$5+4Nmlhe(o;<Amh zTG$vek8@SyD7#TXnO@jv5tna!G%b@H<mLWy?>jkOk=(lrCa=|Py!{0lakzlXBC+B! zrKMpTIt5Sl){dhS^vD+=q>-vFE5h+>Z;#@)z^*@pHPGL5rzj=Rcn|(r@#A?BLCEv3 z?u^(r{lF!M?KC`G6dqg@NVFl~L-Bi}msgPP#r4A)1yY$--bdWJ%!PXzhXpfr-j~dU z$0OHFF;z&P+&|zc9WG%h!@$retLL_s%eQ`!jU1sx<zm0ab(0F@i%%Ei6*RKg&h1Sf z?YM?~&pp9&umJ1Ytt#w#&&^9UoV-m_0&~}`$5KJtm*Zotv(RPNFvU5?kPnM;xc?Ed zqQUHc{a}rnyx_59-4h&}CnALW`|VT89=UxP_-%imKO%DjemRQ7mBSQ%pPHLCyD3n} Prxb1}-OTyJ=<)vo<ijIl literal 0 HcmV?d00001 diff --git a/.github/pr-screenshots/39327/tools-expanded.png b/.github/pr-screenshots/39327/tools-expanded.png new file mode 100755 index 0000000000000000000000000000000000000000..1f57248e69078824ae928d4de624cff7d6544614 GIT binary patch literal 36975 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 E03RzT4gdfE literal 0 HcmV?d00001 diff --git a/apps/desktop/src/app/settings/credential-key-ui.tsx b/apps/desktop/src/app/settings/credential-key-ui.tsx index 4c916b3c04..8003b34875 100644 --- a/apps/desktop/src/app/settings/credential-key-ui.tsx +++ b/apps/desktop/src/app/settings/credential-key-ui.tsx @@ -2,7 +2,7 @@ import { type ChangeEvent, type KeyboardEvent } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { ExternalLink, Loader2, Save } from '@/lib/icons' +import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons' import { cn } from '@/lib/utils' import type { EnvVarInfo } from '@/types/hermes' @@ -133,79 +133,196 @@ function CredentialDocsLink({ href }: { href: string }) { ) } -/** One credential row — same ListRow layout as Advanced config fields. */ +/** One credential row — collapsible; description and docs link expand on click. */ export function CredentialKeyCard({ + expanded, info, label, + onExpand, + onToggle, placeholder, rowProps, varKey -}: { - info: EnvVarInfo - label: string - placeholder: string - rowProps: KeyRowProps - varKey: string -}) { +}: CredentialKeyCardProps) { const docsUrl = info.url?.trim() const description = info.description?.trim() + const expandable = Boolean(description || docsUrl) return ( - <ListRow - action={<KeyField info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />} - below={docsUrl ? <CredentialDocsLink href={docsUrl} /> : undefined} - description={description} - title={label} - /> + <div + className={cn( + 'group/card rounded-[6px] px-2 py-1 transition-colors', + expandable && 'cursor-pointer', + expandable && !expanded && 'hover:bg-(--ui-row-hover-background)', + expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)' + )} + onClick={expandable ? onToggle : undefined} + onKeyDown={ + expandable + ? e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + } + : undefined + } + role={expandable ? 'button' : undefined} + tabIndex={expandable ? 0 : undefined} + > + <div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center"> + <div className="flex min-w-0 items-center gap-2"> + <span + className={cn( + 'size-2 shrink-0 rounded-full', + info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)' + )} + /> + + <span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground"> + {label} + </span> + + {expandable && ( + <ChevronDown + className={cn( + 'size-3.5 shrink-0 text-muted-foreground transition', + expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100' + )} + /> + )} + </div> + + <div + className="min-w-0 sm:justify-self-end" + onClick={e => e.stopPropagation()} + onFocus={() => { + if (expandable && !expanded) { + onExpand() + } + }} + > + <KeyField info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} /> + </div> + </div> + + {expandable && expanded && ( + <div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}> + {description && ( + <p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + {description} + </p> + )} + + {docsUrl && <CredentialDocsLink href={docsUrl} />} + </div> + )} + </div> ) } -/** Provider API key group — primary + optional advanced fields as ListRows. */ -export function ProviderKeyRows({ - group, - rowProps -}: { - group: ProviderKeyRowGroup - rowProps: KeyRowProps -}) { +/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */ +export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) { const docsUrl = group.docsUrl?.trim() const description = group.description?.trim() - const docsBelow = docsUrl ? <CredentialDocsLink href={docsUrl} /> : undefined + const expandable = Boolean(description || docsUrl || group.advanced.length > 0) return ( - <> - <ListRow - action={ + <div + className={cn( + 'group/card rounded-[6px] px-2 py-1 transition-colors', + expandable && 'cursor-pointer', + expandable && !expanded && 'hover:bg-(--ui-row-hover-background)', + expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)' + )} + onClick={expandable ? onToggle : undefined} + onKeyDown={ + expandable + ? e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + } + : undefined + } + role={expandable ? 'button' : undefined} + tabIndex={expandable ? 0 : undefined} + > + <div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center"> + <div className="flex min-w-0 items-center gap-2"> + <span + className={cn( + 'size-2 shrink-0 rounded-full', + group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)' + )} + /> + + <span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground"> + {group.name} + </span> + + {expandable && ( + <ChevronDown + className={cn( + 'size-3.5 shrink-0 text-muted-foreground transition', + expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100' + )} + /> + )} + </div> + + <div + className="min-w-0 sm:justify-self-end" + onClick={e => e.stopPropagation()} + onFocus={() => { + if (expandable && !expanded) { + onExpand() + } + }} + > <KeyField info={group.primary[1]} placeholder={`Paste ${group.name} key`} rowProps={rowProps} varKey={group.primary[0]} /> - } - below={docsBelow} - description={description} - title={group.name} - /> - {group.advanced.map(([key, info]) => { - const fieldLabel = isKeyVar(key, info) ? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, '')) : friendlyFieldLabel(key, info) + </div> + </div> - return ( - <ListRow - action={ - <KeyField - info={info} - placeholder={credentialPlaceholder(key, info, fieldLabel)} - rowProps={rowProps} - varKey={key} + {expandable && expanded && ( + <div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}> + {description && ( + <p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + {description} + </p> + )} + + {group.advanced.map(([key, info]) => { + const fieldLabel = isKeyVar(key, info) + ? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, '')) + : friendlyFieldLabel(key, info) + + return ( + <ListRow + action={ + <KeyField + info={info} + placeholder={credentialPlaceholder(key, info, fieldLabel)} + rowProps={rowProps} + varKey={key} + /> + } + key={key} + title={fieldLabel} /> - } - key={key} - title={fieldLabel} - /> - ) - })} - </> + ) + })} + + {docsUrl && <CredentialDocsLink href={docsUrl} />} + </div> + )} + </div> ) } @@ -217,10 +334,30 @@ export function credentialRowLabel(varKey: string, info: EnvVarInfo): string { return prettyName(varKey) } +interface CredentialKeyCardProps { + expanded: boolean + info: EnvVarInfo + label: string + onExpand: () => void + onToggle: () => void + placeholder: string + rowProps: KeyRowProps + varKey: string +} + +interface ProviderKeyRowsProps { + expanded: boolean + group: ProviderKeyRowGroup + onExpand: () => void + onToggle: () => void + rowProps: KeyRowProps +} + export interface ProviderKeyRowGroup { advanced: [string, EnvVarInfo][] description?: string docsUrl?: string + hasAnySet: boolean name: string primary: [string, EnvVarInfo] } diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx index 9918451f32..89545acc4f 100644 --- a/apps/desktop/src/app/settings/keys-settings.tsx +++ b/apps/desktop/src/app/settings/keys-settings.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import type { EnvVarInfo } from '@/types/hermes' @@ -28,6 +28,11 @@ const VIEW_CATEGORIES: Record<KeysView, readonly string[]> = { export function KeysSettings({ view }: KeysSettingsProps) { const { rowProps, vars } = useEnvCredentials() + const [openKey, setOpenKey] = useState<null | string>(null) + + useEffect(() => { + setOpenKey(null) + }, [view]) const groups = useMemo(() => { if (!vars) { @@ -54,15 +59,18 @@ export function KeysSettings({ view }: KeysSettingsProps) { return ( <SettingsContent> {visible.map(group => ( - <div className="grid gap-1" key={group.category}> + <div className="grid gap-2" key={group.category}> {group.entries.map(([key, info]: [string, EnvVarInfo]) => { const label = credentialRowLabel(key, info) return ( <CredentialKeyCard + expanded={openKey === key} info={info} key={key} label={label} + onExpand={() => setOpenKey(key)} + onToggle={() => setOpenKey(prev => (prev === key ? null : key))} placeholder={credentialPlaceholder(key, info, label)} rowProps={rowProps} varKey={key} diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index 759d61a44d..413ebd2827 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -165,6 +165,7 @@ function NoProviderKeys() { export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) { const { rowProps, vars } = useEnvCredentials() const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([]) + const [openProvider, setOpenProvider] = useState<null | string>(null) // The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we // re-read connection state when the user finishes (or dismisses) a sign-in // they launched from this page — otherwise the cards keep their stale status. @@ -208,9 +209,16 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps return ( <SettingsContent> {keyGroups.length > 0 ? ( - <div className="grid gap-1"> + <div className="grid gap-2"> {keyGroups.map(group => ( - <ProviderKeyRows group={group} key={group.name} rowProps={rowProps} /> + <ProviderKeyRows + expanded={openProvider === group.name} + group={group} + key={group.name} + onExpand={() => setOpenProvider(group.name)} + onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))} + rowProps={rowProps} + /> ))} </div> ) : ( From 825629424d765da6a86b01ad9352ba2f98f51b61 Mon Sep 17 00:00:00 2001 From: Ben <ben@nousresearch.com> Date: Thu, 4 Jun 2026 11:00:31 +1000 Subject: [PATCH 17/52] fix(tui): persist timed-out/cancelled clarify prompts in transcript When a clarify prompt times out (backend _block returns an empty answer after the configured timeout) or is dismissed with Esc/Ctrl+C, the live ClarifyPrompt overlay was torn down by turnController.idle() -> resetFlowOverlays() with no persistent transcript record. The question and options vanished from the screen while the agent's follow-up still referred to "the options above". The answered path already persists the question + answer; only the unanswered exits left no trace. This asymmetry is the bug. Fix (TUI layer only, no Python/protocol change): - formatAbandonedClarify() in lib/text.ts renders the question + the same 1-based numbered option list shown by ClarifyPrompt, plus a reason ('timed out' / 'cancelled'). - Timeout: createGatewayEventHandler flushes a still-live clarify into the transcript as a plain system line when the clarify tool's own tool.complete fires. A live capture of the event stream confirmed this is the only point where the overlay is still set after a timeout: the sequence is clarify.request -> (timeout) -> tool.complete -> message.complete, with NO intervening message.start/tool.start. On a real answer, answerClarify() clears the overlay before tool.complete arrives, so the hook no-ops there (no double-write); a per-requestId guard set is belt-and-braces. - Explicit cancel: answerClarify('') persists the prompt as a system line instead of a transient 'prompt cancelled' flash. System lines always render (unlike trail lines, which /details can hide), so the record reliably survives on screen as standard output. Verified live in the TUI: an Esc-cancelled clarify now leaves the question + options + '(cancelled - no selection)' in the transcript after the turn ends. Tests: formatAbandonedClarify unit cases + gateway-handler behavioral cases (persist on clarify tool.complete, no flush on a non-clarify tool.complete, no double-persist on repeat tool.complete, no-op when the overlay was already cleared by an answer). --- .../createGatewayEventHandler.test.ts | 64 +++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 39 ++++++++++- ui-tui/src/app/useMainApp.ts | 12 +++- ui-tui/src/lib/text.test.ts | 33 +++++++++- ui-tui/src/lib/text.ts | 16 +++++ 5 files changed, 159 insertions(+), 5 deletions(-) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index afebc4d10a..1b433220b6 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -1113,4 +1113,68 @@ describe('createGatewayEventHandler', () => { vi.useRealTimers() } }) + + it('persists an abandoned (timed-out) clarify into the transcript when the clarify tool completes', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + // Backend clarify timed out: the overlay is still live (Python returned an + // empty answer), and the clarify tool's own tool.complete then fires. + patchOverlayState({ + clarify: { choices: ['Scope A', 'Scope B'], question: 'How do you want to scope?', requestId: 'req-1' } + }) + + onEvent({ payload: { duration_s: 300, name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any) + + const record = appended.find(msg => msg.role === 'system' && msg.text.startsWith('ask How do you want to scope?')) + expect(record).toBeDefined() + expect(record?.text).toContain('1. Scope A') + expect(record?.text).toContain('2. Scope B') + expect(record?.text).toContain('timed out — no selection') + // The live overlay is cleared so it doesn't double-render with the record. + expect(getOverlayState().clarify).toBeNull() + }) + + it('only persists an abandoned clarify once even if tool.complete fires twice', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + patchOverlayState({ + clarify: { choices: ['A'], question: 'Pick?', requestId: 'req-3' } + }) + + onEvent({ payload: { name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any) + // A duplicate clarify tool.complete must not re-persist the same prompt. + onEvent({ payload: { name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any) + + const records = appended.filter(msg => msg.role === 'system' && msg.text.startsWith('ask Pick?')) + expect(records).toHaveLength(1) + }) + + it('does not flush the clarify overlay when a non-clarify tool completes', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + // A clarify is live, but it's a *different* tool that just completed — the + // clarify itself is still pending, so we must not persist or clear it. + patchOverlayState({ + clarify: { choices: ['A', 'B'], question: 'Pick?', requestId: 'req-4' } + }) + + onEvent({ payload: { name: 'search', tool_id: 'tool-1' }, type: 'tool.complete' } as any) + + expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false) + expect(getOverlayState().clarify).not.toBeNull() + }) + + it('does not persist when an answered clarify already cleared the overlay before tool.complete', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + // Answered path (answerClarify) clears the overlay before the agent's + // tool.complete arrives, so there's nothing live to persist. + onEvent({ payload: { duration_s: 4.2, name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any) + + expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false) + }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 987518a446..6e40e1c756 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -11,7 +11,7 @@ import type { } from '../gatewayTypes.js' import { rpcErrorMessage } from '../lib/rpc.js' import { topLevelSubagents } from '../lib/subagentTree.js' -import { formatToolCall, stripAnsi } from '../lib/text.js' +import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js' import { fromSkin } from '../theme.js' import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' @@ -87,6 +87,35 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null let startupPromptSubmitted = false + // Request IDs of clarify prompts we've already flushed to the transcript as + // an abandoned-prompt record, so the tool.complete and message.complete + // paths can't both persist the same prompt twice. + const persistedAbandonedClarify = new Set<string>() + + // When a clarify prompt is dismissed without an answer (the backend _block + // timed out and returned an empty string), the live ClarifyPrompt overlay is + // left set until the next turn's idle() silently nulls it — so the question + // and options vanish from the screen while the agent's follow-up still refers + // to them. The reliable signal is the clarify tool's own tool.complete (and, + // as a backstop, message.complete): at those points the overlay is provably + // still set on a timeout, but already cleared by answerClarify() on a real + // answer (so this no-ops there). Flush the question + options into the + // transcript as a persistent system line, then clear the overlay. + const flushAbandonedClarify = () => { + const { clarify } = getOverlayState() + + if (!clarify || persistedAbandonedClarify.has(clarify.requestId)) { + return + } + + persistedAbandonedClarify.add(clarify.requestId) + appendMessage({ + role: 'system', + text: formatAbandonedClarify(clarify.question, clarify.choices, 'timed out') + }) + patchOverlayState({ clarify: null }) + } + // Inject the disk-save callback into turnController so recordMessageComplete // can fire-and-forget a persist without having to plumb a gateway ref around. turnController.persistSpawnTree = async (subagents, sessionId) => { @@ -624,6 +653,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'tool.complete': { + // The clarify tool finishing with its overlay still live means it was + // abandoned (backend _block timed out, empty answer). A real answer + // clears the overlay in answerClarify() before this fires, so this + // no-ops there. Persist the question + options so they don't vanish. + if (ev.payload.name === 'clarify') { + flushAbandonedClarify() + } + const inlineDiffText = ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : '' diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 6c48c56f95..509e877587 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -25,7 +25,7 @@ import { appendTranscriptMessage } from '../lib/messages.js' import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { terminalParityHints } from '../lib/terminalParity.js' -import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' +import { buildToolTrailLine, formatAbandonedClarify, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js' import type { Msg, PanelSection, SlashCatalog } from '../types.js' @@ -608,13 +608,19 @@ export function useMainApp(gw: GatewayClient) { appendMessage({ role: 'user', text: answer }) patchUiState({ status: 'running…' }) } else { - sys('prompt cancelled') + // Esc / Ctrl+C cancel: persist the question + options as a system + // line (not a transient "prompt cancelled" flash) so the prompt + // survives on screen as standard output, matching the timeout path. + appendMessage({ + role: 'system', + text: formatAbandonedClarify(clarify.question, clarify.choices, 'cancelled') + }) } patchOverlayState({ clarify: null }) }) }, - [appendMessage, overlay.clarify, rpc, sys] + [appendMessage, overlay.clarify, rpc] ) const paste = useCallback( diff --git a/ui-tui/src/lib/text.test.ts b/ui-tui/src/lib/text.test.ts index 1a3800ec76..ebea2f5b5c 100644 --- a/ui-tui/src/lib/text.test.ts +++ b/ui-tui/src/lib/text.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { stripTrailingPasteNewlines } from './text.js' +import { formatAbandonedClarify, stripTrailingPasteNewlines } from './text.js' describe('stripTrailingPasteNewlines', () => { it('removes trailing newline runs from pasted text', () => { @@ -16,3 +16,34 @@ describe('stripTrailingPasteNewlines', () => { expect(stripTrailingPasteNewlines('\n\n')).toBe('\n\n') }) }) + +describe('formatAbandonedClarify', () => { + it('renders the question, numbered options, and reason', () => { + const out = formatAbandonedClarify('How do you want to scope?', ['Option A', 'Option B', 'Option C'], 'timed out') + + expect(out).toBe( + ['ask How do you want to scope?', ' 1. Option A', ' 2. Option B', ' 3. Option C', ' (timed out — no selection)'].join( + '\n' + ) + ) + }) + + it('handles a prompt with no choices (free-text clarify)', () => { + const out = formatAbandonedClarify('What is the target branch?', null, 'cancelled') + + expect(out).toBe(['ask What is the target branch?', ' (cancelled — no selection)'].join('\n')) + }) + + it('trims surrounding whitespace on the question', () => { + const out = formatAbandonedClarify(' trailing space ', [], 'timed out') + + expect(out.split('\n')[0]).toBe('ask trailing space') + }) + + it('numbers options 1-based to match the live ClarifyPrompt', () => { + const out = formatAbandonedClarify('q', ['first'], 'timed out') + + expect(out).toContain(' 1. first') + expect(out).not.toContain(' 0.') + }) +}) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index feb3547a38..b1e86e3675 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -338,6 +338,22 @@ export const estimateRows = (text: string, w: number, compact = false) => { return Math.max(1, rows) } +/** + * Render an unanswered clarify prompt (timed out, or cancelled with Esc/Ctrl+C) + * as a persistent transcript block. The live `ClarifyPrompt` overlay is torn + * down the moment the turn settles, so without this the question + options + * vanish from the screen while the agent's follow-up still refers to "the + * options above". Mirrors the option formatting in ClarifyPrompt (the same + * 1-based numbered list) so the persisted record reads identically to what was + * on screen. `reason` states why the prompt ended ("timed out", "cancelled"). + */ +export const formatAbandonedClarify = (question: string, choices: string[] | null, reason: string) => { + const head = `ask ${question.trim()}` + const opts = (choices ?? []).map((c, i) => ` ${i + 1}. ${c}`) + + return [head, ...opts, ` (${reason} — no selection)`].join('\n') +} + export const flat = (r: Record<string, string[]>) => Object.values(r).flat() const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' }) From 495c3733d8cedca83f741560623d5efd86f2ab03 Mon Sep 17 00:00:00 2001 From: Dusk <135010814+Dusk1e@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:31:01 +0300 Subject: [PATCH 18/52] fix(config): bridge docker_volumes and docker_forward_env in config set (#38611) Co-authored-by: Ben Barclay <ben@nousresearch.com> --- hermes_cli/config.py | 5 +++ tests/tools/test_terminal_config_env_sync.py | 47 +++++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a6948c23f2..d0b4493ddd 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5958,6 +5958,11 @@ def set_config_value(key: str, value: str): "terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", "terminal.docker_env": "TERMINAL_DOCKER_ENV", + # JSON-valued keys (terminal_tool parses these via json.loads). The user + # passes JSON on the CLI, so str(value) below already yields valid JSON — + # same as terminal.docker_env. cli.py and gateway/run.py bridge these too. + "terminal.docker_volumes": "TERMINAL_DOCKER_VOLUMES", + "terminal.docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV", # terminal.cwd intentionally excluded — CLI resolves at runtime, # gateway bridges it in gateway/run.py. Persisting to .env causes # stale values to poison child processes. diff --git a/tests/tools/test_terminal_config_env_sync.py b/tests/tools/test_terminal_config_env_sync.py index 1613184341..a3b9b14dd2 100644 --- a/tests/tools/test_terminal_config_env_sync.py +++ b/tests/tools/test_terminal_config_env_sync.py @@ -156,14 +156,15 @@ def test_cli_and_gateway_env_maps_agree(): def test_save_config_set_supports_critical_bridged_keys(): """``hermes config set terminal.X true`` must propagate to .env for - known-critical keys. This used to be an all-keys invariant but several - pre-existing terminal keys (ssh_*, docker_forward_env, docker_volumes) - aren't in _config_to_env_sync and are instead handled via the separate - api_keys TERMINAL_SSH_* fallback path or user-edits-yaml-directly. + known-critical keys. This used to be an all-keys invariant but the SSH + terminal keys (ssh_*) aren't in _config_to_env_sync and are instead + handled via the separate api_keys TERMINAL_SSH_* fallback path or + user-edits-yaml-directly. Until those gaps are audited and fixed, pin the specific keys that are - load-bearing for the docker backend's ownership flag so the bug we just - fixed cannot silently regress. + load-bearing for the docker backend so the bugs we fixed cannot silently + regress. (docker_volumes / docker_forward_env, previously listed here as + gaps, are now bridged — see the dedicated tests below.) """ save_keys = _save_config_env_sync_keys() required = { @@ -260,3 +261,37 @@ def test_docker_orphan_reaper_is_bridged_everywhere(): assert "docker_orphan_reaper" in _gateway_env_map_keys() assert "docker_orphan_reaper" in _save_config_env_sync_keys() assert "TERMINAL_DOCKER_ORPHAN_REAPER" in _terminal_tool_env_var_names() + + +def test_docker_volumes_is_bridged_everywhere(): + """Regression pin for ``terminal.docker_volumes`` being silently dropped by + ``hermes config set``. + + The JSON list of ``host:container`` bind mounts was bridged by cli.py and + gateway/run.py and consumed by terminal_tool (via json.loads), but was + missing from set_config_value's _config_to_env_sync. So + ``hermes config set terminal.docker_volumes '["/host:/workspace"]'`` wrote + config.yaml yet left the running process's TERMINAL_DOCKER_VOLUMES stale — + the mounts didn't apply until a full restart. Same four-site bridge + invariant as docker_env / docker_run_as_host_user. + """ + assert "docker_volumes" in _cli_env_map_keys() + assert "docker_volumes" in _gateway_env_map_keys() + assert "docker_volumes" in _save_config_env_sync_keys() + assert "TERMINAL_DOCKER_VOLUMES" in _terminal_tool_env_var_names() + + +def test_docker_forward_env_is_bridged_everywhere(): + """Regression pin for ``terminal.docker_forward_env`` — the sibling gap to + docker_volumes. + + The JSON list of host env-var names forwarded into the container was + bridged by cli.py and gateway/run.py and consumed by terminal_tool (via + json.loads), but missing from set_config_value's _config_to_env_sync, so + ``hermes config set terminal.docker_forward_env '["GITHUB_TOKEN"]'`` had no + effect on the running process until restart. + """ + assert "docker_forward_env" in _cli_env_map_keys() + assert "docker_forward_env" in _gateway_env_map_keys() + assert "docker_forward_env" in _save_config_env_sync_keys() + assert "TERMINAL_DOCKER_FORWARD_ENV" in _terminal_tool_env_var_names() From b434f8c3e081c618190dd1a14510335dc72ee98a Mon Sep 17 00:00:00 2001 From: Ben Barclay <ben@nousresearch.com> Date: Fri, 5 Jun 2026 09:46:36 +1000 Subject: [PATCH 19/52] fix(deps): promote markdown to a core dependency so rich delivery works out of the box (#32486) (#38649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `markdown` was declared only in the `matrix` optional extra, and the official Docker image installs `--extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight` — notably NOT `--extra matrix` (the matrix extra is deliberately routed to lazy-install because `mautrix[encryption]`/`python-olm` can't build on Windows/macOS — see the 2026-05-12 policy comment in `[all]`). Result: `markdown` never lands in the image venv, so the Markdown->HTML conversion on the DEFAULT delivery path silently falls back to plain text. Cron/agent deliveries render raw `##`/`**`/tables in clients like Element (no `formatted_body`). The conversion is now used by BOTH `gateway/platforms/matrix.py` and `tools/send_message_tool.py`, so it is no longer matrix-specific. `markdown` is a pure-Python `py3-none-any` wheel (~108KB, no compiled extensions, no platform constraints), so none of the reasons the matrix extra was lazy-routed apply to it. Promote it to a core dependency so it ships in the wheel, the Docker image, and every install; drop the now redundant copies from the `matrix` extra and the `platform.matrix` lazy-deps group; refresh the stale "installed with the matrix extra" docstring. Verified against a real build: ran the image's exact `uv sync` command (same extras, no `--extra matrix`) in a clean container off the new lockfile -> `import markdown` succeeds (3.10.2). On `origin/main` the same command leaves markdown absent. 223 targeted tests pass (test_matrix.py + test_lazy_deps.py). Closes #32486. --- gateway/platforms/matrix.py | 10 +++++----- pyproject.toml | 12 +++++++++++- tools/lazy_deps.py | 1 - uv.lock | 4 ++-- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index cccb4d70e1..a649bb91e5 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -2799,11 +2799,11 @@ class MatrixAdapter(BasePlatformAdapter): def _markdown_to_html(self, text: str) -> str: """Convert Markdown to Matrix-compatible HTML (org.matrix.custom.html). - Uses the ``markdown`` library when available (installed with the - ``matrix`` extra). Falls back to a comprehensive regex converter - that handles fenced code blocks, inline code, headers, bold, - italic, strikethrough, links, blockquotes, lists, and horizontal - rules — everything the Matrix HTML spec allows. + Uses the ``markdown`` library (a core dependency) when available. + Falls back to a comprehensive regex converter that handles fenced + code blocks, inline code, headers, bold, italic, strikethrough, + links, blockquotes, lists, and horizontal rules — everything the + Matrix HTML spec allows. """ try: import markdown as _md diff --git a/pyproject.toml b/pyproject.toml index 0ce6375288..2b12246e0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,16 @@ dependencies = [ "prompt_toolkit==3.0.52", # Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter). "croniter==6.0.0", + # Markdown -> HTML conversion for rich message delivery (Matrix + # `formatted_body`, and the `send_message` tool's HTML path). Now on the + # DEFAULT delivery path, not matrix-specific: without it both + # gateway/platforms/matrix.py and tools/send_message_tool.py silently fall + # back to plain text, so cron/agent deliveries render raw `##`/`**`/tables + # in clients like Element (see #32486). Pure-Python py3-none-any wheel + # (~108KB, no compiled extensions, no platform constraints), so unlike the + # matrix extra's `mautrix`/`python-olm` it's safe to ship everywhere — keeps + # it out of the lazy-install path that exists only for the heavy matrix deps. + "Markdown==3.10.2", # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) "PyJWT[crypto]==2.12.1", # CVE-2026-32597 # Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo`` @@ -104,7 +114,7 @@ dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-time messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] cron = [] # croniter is now a core dependency; this extra kept for back-compat slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"] -matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] +matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] # WeCom callback-mode adapter — parses untrusted XML POST bodies from # WeCom-controlled callback endpoints, so we use defusedxml (drop-in # replacement for stdlib xml.etree.ElementTree) to block billion-laughs diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 0c0a2a6e9d..5b5878eb45 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -135,7 +135,6 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { ), "platform.matrix": ( "mautrix[encryption]==0.21.0", - "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0", diff --git a/uv.lock b/uv.lock index b10a215c9f..c90be9adda 100644 --- a/uv.lock +++ b/uv.lock @@ -1398,6 +1398,7 @@ dependencies = [ { name = "fire" }, { name = "httpx", extra = ["socks"] }, { name = "jinja2" }, + { name = "markdown" }, { name = "openai" }, { name = "pathspec" }, { name = "prompt-toolkit" }, @@ -1502,7 +1503,6 @@ matrix = [ { name = "aiohttp-socks" }, { name = "aiosqlite" }, { name = "asyncpg" }, - { name = "markdown" }, { name = "mautrix", extra = ["encryption"] }, ] mcp = [ @@ -1642,7 +1642,7 @@ requires-dist = [ { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" }, - { name = "markdown", marker = "extra == 'matrix'", specifier = "==3.10.2" }, + { name = "markdown", specifier = "==3.10.2" }, { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" }, { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" }, { name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" }, From eb43a5b5d8c9a27ff59f61b59d675901b3b1390b Mon Sep 17 00:00:00 2001 From: zer0 spirits <122831462+Archerouyang@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:53:42 +0800 Subject: [PATCH 20/52] chore: improve .dockerignore with Python and common patterns (#6092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 欧阳 <archer@ouyangdeMac-mini.local> --- .dockerignore | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.dockerignore b/.dockerignore index 3c16d71b22..ee3947b253 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,21 @@ .gitignore .gitmodules +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ + +# Virtual environments +venv/ +env/ +ENV/ + # Dependencies node_modules **/node_modules @@ -24,7 +39,20 @@ ui-tui/packages/hermes-ink/dist/ # Environment files .env +.env.* +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Documentation *.md # Runtime data (bind-mounted at /opt/data; must not leak into build context) From 6ad015255d0f75ede2d9b35b2dd9d1cde0a73343 Mon Sep 17 00:00:00 2001 From: bluefishs <125471205+bluefishs@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:54:01 +0800 Subject: [PATCH 21/52] chore: enforce LF line endings for container entrypoints (#12181) Windows contributors checking out on NTFS with git's default core.autocrlf will end up with CRLF in docker/entrypoint.sh. When COPY'd into the image and invoked as ENTRYPOINT, the kernel interprets the trailing \r as part of the interpreter path, producing a confusing 'no such file or directory' despite the file being present and executable. Lock LF for the usual suspects (*.sh, Dockerfile, *.dockerfile, and the specific docker/entrypoint.sh). The existing tree is already LF; this is preventive against future Windows regressions only. --- .gitattributes | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitattributes b/.gitattributes index 8726216891..553e3cd21b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,10 @@ # Auto-generated files — collapse diffs and exclude from language stats web/package-lock.json linguist-generated=true + +# Enforce LF for scripts that run inside Linux containers. +# Without this, Windows checkout converts to CRLF and breaks `exec` in the +# container entrypoint with "no such file or directory". +*.sh text eol=lf +Dockerfile text eol=lf +*.dockerfile text eol=lf +docker/entrypoint.sh text eol=lf From 5300727a08eb74afd3649118142af8e25e08d05a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:54:40 -0700 Subject: [PATCH 22/52] revert: keep Google Chat OAuth secret + active_provider profile-scoped (#39398) * Revert "fix(gateway): anchor Google Chat OAuth client secret to default Hermes root" This reverts commit fff0561441d26f8056af5e64bf44c0a54cad5ecc. * Revert "fix(cli): honor global-root active_provider fallback for named profiles" This reverts commit 3858cf43075edc7a7d530ed18a4934eb79c81ce4. * docs(google_chat): describe OAuth client secret as profile-scoped, not host-wide The setup docs, oauth docstring, and the adapter's 'no credentials' error message all described the Google Chat OAuth client secret as host-wide shared infrastructure. That contradicts profile isolation: profiles are separate auth boundaries, so two profiles can point at different Google OAuth apps / accounts. Reword all three to say the secret is profile-scoped and each profile registers its own. --- hermes_cli/auth.py | 40 +------ plugins/platforms/google_chat/adapter.py | 2 +- plugins/platforms/google_chat/oauth.py | 49 +------- tests/gateway/test_google_chat.py | 43 ------- .../hermes_cli/test_auth_profile_fallback.py | 106 +----------------- .../docs/user-guide/messaging/google_chat.md | 37 +++--- 6 files changed, 32 insertions(+), 245 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 165410bcdc..021905c3ec 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1322,38 +1322,10 @@ def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]: return _load_provider_state(auth_store, provider_id) -def _active_provider_from_store(auth_store: Dict[str, Any]) -> Optional[str]: - """Return the active provider for a loaded auth store. - - In profile mode, falls back to the global-root ``auth.json`` when the - profile store has no ``active_provider`` set. This mirrors the per-provider - shadowing already used by ``_load_provider_state`` and - ``read_credential_pool``: a named profile that never selected its own - provider still resolves the provider the user authenticated at the global - root (e.g. a Nous OAuth login), so ``model.provider: auto`` works under a - profile. A profile that has its own ``active_provider`` always wins; the - fallback only fires when the profile has none. Returns ``None`` when - neither scope has one. In classic mode ``_load_global_auth_store`` returns - an empty dict, so this is a no-op. See issue #18594 follow-up. - """ - active = auth_store.get("active_provider") - if active: - return active - global_store = _load_global_auth_store() - if global_store: - return global_store.get("active_provider") - return None - - def get_active_provider() -> Optional[str]: - """Return the currently active provider ID from auth store. - - In profile mode this falls back to the global-root ``active_provider`` - when the profile has not selected one of its own — see - ``_active_provider_from_store``. - """ + """Return the currently active provider ID from auth store.""" auth_store = _load_auth_store() - return _active_provider_from_store(auth_store) + return auth_store.get("active_provider") def is_provider_explicitly_configured(provider_id: str) -> bool: @@ -1575,14 +1547,10 @@ def resolve_provider( if explicit_api_key or explicit_base_url: return "openrouter" - # Check auth store for an active OAuth provider. In profile mode this - # honors the global-root active_provider when the profile has none of its - # own, mirroring the credential-pool / provider-state fallbacks so a - # named profile running model.provider: auto can use a globally - # authenticated provider. See issue #18594 follow-up. + # Check auth store for an active OAuth provider try: auth_store = _load_auth_store() - active = _active_provider_from_store(auth_store) + active = auth_store.get("active_provider") if active and active in PROVIDER_REGISTRY: status = get_auth_status(active) if status.get("logged_in"): diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 0fdf1ea9d8..f91a544170 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -1390,7 +1390,7 @@ class GoogleChatAdapter(BasePlatformAdapter): if arg == "start": if not oauth_helper._client_secret_path().exists(): await _reply( - "⚠️ No client credentials stored on the host. Send " + "⚠️ No client credentials stored for this profile. Send " "`/setup-files` (no args) for setup instructions." ) return True diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py index 3b11011066..3d481b3ead 100644 --- a/plugins/platforms/google_chat/oauth.py +++ b/plugins/platforms/google_chat/oauth.py @@ -50,10 +50,8 @@ Token storage layout ``${HERMES_HOME}/google_chat_user_oauth_pending/<sanitized_email>.json`` - Legacy pending state: ``${HERMES_HOME}/google_chat_user_oauth_pending.json`` -- Shared OAuth client (one per host, anchored at the default Hermes root so - every profile sees it; a profile-local copy under ``${HERMES_HOME}`` wins - when present): - ``<default-root>/google_chat_user_client_secret.json`` (default ``~/.hermes``) +- OAuth client secret (profile-scoped — each profile registers its own): + ``${HERMES_HOME}/google_chat_user_client_secret.json`` """ from __future__ import annotations @@ -77,11 +75,7 @@ logger = logging.getLogger("gateway.platforms.google_chat_user_oauth") # Use the project's HERMES_HOME helper so the token follows the user's # profile (e.g. tests can override via HERMES_HOME=/tmp/...). try: - from hermes_constants import ( - display_hermes_home, - get_default_hermes_root, - get_hermes_home, - ) + from hermes_constants import display_hermes_home, get_hermes_home except (ModuleNotFoundError, ImportError): # Fallback for environments where hermes_constants isn't importable # (mirrors the same fallback used by the google-workspace skill's @@ -90,24 +84,6 @@ except (ModuleNotFoundError, ImportError): val = os.environ.get("HERMES_HOME", "").strip() return Path(val) if val else Path.home() / ".hermes" - def get_default_hermes_root() -> Path: - # Mirror hermes_constants.get_default_hermes_root(): resolve the - # profile root so host-wide files (the shared client secret) are - # found regardless of which profile is active. - native_home = Path.home() / ".hermes" - env_home = os.environ.get("HERMES_HOME", "").strip() - if not env_home: - return native_home - env_path = Path(env_home) - try: - env_path.resolve().relative_to(native_home.resolve()) - return native_home - except ValueError: - pass - if env_path.parent.name == "profiles": - return env_path.parent.parent - return env_path - def display_hermes_home() -> str: home = get_hermes_home() try: @@ -164,24 +140,7 @@ def _token_path(email: Optional[str] = None) -> Path: def _client_secret_path() -> Path: - """Path to the shared OAuth client secret (one per host). - - The client secret identifies the OAuth *app*, not a user or a profile, - so it is anchored at the default Hermes root (``~/.hermes`` — or the - Docker root) rather than the active profile's ``HERMES_HOME``. That way - the one-time ``--client-secret`` host setup is visible to gateways - running under any named profile, exactly as the docs describe ("one - file per host is enough no matter how many users authorize later"). - - A profile-local secret (``$HERMES_HOME/google_chat_user_client_secret.json``) - still takes precedence when present, for installs that seeded one under - the previous profile-scoped behavior or that deliberately run a separate - OAuth app per profile. - """ - profile_local = _hermes_home() / "google_chat_user_client_secret.json" - if profile_local.exists(): - return profile_local - return get_default_hermes_root() / "google_chat_user_client_secret.json" + return _hermes_home() / "google_chat_user_client_secret.json" def _pending_auth_path(email: Optional[str] = None) -> Path: diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index 03ab232eb8..b759027850 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -16,7 +16,6 @@ import json import os import sys import types -from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -1651,48 +1650,6 @@ class TestUserOAuthHelper: }, ) - def test_client_secret_is_shared_across_profiles(self, tmp_path, monkeypatch): - """The OAuth client secret is host-wide infra: a secret seeded at the - default root by the documented one-time `--client-secret` host step - must be visible to a gateway running under a named profile. - - Regression: `_client_secret_path()` used to scope to the active - HERMES_HOME, so a profile gateway reported 'No client credentials - stored on the host' even after the host setup had been run. - """ - root = tmp_path / ".hermes" - profile_home = root / "profiles" / "bot1" - profile_home.mkdir(parents=True) - monkeypatch.setattr(Path, "home", lambda: tmp_path) - - # Seed the secret at the default root, as the host setup does. - secret = root / "google_chat_user_client_secret.json" - secret.write_text("{}", encoding="utf-8") - - # Resolve from inside a named profile. - monkeypatch.setenv("HERMES_HOME", str(profile_home)) - from plugins.platforms.google_chat.oauth import _client_secret_path - assert _client_secret_path() == secret - assert _client_secret_path().exists() - - def test_profile_local_client_secret_takes_precedence(self, tmp_path, monkeypatch): - """A profile-local secret (separate OAuth app per bot, or a legacy - profile-scoped seed) overrides the host-wide default when present.""" - root = tmp_path / ".hermes" - profile_home = root / "profiles" / "bot1" - profile_home.mkdir(parents=True) - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(profile_home)) - - (root / "google_chat_user_client_secret.json").write_text( - "{}", encoding="utf-8" - ) - profile_secret = profile_home / "google_chat_user_client_secret.json" - profile_secret.write_text("{}", encoding="utf-8") - - from plugins.platforms.google_chat.oauth import _client_secret_path - assert _client_secret_path() == profile_secret - def test_store_client_secret_writes_private_json(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) src = tmp_path / "client_secret.json" diff --git a/tests/hermes_cli/test_auth_profile_fallback.py b/tests/hermes_cli/test_auth_profile_fallback.py index d041b4efa2..5210404c40 100644 --- a/tests/hermes_cli/test_auth_profile_fallback.py +++ b/tests/hermes_cli/test_auth_profile_fallback.py @@ -17,18 +17,12 @@ from pathlib import Path import pytest -def _make_auth_store( - pool: dict | None = None, - providers: dict | None = None, - active_provider: str | None = None, -) -> dict: +def _make_auth_store(pool: dict | None = None, providers: dict | None = None) -> dict: store: dict = {"version": 1} if pool is not None: store["credential_pool"] = pool if providers is not None: store["providers"] = providers - if active_provider is not None: - store["active_provider"] = active_provider return store @@ -456,101 +450,3 @@ def test_write_credential_pool_targets_profile_not_global(profile_env): # Subsequent read returns profile (shadows global). assert [e["id"] for e in read_credential_pool("openrouter")] == ["prof-new"] - - -# --------------------------------------------------------------------------- -# get_active_provider — global active_provider fallback (issue #18594 follow-up) -# -# The per-provider state/pool fallbacks let a profile *read* a provider that -# was only authenticated at the global root, but ``resolve_provider()`` picks -# the ``auto`` provider from ``active_provider`` — which only ever read the -# profile store. A named profile running ``model.provider: auto`` could see -# the global Nous login (``get_provider_auth_state('nous')`` succeeds) yet -# still fail to select it. These pin the active_provider shadowing so the -# selection mirrors the state/pool fallbacks: profile wins when present, fall -# back to global when the profile never chose its own provider. -# --------------------------------------------------------------------------- - - -def test_active_provider_falls_back_to_global(profile_env): - """An empty profile inherits the global-root active_provider selection.""" - from hermes_cli.auth import get_active_provider - - _write(profile_env["global"] / "auth.json", _make_auth_store( - providers={"nous": {"access_token": "nous-global"}}, - active_provider="nous", - )) - _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) - - assert get_active_provider() == "nous" - - -def test_active_provider_profile_wins_over_global(profile_env): - """A profile that selected its own provider shadows the global selection.""" - from hermes_cli.auth import get_active_provider - - _write(profile_env["global"] / "auth.json", _make_auth_store( - providers={"nous": {"access_token": "nous-global"}}, - active_provider="nous", - )) - _write(profile_env["profile"] / "auth.json", _make_auth_store( - providers={"anthropic": {"access_token": "ant-profile"}}, - active_provider="anthropic", - )) - - assert get_active_provider() == "anthropic" - - -def test_active_provider_none_when_neither_has_it(profile_env): - """No selection anywhere stays None — the fallback must not invent one.""" - from hermes_cli.auth import get_active_provider - - _write(profile_env["global"] / "auth.json", _make_auth_store(providers={})) - _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) - - assert get_active_provider() is None - - -def test_active_provider_classic_mode_reads_profile(tmp_path, monkeypatch): - """In classic mode there is no global to fall back to; behavior is unchanged.""" - fake_home = tmp_path / "home" - fake_home.mkdir() - monkeypatch.setattr(Path, "home", lambda: fake_home) - hermes_home = tmp_path / "classic" - hermes_home.mkdir() - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - - _write(hermes_home / "auth.json", _make_auth_store( - providers={"nous": {"access_token": "classic-token"}}, - active_provider="nous", - )) - - from hermes_cli.auth import get_active_provider - - assert get_active_provider() == "nous" - - -def test_resolve_provider_uses_global_active_provider(profile_env, monkeypatch): - """resolve_provider('auto') honors the global-root active_provider. - - This is the user-visible contract: a named profile with no provider entry - of its own, started with ``model.provider: auto`` while a valid login - exists at the global root, resolves that provider instead of raising - ``No inference provider configured``. ``get_auth_status`` is stubbed so the - login check stays offline (no Nous token refresh / network). - """ - import hermes_cli.auth as auth - - _write(profile_env["global"] / "auth.json", _make_auth_store( - providers={"nous": {"access_token": "nous-global"}}, - active_provider="nous", - )) - _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) - - monkeypatch.setattr( - auth, - "get_auth_status", - lambda provider=None: {"logged_in": True, "provider": provider}, - ) - - assert auth.resolve_provider("auto") == "nous" diff --git a/website/docs/user-guide/messaging/google_chat.md b/website/docs/user-guide/messaging/google_chat.md index d34ebbd2e4..eeeb69c6c9 100644 --- a/website/docs/user-guide/messaging/google_chat.md +++ b/website/docs/user-guide/messaging/google_chat.md @@ -231,28 +231,29 @@ There's no IAM role or scope that fixes this. The endpoint only accepts user credentials. So the bot has to act *as a user* whenever it uploads a file — specifically, as the user who asked for the file. -### One-time host setup +### One-time setup (per profile) 1. Go to **APIs & Services → Credentials** in the same GCP project. 2. **Create credentials → OAuth client ID → Desktop app**. 3. Download the JSON. Move it onto the host that runs Hermes. -4. On the host, register the client with Hermes: +4. Register the client with Hermes (run under the profile you want it scoped to): ```bash +# Default profile: python -m plugins.platforms.google_chat.oauth \ --client-secret /path/to/client_secret.json + +# A named profile gets its own separate registration: +hermes -p <profile> python -m plugins.platforms.google_chat.oauth \ + --client-secret /path/to/client_secret.json ``` -That writes `~/.hermes/google_chat_user_client_secret.json`. This is shared -infrastructure — it identifies the OAuth *app*, not any individual user. One -file per host is enough no matter how many users authorize later. - -This file lives at the default Hermes root, so a gateway running under a named -profile (`hermes -p <name> gateway …`) finds the same host-wide secret — you do -**not** re-run this step per profile. To deliberately use a separate OAuth app -for one profile, drop a `google_chat_user_client_secret.json` inside that -profile's `HERMES_HOME` and it takes precedence. Per-user tokens always stay -scoped to the active profile. +That writes the client secret into the active profile's Hermes home (e.g. +`~/.hermes/google_chat_user_client_secret.json` for the default profile). The +client secret is **profile-scoped, not shared across profiles** — each profile +registers its own. This is deliberate: profiles are isolated auth boundaries, so +two profiles can point at different Google OAuth apps / accounts. Register it +once per profile that needs Google Chat attachment delivery. ### Per-user authorization (in chat) @@ -333,14 +334,20 @@ The asker has no per-user OAuth token and there's no legacy fallback. Run `/setup-files` in their DM and follow Step 10. After the exchange completes the next file request uploads natively without a gateway restart. -**`/setup-files start` says "No client credentials stored on the host."** +**`/setup-files start` says "No client credentials stored."** -The one-time host setup wasn't done. From a terminal on the host that runs -Hermes: +The one-time setup wasn't done *for this profile* (the client secret is +profile-scoped, so a registration under one profile won't be seen by another). +From a terminal, run it under the profile the gateway uses: ```bash +# Default profile: python -m plugins.platforms.google_chat.oauth \ --client-secret /path/to/client_secret.json + +# Named profile: +hermes -p <profile> python -m plugins.platforms.google_chat.oauth \ + --client-secret /path/to/client_secret.json ``` Then send `/setup-files start` again. From 2f0c8e90e6138dc986c2e533941e6e20e54536f5 Mon Sep 17 00:00:00 2001 From: Shannon Sands <shannon.sands.1979@gmail.com> Date: Wed, 3 Jun 2026 13:24:03 +1000 Subject: [PATCH 23/52] Add Telegram QR onboarding to dashboard --- hermes_cli/web_server.py | 337 +++++++++++++++++++- nix/lib.nix | 2 +- package-lock.json | 221 ++++++++++++- tests/hermes_cli/test_web_server.py | 152 ++++++++- web/package.json | 2 + web/src/lib/api.ts | 54 ++++ web/src/pages/ChannelsPage.tsx | 463 ++++++++++++++++++++++++---- 7 files changed, 1156 insertions(+), 75 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 233245de33..628edf6916 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -14,11 +14,14 @@ from contextlib import asynccontextmanager import asyncio import base64 import binascii +from dataclasses import dataclass +from datetime import datetime, timezone import hmac import importlib.util import json import logging import os +import re import secrets import stat import subprocess @@ -26,6 +29,7 @@ import sys import tempfile import threading import time +import urllib.error import urllib.parse import urllib.request from pathlib import Path @@ -557,6 +561,14 @@ class MessagingPlatformUpdate(BaseModel): clear_env: List[str] = [] +class TelegramOnboardingStart(BaseModel): + bot_name: Optional[str] = None + + +class TelegramOnboardingApply(BaseModel): + allowed_user_ids: List[str] + + class AudioTranscriptionRequest(BaseModel): data_url: str mime_type: Optional[str] = None @@ -3050,6 +3062,329 @@ def _write_platform_enabled(platform_id: str, enabled: bool) -> None: save_config(config) +_TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com" +_TELEGRAM_USER_ID_RE = re.compile(r"^\d+$") + + +@dataclass +class _TelegramOnboardingPairing: + poll_token: str + expires_at: str + expires_at_ts: float + bot_token: str | None = None + bot_username: str | None = None + owner_user_id: str | None = None + + +_telegram_onboarding_pairings: dict[str, _TelegramOnboardingPairing] = {} +_telegram_onboarding_lock = threading.RLock() + + +def _telegram_onboarding_base_url() -> str: + return ( + os.getenv("TELEGRAM_ONBOARDING_URL", _TELEGRAM_ONBOARDING_DEFAULT_URL) + .strip() + .rstrip("/") + ) + + +def _parse_expiry_ts(value: str) -> float: + try: + normalized = value.replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.timestamp() + except Exception: + return time.time() + 600 + + +def _prune_telegram_onboarding_pairings() -> None: + now = time.time() + expired = [ + pairing_id + for pairing_id, record in _telegram_onboarding_pairings.items() + if record.expires_at_ts <= now + ] + for pairing_id in expired: + _telegram_onboarding_pairings.pop(pairing_id, None) + + +def _normalize_telegram_user_id(value: Any) -> str | None: + normalized = str(value or "").strip() + if _TELEGRAM_USER_ID_RE.fullmatch(normalized): + return normalized + return None + + +def _telegram_onboarding_error_message(error: str, fallback: str) -> str: + return { + "not_found": "Telegram pairing was not found. Start a new setup.", + "expired": "Telegram setup expired. Start a new setup.", + "claimed": "Telegram setup was already claimed. Start a new setup.", + "unauthorized": "Telegram setup service rejected this request.", + "telegram_manager_bot_token_not_configured": "Telegram setup service is not configured.", + "telegram_token_fetch_failed": "Telegram could not finish bot setup. Try again.", + }.get(error, fallback) + + +def _telegram_onboarding_request_sync( + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + bearer_token: str | None = None, +) -> dict[str, Any]: + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + if bearer_token: + headers["Authorization"] = f"Bearer {bearer_token}" + + request = urllib.request.Request( + f"{_telegram_onboarding_base_url()}{path}", + data=data, + headers=headers, + method=method, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + payload = response.read() + except urllib.error.HTTPError as exc: + payload = exc.read() + try: + parsed = json.loads(payload.decode("utf-8")) + except Exception: + parsed = {} + error = str(parsed.get("error") or parsed.get("status") or "") + detail = _telegram_onboarding_error_message( + error, + "Telegram setup service returned an error.", + ) + status_code = 404 if exc.code == 404 else 502 + if error in {"expired", "claimed"}: + status_code = 410 + raise HTTPException(status_code=status_code, detail=detail) from exc + except Exception as exc: + raise HTTPException( + status_code=502, + detail="Telegram setup service is unavailable. Try again shortly.", + ) from exc + + try: + parsed = json.loads(payload.decode("utf-8")) + except Exception as exc: + raise HTTPException( + status_code=502, + detail="Telegram setup service returned an invalid response.", + ) from exc + if not isinstance(parsed, dict): + raise HTTPException( + status_code=502, + detail="Telegram setup service returned an invalid response.", + ) + return parsed + + +async def _telegram_onboarding_request( + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + bearer_token: str | None = None, +) -> dict[str, Any]: + return await asyncio.to_thread( + _telegram_onboarding_request_sync, + method, + path, + body=body, + bearer_token=bearer_token, + ) + + +@app.post("/api/messaging/telegram/onboarding/start") +async def start_telegram_onboarding(body: TelegramOnboardingStart): + bot_name = (body.bot_name or "Hermes Agent").strip() or "Hermes Agent" + payload = await _telegram_onboarding_request( + "POST", + "/v1/telegram/pairings", + body={"bot_name": bot_name}, + ) + + pairing_id = str(payload.get("pairing_id") or "").strip() + poll_token = str(payload.get("poll_token") or "").strip() + expires_at = str(payload.get("expires_at") or "").strip() + deep_link = str(payload.get("deep_link") or "").strip() + qr_payload = str(payload.get("qr_payload") or deep_link).strip() + suggested_username = str(payload.get("suggested_username") or "").strip() + if not pairing_id or not poll_token or not expires_at or not deep_link: + raise HTTPException( + status_code=502, + detail="Telegram setup service returned an incomplete response.", + ) + + with _telegram_onboarding_lock: + _prune_telegram_onboarding_pairings() + _telegram_onboarding_pairings[pairing_id] = _TelegramOnboardingPairing( + poll_token=poll_token, + expires_at=expires_at, + expires_at_ts=_parse_expiry_ts(expires_at), + ) + + return { + "pairing_id": pairing_id, + "suggested_username": suggested_username, + "deep_link": deep_link, + "qr_payload": qr_payload, + "expires_at": expires_at, + } + + +@app.get("/api/messaging/telegram/onboarding/{pairing_id}") +async def get_telegram_onboarding_status(pairing_id: str): + with _telegram_onboarding_lock: + _prune_telegram_onboarding_pairings() + record = _telegram_onboarding_pairings.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="Telegram setup session was not found. Start a new setup.", + ) + if record.bot_token: + return { + "status": "ready", + "bot_username": record.bot_username, + "owner_user_id": record.owner_user_id, + "expires_at": record.expires_at, + } + poll_token = record.poll_token + + payload = await _telegram_onboarding_request( + "GET", + f"/v1/telegram/pairings/{urllib.parse.quote(pairing_id, safe='')}", + bearer_token=poll_token, + ) + status = str(payload.get("status") or "").strip() + if status == "waiting": + with _telegram_onboarding_lock: + current = _telegram_onboarding_pairings.get(pairing_id) + expires_at = current.expires_at if current else "" + return {"status": "waiting", "expires_at": expires_at} + + if status == "ready": + bot_token = str(payload.get("token") or "").strip() + bot_username = str(payload.get("bot_username") or "").strip() + if not bot_token: + raise HTTPException( + status_code=502, + detail="Telegram setup service returned an incomplete response.", + ) + owner_user_id = _normalize_telegram_user_id(payload.get("owner_user_id")) + with _telegram_onboarding_lock: + record = _telegram_onboarding_pairings.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="Telegram setup session was not found. Start a new setup.", + ) + record.bot_token = bot_token + record.bot_username = bot_username or None + record.owner_user_id = owner_user_id + return { + "status": "ready", + "bot_username": record.bot_username, + "owner_user_id": record.owner_user_id, + "expires_at": record.expires_at, + } + + if status in {"expired", "claimed"}: + with _telegram_onboarding_lock: + _telegram_onboarding_pairings.pop(pairing_id, None) + raise HTTPException( + status_code=410, + detail=_telegram_onboarding_error_message( + status, + "Telegram setup is no longer available. Start a new setup.", + ), + ) + + raise HTTPException( + status_code=502, + detail="Telegram setup service returned an unknown status.", + ) + + +@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply") +async def apply_telegram_onboarding( + pairing_id: str, body: TelegramOnboardingApply +): + allowed_user_ids = [] + seen = set() + for raw_id in body.allowed_user_ids: + normalized = _normalize_telegram_user_id(raw_id) + if not normalized: + raise HTTPException( + status_code=400, + detail="Allowed Telegram user IDs must be numeric.", + ) + if normalized not in seen: + seen.add(normalized) + allowed_user_ids.append(normalized) + if not allowed_user_ids: + raise HTTPException( + status_code=400, + detail="Add at least one allowed Telegram user ID.", + ) + + with _telegram_onboarding_lock: + _prune_telegram_onboarding_pairings() + record = _telegram_onboarding_pairings.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="Telegram setup session was not found. Start a new setup.", + ) + bot_token = record.bot_token + bot_username = record.bot_username + if not bot_token: + raise HTTPException( + status_code=409, + detail="Telegram setup is not ready yet.", + ) + + try: + save_env_value("TELEGRAM_BOT_TOKEN", bot_token) + save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids)) + _write_platform_enabled("telegram", True) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + _log.exception("Telegram onboarding apply failed") + raise HTTPException( + status_code=500, + detail="Failed to save Telegram setup.", + ) from exc + + with _telegram_onboarding_lock: + _telegram_onboarding_pairings.pop(pairing_id, None) + + return { + "ok": True, + "platform": "telegram", + "bot_username": bot_username, + "needs_restart": True, + } + + +@app.delete("/api/messaging/telegram/onboarding/{pairing_id}") +async def cancel_telegram_onboarding(pairing_id: str): + with _telegram_onboarding_lock: + _telegram_onboarding_pairings.pop(pairing_id, None) + return {"ok": True} + + @app.get("/api/messaging/platforms") async def get_messaging_platforms(): env_on_disk = load_env() @@ -7078,8 +7413,6 @@ async def get_models_analytics(days: int = 30): # though uvicorn binds to 127.0.0.1. # --------------------------------------------------------------------------- -import re - # PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native # Windows the import raises; catch and leave PtyBridge=None so the rest of # the dashboard (sessions, jobs, metrics, config editor) still loads and the diff --git a/nix/lib.nix b/nix/lib.nix index b3aa020ace..9ef9b1acd1 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -21,7 +21,7 @@ let # Single npm deps fetch from the workspace root lockfile. # All workspace packages share this derivation. - npmDepsHash = "sha256-2CoB0uUc8Pf9iNR0I1EzVqgL89B5sADnC9sxGah8ndU="; + npmDepsHash = "sha256-T9UtpXgBCl/GywDZyrvG4a69RkV8oD6p1UOT7GPgAS0="; npmDeps = pkgs.fetchNpmDeps { inherit src; diff --git a/package-lock.json b/package-lock.json index 5b47e07c64..b8fbc0c132 100644 --- a/package-lock.json +++ b/package-lock.json @@ -143,6 +143,9 @@ "vite": "^8.0.10", "vitest": "^4.1.5", "wait-on": "^9.0.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "apps/desktop/node_modules/@nous-research/ui": { @@ -8353,6 +8356,16 @@ "xmlbuilder": ">=11.0.1" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -8985,7 +8998,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8995,7 +9007,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -9791,6 +9802,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001787", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", @@ -10061,7 +10081,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -10074,7 +10093,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colord": { @@ -10921,6 +10939,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -11083,6 +11110,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -11542,7 +11575,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/end-of-stream": { @@ -12704,7 +12736,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -14133,7 +14164,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16823,6 +16853,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -16905,7 +16944,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17020,6 +17058,15 @@ "node": ">=8.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -17262,6 +17309,141 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -17985,7 +18167,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18000,6 +18181,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -18485,6 +18672,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -18915,7 +19108,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -19042,7 +19234,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -20930,6 +21121,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -21972,6 +22169,7 @@ "leva": "^0.10.1", "lucide-react": "^0.577.0", "motion": "^12.38.0", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.14.1", @@ -21982,6 +22180,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^24.12.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 592d62c44f..6353ebc9e0 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -968,6 +968,157 @@ class TestWebServerEndpoints: assert data["state"] == "not_configured" assert "DISCORD_BOT_TOKEN" in data["message"] + def test_telegram_onboarding_start_strips_poll_token(self, monkeypatch): + import hermes_cli.web_server as ws + + with ws._telegram_onboarding_lock: + ws._telegram_onboarding_pairings.clear() + + calls = [] + + def fake_request(method, path, *, body=None, bearer_token=None): + calls.append((method, path, body, bearer_token)) + return { + "pairing_id": "pair123", + "poll_token": "poll-secret", + "suggested_username": "hermes_pair123_bot", + "deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair123_bot", + "qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair123_bot", + "expires_at": "2027-05-18T00:00:00.000Z", + } + + monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request) + + resp = self.client.post( + "/api/messaging/telegram/onboarding/start", + json={"bot_name": "Hosted Hermes"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["pairing_id"] == "pair123" + assert "poll_token" not in data + assert calls == [ + ( + "POST", + "/v1/telegram/pairings", + {"bot_name": "Hosted Hermes"}, + None, + ) + ] + + def test_telegram_onboarding_ready_and_apply_never_returns_bot_token(self, monkeypatch): + import hermes_cli.web_server as ws + from hermes_cli.config import load_config, load_env + + with ws._telegram_onboarding_lock: + ws._telegram_onboarding_pairings.clear() + + def fake_request(method, path, *, body=None, bearer_token=None): + if method == "POST": + return { + "pairing_id": "pair-ready", + "poll_token": "poll-secret", + "suggested_username": "hermes_pair_ready_bot", + "deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_ready_bot", + "qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_ready_bot", + "expires_at": "2027-05-18T00:00:00.000Z", + } + assert method == "GET" + assert path == "/v1/telegram/pairings/pair-ready" + assert bearer_token == "poll-secret" + return { + "status": "ready", + "bot_username": "hermes_pair_ready_bot", + "owner_user_id": 123456789, + "token": "123456:SECRET", + } + + monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request) + + start = self.client.post("/api/messaging/telegram/onboarding/start", json={}) + assert start.status_code == 200 + + ready = self.client.get("/api/messaging/telegram/onboarding/pair-ready") + assert ready.status_code == 200 + ready_data = ready.json() + assert ready_data["status"] == "ready" + assert ready_data["owner_user_id"] == "123456789" + assert "token" not in ready_data + + applied = self.client.post( + "/api/messaging/telegram/onboarding/pair-ready/apply", + json={"allowed_user_ids": ["123456789", "123456789"]}, + ) + assert applied.status_code == 200 + applied_data = applied.json() + assert applied_data == { + "ok": True, + "platform": "telegram", + "bot_username": "hermes_pair_ready_bot", + "needs_restart": True, + } + env = load_env() + assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET" + assert env["TELEGRAM_ALLOWED_USERS"] == "123456789" + assert load_config()["platforms"]["telegram"]["enabled"] is True + + def test_telegram_onboarding_apply_requires_ready_pairing(self, monkeypatch): + import hermes_cli.web_server as ws + + with ws._telegram_onboarding_lock: + ws._telegram_onboarding_pairings.clear() + + def fake_request(method, path, *, body=None, bearer_token=None): + return { + "pairing_id": "pair-waiting", + "poll_token": "poll-secret", + "suggested_username": "hermes_pair_waiting_bot", + "deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_waiting_bot", + "qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_waiting_bot", + "expires_at": "2027-05-18T00:00:00.000Z", + } + + monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request) + + start = self.client.post("/api/messaging/telegram/onboarding/start", json={}) + assert start.status_code == 200 + + resp = self.client.post( + "/api/messaging/telegram/onboarding/pair-waiting/apply", + json={"allowed_user_ids": ["123456789"]}, + ) + + assert resp.status_code == 409 + assert "not ready" in resp.json()["detail"] + + def test_telegram_onboarding_cancel_clears_local_session(self, monkeypatch): + import hermes_cli.web_server as ws + + with ws._telegram_onboarding_lock: + ws._telegram_onboarding_pairings.clear() + + def fake_request(method, path, *, body=None, bearer_token=None): + return { + "pairing_id": "pair-cancel", + "poll_token": "poll-secret", + "suggested_username": "hermes_pair_cancel_bot", + "deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_cancel_bot", + "qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_cancel_bot", + "expires_at": "2027-05-18T00:00:00.000Z", + } + + monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request) + + start = self.client.post("/api/messaging/telegram/onboarding/start", json={}) + assert start.status_code == 200 + + cancel = self.client.delete("/api/messaging/telegram/onboarding/pair-cancel") + assert cancel.status_code == 200 + + status = self.client.get("/api/messaging/telegram/onboarding/pair-cancel") + assert status.status_code == 404 + def test_session_token_endpoint_removed(self): """GET /api/auth/session-token should no longer exist (token injected via HTML).""" resp = self.client.get("/api/auth/session-token") @@ -3985,4 +4136,3 @@ class TestValidateProviderCredential: def test_empty_value_rejected(self): data = self._post("OPENAI_API_KEY", " ").json() assert data["ok"] is False - diff --git a/web/package.json b/web/package.json index 7615a0976a..72f6dc4f8e 100644 --- a/web/package.json +++ b/web/package.json @@ -25,6 +25,7 @@ "leva": "^0.10.1", "lucide-react": "^0.577.0", "motion": "^12.38.0", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.14.1", @@ -35,6 +36,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^24.12.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2f59f095e1..2ee4c83354 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -589,6 +589,36 @@ export const api = { `/api/messaging/platforms/${encodeURIComponent(id)}/test`, { method: "POST" }, ), + startTelegramOnboarding: (body: { bot_name?: string }) => + fetchJSON<TelegramOnboardingStartResponse>( + "/api/messaging/telegram/onboarding/start", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + getTelegramOnboardingStatus: (pairingId: string) => + fetchJSON<TelegramOnboardingStatusResponse>( + `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}`, + ), + applyTelegramOnboarding: ( + pairingId: string, + body: { allowed_user_ids: string[] }, + ) => + fetchJSON<TelegramOnboardingApplyResponse>( + `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}/apply`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + cancelTelegramOnboarding: (pairingId: string) => + fetchJSON<{ ok: boolean }>( + `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}`, + { method: "DELETE" }, + ), // Gateway / update actions restartGateway: () => @@ -1293,6 +1323,30 @@ export interface EnvVarInfo { channel_managed?: boolean; } +export interface TelegramOnboardingStartResponse { + pairing_id: string; + suggested_username: string; + deep_link: string; + qr_payload: string; + expires_at: string; +} + +export type TelegramOnboardingStatusResponse = + | { status: "waiting"; expires_at: string } + | { + status: "ready"; + bot_username: string; + owner_user_id?: string; + expires_at: string; + }; + +export interface TelegramOnboardingApplyResponse { + ok: boolean; + platform: "telegram"; + bot_username?: string; + needs_restart: true; +} + export interface SessionMessage { role: "user" | "assistant" | "system" | "tool"; content: string | null; diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index 4320d5f86a..98c9b7a77c 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -1,15 +1,19 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; import { AlertTriangle, + Check, CheckCircle2, ExternalLink, PlugZap, + QrCode, Radio, RotateCw, + Save, Settings2, WifiOff, X, } from "lucide-react"; +import * as QRCode from "qrcode"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; import { Card, CardContent } from "@nous-research/ui/ui/components/card"; @@ -24,6 +28,7 @@ import type { MessagingPlatform, MessagingPlatformEnvVar, MessagingPlatformUpdate, + TelegramOnboardingStartResponse, } from "@/lib/api"; import { useModalBehavior } from "@/hooks/useModalBehavior"; import { usePageHeader } from "@/contexts/usePageHeader"; @@ -48,6 +53,22 @@ function stateBadge(state: string) { return STATE_BADGE[state] ?? { tone: "outline" as const, label: state }; } +const TELEGRAM_USER_ID_RE = /^\d+$/; + +function formatExpiry(expiresAt: string): string { + const ms = Date.parse(expiresAt) - Date.now(); + if (!Number.isFinite(ms) || ms <= 0) return "expired"; + const seconds = Math.ceil(ms / 1000); + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + return `${minutes}:${rest.toString().padStart(2, "0")}`; +} + +function isTerminalTelegramOnboardingError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /\b410\b/.test(message) && /\b(expired|claimed|gone)\b/i.test(message); +} + export default function ChannelsPage() { const [platforms, setPlatforms] = useState<MessagingPlatform[]>([]); const [loading, setLoading] = useState(true); @@ -353,72 +374,83 @@ export default function ChannelsPage() { : Radio; return ( <Card key={platform.id} className="border-border"> - <CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"> - <div className="flex items-start gap-3 min-w-0"> - <StateIcon - className={cn( - "h-5 w-5 shrink-0 mt-0.5", - platform.state === "connected" - ? "text-success" - : platform.state === "fatal" - ? "text-destructive" - : "text-muted-foreground", - )} - /> - <div className="flex flex-col gap-0.5 min-w-0"> - <div className="flex items-center gap-2 flex-wrap"> - <span className="font-mondwest normal-case text-sm font-medium"> - {platform.name} + <CardContent className="flex flex-col gap-4 p-4"> + <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex items-start gap-3 min-w-0"> + <StateIcon + className={cn( + "h-5 w-5 shrink-0 mt-0.5", + platform.state === "connected" + ? "text-success" + : platform.state === "fatal" + ? "text-destructive" + : "text-muted-foreground", + )} + /> + <div className="flex flex-col gap-0.5 min-w-0"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="font-mondwest normal-case text-sm font-medium"> + {platform.name} + </span> + <Badge tone={badge.tone}>{badge.label}</Badge> + </div> + <span className="text-xs text-muted-foreground"> + {platform.description} </span> - <Badge tone={badge.tone}>{badge.label}</Badge> + {platform.error_message && ( + <span className="text-xs text-destructive"> + {platform.error_message} + </span> + )} </div> - <span className="text-xs text-muted-foreground"> - {platform.description} - </span> - {platform.error_message && ( - <span className="text-xs text-destructive"> - {platform.error_message} - </span> - )} </div> - </div> - <div className="flex items-center gap-2 shrink-0 self-start sm:self-center"> - <div className="flex items-center gap-1.5"> - {busy ? ( - <Spinner className="text-sm" /> - ) : ( - <Switch - checked={platform.enabled} - onCheckedChange={() => void handleToggle(platform)} - aria-label={`Enable ${platform.name}`} - /> - )} - </div> - <Button - ghost - size="sm" - onClick={() => handleTest(platform)} - disabled={testingId === platform.id} - prefix={ - testingId === platform.id ? ( - <Spinner /> + <div className="flex items-center gap-2 shrink-0 self-start sm:self-center"> + <div className="flex items-center gap-1.5"> + {busy ? ( + <Spinner className="text-sm" /> ) : ( - <PlugZap className="h-4 w-4" /> - ) - } - > - Test - </Button> - <Button - size="sm" - className="uppercase" - onClick={() => openConfig(platform)} - prefix={<Settings2 className="h-4 w-4" />} - > - Configure - </Button> + <Switch + checked={platform.enabled} + onCheckedChange={() => void handleToggle(platform)} + aria-label={`Enable ${platform.name}`} + /> + )} + </div> + <Button + ghost + size="sm" + onClick={() => handleTest(platform)} + disabled={testingId === platform.id} + prefix={ + testingId === platform.id ? ( + <Spinner /> + ) : ( + <PlugZap className="h-4 w-4" /> + ) + } + > + Test + </Button> + <Button + size="sm" + className="uppercase" + onClick={() => openConfig(platform)} + prefix={<Settings2 className="h-4 w-4" />} + > + Configure + </Button> + </div> </div> + {platform.id === "telegram" && ( + <TelegramOnboardingPanel + onChanged={load} + onRestartNeeded={() => setRestartNeeded(true)} + platform={platform} + setRestartNeeded={setRestartNeeded} + showToast={showToast} + /> + )} </CardContent> </Card> ); @@ -427,3 +459,314 @@ export default function ChannelsPage() { </div> ); } + +function TelegramOnboardingPanel({ + onChanged, + onRestartNeeded, + platform, + setRestartNeeded, + showToast, +}: { + onChanged: () => Promise<void>; + onRestartNeeded: () => void; + platform: MessagingPlatform; + setRestartNeeded: (needed: boolean) => void; + showToast: (message: string, type: "success" | "error") => void; +}) { + const [setup, setSetup] = useState<TelegramOnboardingStartResponse | null>( + null, + ); + const [qrDataUrl, setQrDataUrl] = useState(""); + const [phase, setPhase] = useState< + "idle" | "starting" | "waiting" | "ready" | "applying" + >("idle"); + const [botUsername, setBotUsername] = useState<string | null>(null); + const [allowedIds, setAllowedIds] = useState<string[]>([]); + const [detectedOwnerId, setDetectedOwnerId] = useState<string | null>(null); + const [newAllowedId, setNewAllowedId] = useState(""); + const [error, setError] = useState(""); + const [tick, setTick] = useState(0); + + useEffect(() => { + if (!setup || phase !== "waiting") return; + let cancelled = false; + let timeout: ReturnType<typeof setTimeout> | null = null; + + const poll = async () => { + try { + const status = await api.getTelegramOnboardingStatus(setup.pairing_id); + if (cancelled) return; + if (status.status === "ready") { + setPhase("ready"); + setBotUsername(status.bot_username ?? null); + setError(""); + if ( + status.owner_user_id && + TELEGRAM_USER_ID_RE.test(status.owner_user_id) + ) { + setDetectedOwnerId(status.owner_user_id); + setAllowedIds([status.owner_user_id]); + } + return; + } + setError(""); + timeout = setTimeout(poll, 2000); + } catch (pollError) { + if (cancelled) return; + + const expiresAt = Date.parse(setup.expires_at); + const expired = + Number.isFinite(expiresAt) && Date.now() >= expiresAt; + if (isTerminalTelegramOnboardingError(pollError) || expired) { + setSetup(null); + setQrDataUrl(""); + setPhase("idle"); + setError("Telegram pairing expired. Start a new QR setup to try again."); + return; + } + + setError(`Still waiting for Telegram. Retrying after: ${pollError}`); + timeout = setTimeout(poll, 2000); + } + }; + + timeout = setTimeout(poll, 1200); + return () => { + cancelled = true; + if (timeout) clearTimeout(timeout); + }; + }, [phase, setup]); + + useEffect(() => { + if (!setup) return; + const timer = setInterval(() => setTick((value) => value + 1), 1000); + return () => clearInterval(timer); + }, [setup]); + + const resetSetup = () => { + setSetup(null); + setQrDataUrl(""); + setPhase("idle"); + setBotUsername(null); + setAllowedIds([]); + setDetectedOwnerId(null); + setNewAllowedId(""); + setError(""); + }; + + const start = async () => { + setPhase("starting"); + setError(""); + setBotUsername(null); + setAllowedIds([]); + setDetectedOwnerId(null); + setNewAllowedId(""); + try { + const res = await api.startTelegramOnboarding({ bot_name: "Hermes Agent" }); + const dataUrl = await QRCode.toDataURL(res.qr_payload, { + errorCorrectionLevel: "M", + margin: 1, + width: 224, + }); + setSetup(res); + setQrDataUrl(dataUrl); + setPhase("waiting"); + } catch (startError) { + setPhase("idle"); + setError(String(startError)); + } + }; + + const cancel = async () => { + if (setup) { + try { + await api.cancelTelegramOnboarding(setup.pairing_id); + } catch { + /* local cleanup still wins */ + } + } + resetSetup(); + }; + + const addAllowedId = () => { + const trimmed = newAllowedId.trim(); + if (!TELEGRAM_USER_ID_RE.test(trimmed)) { + setError("Allowed Telegram user IDs must be numeric."); + return; + } + setError(""); + setAllowedIds((ids) => (ids.includes(trimmed) ? ids : [...ids, trimmed])); + setNewAllowedId(""); + }; + + const apply = async () => { + if (!setup) return; + if (allowedIds.length === 0) { + setError("Add at least one allowed Telegram user ID."); + return; + } + setPhase("applying"); + setError(""); + try { + await api.applyTelegramOnboarding(setup.pairing_id, { + allowed_user_ids: allowedIds, + }); + resetSetup(); + showToast("Telegram saved", "success"); + try { + await api.restartGateway(); + showToast("Gateway restarting…", "success"); + setRestartNeeded(false); + setTimeout(() => void onChanged(), 4000); + } catch (restartError) { + onRestartNeeded(); + showToast(`Telegram saved; restart failed: ${restartError}`, "error"); + } + await onChanged(); + } catch (applyError) { + setPhase("ready"); + setError(String(applyError)); + } + }; + + const expiresIn = useMemo( + () => (setup ? formatExpiry(setup.expires_at) : ""), + // tick keeps the memo fresh without recalculating on every render branch. + // eslint-disable-next-line react-hooks/exhaustive-deps + [setup, tick], + ); + + return ( + <div className="rounded-sm border border-border bg-background/35 p-4"> + <div className="flex flex-wrap items-center gap-2"> + <Button + size="sm" + className="uppercase" + onClick={() => void start()} + disabled={phase === "starting" || phase === "waiting" || phase === "applying"} + prefix={phase === "starting" ? <Spinner /> : <QrCode className="h-4 w-4" />} + > + {phase === "starting" ? "Starting…" : "Set up with QR"} + </Button> + {platform.configured && ( + <span className="text-xs text-muted-foreground"> + Existing Telegram credentials are configured. + </span> + )} + </div> + + {error && ( + <div className="mt-3 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"> + {error} + </div> + )} + + {setup && qrDataUrl && ( + <div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_260px]"> + <div className="grid gap-3"> + {(phase === "ready" || phase === "applying") && ( + <div className="grid gap-3"> + <div className="flex flex-wrap items-center gap-2"> + <Badge tone="success">Ready</Badge> + {botUsername && ( + <span className="font-courier text-sm text-muted-foreground"> + @{botUsername} + </span> + )} + </div> + + <div className="grid gap-2"> + <div className="flex flex-wrap items-center gap-2"> + <span className="text-xs uppercase tracking-[0.12em] text-muted-foreground"> + Allowed users + </span> + {detectedOwnerId && allowedIds.includes(detectedOwnerId) && ( + <Badge tone="success">owner detected</Badge> + )} + </div> + <div className="flex flex-wrap gap-2"> + {allowedIds.map((id) => ( + <button + key={id} + type="button" + className="inline-flex items-center gap-1 border border-border px-2 py-1 font-courier text-xs text-foreground hover:border-destructive/50" + onClick={() => + setAllowedIds((ids) => + ids.filter((existing) => existing !== id), + ) + } + > + {id} + <X className="h-3 w-3" /> + </button> + ))} + {allowedIds.length === 0 && ( + <span className="text-sm text-muted-foreground"> + Add at least one Telegram user ID. + </span> + )} + </div> + </div> + + <div className="flex flex-col gap-2 sm:flex-row"> + <Input + value={newAllowedId} + onChange={(event) => setNewAllowedId(event.target.value)} + placeholder="Telegram user ID" + className="font-courier" + /> + <Button size="sm" outlined onClick={addAllowedId} prefix={<Check />}> + Add + </Button> + </div> + + <div className="flex flex-wrap gap-2"> + <Button + size="sm" + className="uppercase" + onClick={() => void apply()} + disabled={phase === "applying"} + prefix={phase === "applying" ? <Spinner /> : <Save className="h-4 w-4" />} + > + {phase === "applying" ? "Saving…" : "Save and restart"} + </Button> + <Button size="sm" ghost onClick={() => void cancel()}> + Cancel + </Button> + </div> + </div> + )} + </div> + + <div className="flex flex-col items-center justify-center gap-3"> + <img + src={qrDataUrl} + alt="Telegram setup QR code" + className="h-56 w-56 bg-white p-2" + /> + <div className="flex flex-wrap items-center justify-center gap-2 text-sm"> + <Badge tone={expiresIn === "expired" ? "destructive" : "outline"}> + {expiresIn} + </Badge> + {phase === "waiting" && <Badge tone="warning">waiting</Badge>} + </div> + <div className="flex flex-wrap justify-center gap-2"> + <a + href={setup.deep_link} + target="_blank" + rel="noreferrer" + className="inline-flex h-8 items-center gap-1 border border-border px-3 text-xs uppercase text-foreground hover:border-foreground/40" + > + <ExternalLink className="h-4 w-4" /> + Open Telegram + </a> + <Button size="sm" ghost onClick={() => void cancel()}> + Cancel + </Button> + </div> + </div> + </div> + )} + </div> + ); +} From e7a7872a874837ca36105ca3e90db464cf7c125a Mon Sep 17 00:00:00 2001 From: flooryyyy <67979730+flooryyyy@users.noreply.github.com> Date: Wed, 27 May 2026 17:48:49 +0100 Subject: [PATCH 24/52] fix(tui_gateway): dedup re-queued process notifications flooding TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _ notification_poller_loop_ re-emits status.update every cycle when a background process completes while the session is busy. The same completion event gets re-queued and re-emitted to the TUI every few ms, flooding the transcript with duplicate lines. Add _notification_event_dedup_key(evt) that returns a tuple identity for each notification event. Only emit status.update on first sight per identity: - completions: (sid, type) — one-shot per process session - watch_match: (sid, type, command, pattern, output, ...) - watch_overflow/disabled: (sid, type, command, message, ...) The dedup key design was refined from an initial sid:type approach after @lordbuffcloud identified that distinct watch_match events (READY vs DONE) for the same process would be incorrectly collapsed. Tests from @tymrtn cover distinct watch matches, exact replay dedup, and completion one-shot behavior. Co-authored-by: tymrtn <ty@tmrtn.com> --- tests/test_tui_gateway_server.py | 94 ++++++++++++++++++++++++++++++++ tui_gateway/server.py | 47 +++++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ef94dc27a0..7899a6de4b 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -5474,6 +5474,8 @@ def test_notification_poller_requeues_when_busy(monkeypatch): assert requeued["session_id"] == "proc_busy_test" finally: server._sessions.pop("sid_busy", None) + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, tmp_path): @@ -5533,3 +5535,95 @@ def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, t assert payload["session_start"] == "2026-01-01T12:00:00" assert payload["system_prompt"] == "You are Hermes." assert payload["messages"] == history + + +def test_notification_event_dedup_key_preserves_distinct_watch_matches(): + """Watch-match identity includes match content, not just session/type.""" + base = { + "type": "watch_match", + "session_id": "proc_watch", + "command": "tail -f app.log", + "pattern": "READY", + "output": "READY on port 8000", + "suppressed": 0, + } + + identical = dict(base) + distinct_output = {**base, "output": "READY on port 9000"} + distinct_pattern = {**base, "pattern": "MIGRATION_DONE"} + + base_key = server._notification_event_dedup_key(base) + assert server._notification_event_dedup_key(identical) == base_key + assert server._notification_event_dedup_key(distinct_output) != base_key + assert server._notification_event_dedup_key(distinct_pattern) != base_key + + +def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch): + """Distinct watch matches from one process emit; exact replay is deduped.""" + from tools.process_registry import process_registry + + turns = [] + emitted = [] + + def _fake_run_prompt_submit(rid, sid, session, text): + turns.append(text) + with session["history_lock"]: + session["running"] = False + + sess = _session() + server._sessions["sid_watch_dedup"] = sess + monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a)) + monkeypatch.setattr(server, "_run_prompt_submit", _fake_run_prompt_submit) + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + base = { + "type": "watch_match", + "session_id": "proc_watch_dedup", + "command": "tail -f app.log", + "pattern": "READY", + "output": "READY on port 8000", + "suppressed": 0, + } + process_registry.completion_queue.put(base) + process_registry.completion_queue.put({**base, "output": "READY on port 9000"}) + process_registry.completion_queue.put(dict(base)) + + stop = threading.Event() + stop.set() + + try: + server._notification_poller_loop(stop, "sid_watch_dedup", sess) + status_calls = [a for a in emitted if a[0] == "status.update"] + assert len(status_calls) == 2 + status_text = "\n".join(call[2]["text"] for call in status_calls) + assert "READY on port 8000" in status_text + assert "READY on port 9000" in status_text + assert len(turns) == 3 + finally: + server._sessions.pop("sid_watch_dedup", None) + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def test_notification_event_dedup_key_keeps_completions_one_shot(): + """Completion identity remains process-session scoped to avoid floods.""" + first = { + "type": "completion", + "session_id": "proc_done", + "command": "make build", + "exit_code": 0, + "output": "first output", + } + replay = { + "type": "completion", + "session_id": "proc_done", + "command": "make build --again", + "exit_code": 1, + "output": "different output should not change completion key", + } + + assert server._notification_event_dedup_key(first) == server._notification_event_dedup_key( + replay + ) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ace784135f..61822b6da4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4109,6 +4109,38 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool: ) +def _notification_event_dedup_key(evt: dict) -> tuple: + """Return the UI-emission identity for a process notification event. + + Completion events are terminal notifications for a background process, so + they remain one-shot per process session. Watch-match events are not + terminal: a single background process can legitimately match the same or + different patterns many times, so include event-specific content to avoid + suppressing later distinct matches from the same process. + """ + evt_type = evt.get("type", "completion") + evt_sid = evt.get("session_id", "") + if evt_type == "watch_match": + return ( + evt_sid, + evt_type, + evt.get("command", ""), + evt.get("pattern", ""), + evt.get("output", ""), + evt.get("suppressed", 0), + evt.get("message_id", ""), + ) + if evt_type.startswith("watch_overflow_") or evt_type == "watch_disabled": + return ( + evt_sid, + evt_type, + evt.get("command", ""), + evt.get("message", ""), + evt.get("suppressed", 0), + ) + return (evt_sid, evt_type) + + def _notification_poller_loop( stop_event: threading.Event, sid: str, session: dict ) -> None: @@ -4125,6 +4157,7 @@ def _notification_poller_loop( """ from tools.process_registry import process_registry, format_process_notification + _emitted = set() # dedup re-queued events so same completion isn't emitted 50 times while session is busy while not stop_event.is_set() and not session.get("_finalized"): try: evt = process_registry.completion_queue.get(timeout=0.5) @@ -4149,7 +4182,14 @@ def _notification_poller_loop( if not text: continue - _emit("status.update", sid, {"kind": "process", "text": text}) + # Only emit the same notification identity to TUI once — re-queued + # completions get re-emitted every 0.5s otherwise when session is busy, + # while distinct watch_match events from the same process must remain + # visible independently. + _dedup_key = _notification_event_dedup_key(evt) + if _dedup_key not in _emitted: + _emit("status.update", sid, {"kind": "process", "text": text}) + _emitted.add(_dedup_key) with session["history_lock"]: if session.get("running"): @@ -4189,7 +4229,10 @@ def _notification_poller_loop( if not text: continue - _emit("status.update", sid, {"kind": "process", "text": text}) + _dedup_key = _notification_event_dedup_key(evt) + if _dedup_key not in _emitted: + _emit("status.update", sid, {"kind": "process", "text": text}) + _emitted.add(_dedup_key) with session["history_lock"]: if session.get("running"): From 454d6cbe5250964e1e98cf44ae7d06d71a895b33 Mon Sep 17 00:00:00 2001 From: Ali Zakaee <ali.zakaee.1997@gmail.com> Date: Thu, 4 Jun 2026 06:03:05 -0700 Subject: [PATCH 25/52] fix(telegram): finalize sealed overflow chunk so split streamed replies render formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing-message overflow split path in stream_consumer.run() sealed the first chunk via _send_or_edit(chunk) (finalize=False) then reset _message_id to None — so that chunk was never edited again and never received the adapter's final rich-text pass. On Telegram, MarkdownV2 formatting is applied on the finalize edit, so early split messages of a long multi-part streamed reply rendered raw markdown (##, **bold**, code fences) while only the last chunk rendered correctly. Fix: seal the overflow chunk with finalize=True so it gets its final formatting pass before _message_id is cleared. Salvaged from #32609 (the streaming-format portion only; the PR's send_draft parse_mode change is already superseded on main, and its media-roots change conflicts with the current denylist + recency-window delivery model). --- gateway/stream_consumer.py | 14 +++++++++++++- scripts/release.py | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 16d1cecd31..33910c7b40 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -530,7 +530,19 @@ class GatewayStreamConsumer: if split_at < _safe_limit // 2: split_at = _safe_limit chunk = self._accumulated[:split_at] - ok = await self._send_or_edit(chunk) + # finalize=True so the adapter applies platform-specific + # rich-text markup (e.g. Telegram MarkdownV2). This + # sealed chunk will never be edited again — _message_id + # is reset to None right below — so it must receive its + # final formatting pass now, or early split messages + # render raw markdown while only the last chunk renders. + # is_turn_final=False: this is the first of several split + # messages, NOT the turn-final answer, so the fresh-final + # path (opt-in fresh_final_after_seconds) must not mark + # the turn delivered on it (#29346 semantics). + ok = await self._send_or_edit( + chunk, finalize=True, is_turn_final=False, + ) if self._fallback_final_send or not ok: # Edit failed (or backed off due to flood control) # while attempting to split an oversized message. diff --git a/scripts/release.py b/scripts/release.py index 6fa874afd6..adf20ceaac 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -48,6 +48,7 @@ AUTHOR_MAP = { "zhaolei.vc@bytedance.com": "zhaoleibd", "jeffrobodie@gmail.com": "jeffrobodie-glitch", "kyssta-exe@users.noreply.github.com": "kyssta-exe", + "ali.zakaee.1997@gmail.com": "ITheEqualizer", "copii.list@gmail.com": "stremtec", "solaiagent@gmail.com": "solaitken", "cryptoworlldz@gmail.com": "worlldz", From 751b91446e2f3df02952c26a8c279b4ca34474fb Mon Sep 17 00:00:00 2001 From: annguyenNous <annguyenNous@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:24:25 +0700 Subject: [PATCH 26/52] fix(mcp): ensure server.shutdown() on probe iteration failure Wrap the _tools iteration in _probe_single_server() in try/finally so that server.shutdown() is called even if iterating tool metadata raises. Without this, the MCP server connection leaks until the event loop is torn down by _stop_mcp_loop(). --- hermes_cli/mcp_config.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 377c6b20b0..bb8f894875 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -225,13 +225,15 @@ def _probe_single_server( server = await asyncio.wait_for( _connect_server(name, config), timeout=connect_timeout ) - for t in server._tools: - desc = getattr(t, "description", "") or "" - # Truncate long descriptions for display - if len(desc) > 80: - desc = desc[:77] + "..." - tools_found.append((t.name, desc)) - await server.shutdown() + try: + for t in server._tools: + desc = getattr(t, "description", "") or "" + # Truncate long descriptions for display + if len(desc) > 80: + desc = desc[:77] + "..." + tools_found.append((t.name, desc)) + finally: + await server.shutdown() try: _run_on_mcp_loop(_probe(), timeout=connect_timeout + 10) From 4690bbc363e952d29baf31f838b7502c905f8812 Mon Sep 17 00:00:00 2001 From: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:18:10 +1000 Subject: [PATCH 27/52] fix(local): recognize unqualified hostnames as local endpoints (#9248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker Compose service names (e.g. ollama, litellm, hermes-litellm) are unqualified hostnames with no dots. These are always local — they resolve via Docker DNS, /etc/hosts, or mDNS. Without this fix, the stale stream timeout fires on local LLM proxies, causing infinite reconnect loops. Closes #7905 --- agent/model_metadata.py | 4 ++++ tests/agent/test_local_stream_timeout.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 724457fd89..0ce9d0c636 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -441,6 +441,10 @@ def is_local_endpoint(base_url: str) -> bool: # Docker / Podman / Lima internal DNS names (e.g. host.docker.internal) if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES): return True + # Unqualified hostnames (no dots) are local by definition — Docker + # Compose service names, /etc/hosts entries, or mDNS names. + if host and "." not in host: + return True # RFC-1918 private ranges, link-local, and Tailscale CGNAT try: addr = ipaddress.ip_address(host) diff --git a/tests/agent/test_local_stream_timeout.py b/tests/agent/test_local_stream_timeout.py index 0252633f38..91ca7f404c 100644 --- a/tests/agent/test_local_stream_timeout.py +++ b/tests/agent/test_local_stream_timeout.py @@ -98,6 +98,16 @@ class TestIsLocalEndpoint: def test_container_dns_names(self, url): assert is_local_endpoint(url) is True + @pytest.mark.parametrize("url", [ + "http://ollama:11434", + "http://litellm:4000/v1", + "http://hermes-litellm:8080", + "http://vllm:8000", + ]) + def test_unqualified_docker_hostnames(self, url): + """Unqualified hostnames (no dots) are local — Docker Compose, /etc/hosts, etc.""" + assert is_local_endpoint(url) is True + @pytest.mark.parametrize("url", [ "https://api.openai.com", "https://openrouter.ai/api", From 82c157b267e405ef81ace088d93bdc681b6eb05a Mon Sep 17 00:00:00 2001 From: Ben Barclay <ben@nousresearch.com> Date: Fri, 5 Jun 2026 10:19:08 +1000 Subject: [PATCH 28/52] fix(docker): clean up orphaned container when docker run fails (salvage #7440) (#39412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `docker run -d` fails after Docker has already created the container object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image pull), the code raised before `self._container_id` was set — so the container leaked permanently in "Created" state. Reported in #7439: 110+ orphaned containers accumulated over 3 days from hourly cron- scheduled gateway sessions hitting a Docker Desktop startup race. The orphan reaper added in #33645 (reap_orphan_containers) does NOT cover this case: it filters `status=exited`, but a failed-create container is in `Created` state, so it slips through and is never reaped. Wrap the `docker run -d` call in try/except and `docker rm -f` the container by its known name before re-raising. Salvages #7440 by @Tranquil-Flow. Their branch predated the cross-process reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed the same intent (plus their two regression tests, adapted to mock the new reuse `docker ps` probe) against current `main`. Verified adversarially: reverted just the product change to origin/main's `docker.py`, ran the two new tests -> both FAIL with `assert 0 == 1 ("docker rm should be called once")`. With the fix applied, both pass; full test_docker_environment.py is 65/65 green. Closes #7440. Fixes #7439. Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com> --- tests/tools/test_docker_environment.py | 78 ++++++++++++++++++++++++++ tools/environments/docker.py | 32 ++++++++--- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 099e167e77..90f4d5dcd8 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -786,6 +786,84 @@ def test_reuse_falls_back_to_fresh_run_when_start_fails(monkeypatch): assert run_invocations, "fallback to fresh docker run must happen on start failure" +def test_failed_docker_run_cleans_up_orphaned_container(monkeypatch): + """When ``docker run`` fails (e.g. exit 125), the partially-created + container must be removed by name. + + Docker can create the container object before failing to start it, + leaving a stale ``Created`` container. The exited-only orphan reaper + (``reap_orphan_containers``, ``status=exited``) never catches a + ``Created`` orphan, so without this cleanup it leaks permanently. + Regression for #7439. Salvage of #7440 (@Tranquil-Flow). + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default") + + cleanup_calls = [] + + def _run(cmd, **kwargs): + if isinstance(cmd, list) and len(cmd) >= 2: + sub = cmd[1] + if sub == "version": + return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="") + if sub == "ps": + # No reusable container -> fall through to a fresh `docker run`. + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if sub == "run": + raise subprocess.CalledProcessError( + 125, cmd, output="", stderr="docker: Error response from daemon" + ) + if sub == "rm": + cleanup_calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(docker_env.subprocess, "run", _run) + + with pytest.raises(subprocess.CalledProcessError): + _make_dummy_env() + + assert len(cleanup_calls) == 1, "docker rm should be called once for the orphaned container" + rm_cmd = cleanup_calls[0] + assert rm_cmd[1] == "rm" and rm_cmd[2] == "-f" + assert rm_cmd[3].startswith("hermes-"), "should remove the container by its generated name" + + +def test_docker_run_timeout_cleans_up_orphaned_container(monkeypatch): + """When ``docker run`` times out (e.g. slow image pull), the + partially-created container must be removed. Salvage of #7440 + (@Tranquil-Flow); regression for #7439. + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default") + + cleanup_calls = [] + + def _run(cmd, **kwargs): + if isinstance(cmd, list) and len(cmd) >= 2: + sub = cmd[1] + if sub == "version": + return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="") + if sub == "ps": + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if sub == "run": + raise subprocess.TimeoutExpired(cmd, 120) + if sub == "rm": + cleanup_calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(docker_env.subprocess, "run", _run) + + with pytest.raises(subprocess.TimeoutExpired): + _make_dummy_env() + + assert len(cleanup_calls) == 1, "docker rm should be called once for the orphaned container" + rm_cmd = cleanup_calls[0] + assert rm_cmd[1] == "rm" and rm_cmd[2] == "-f" + assert rm_cmd[3].startswith("hermes-"), "should remove the container by its generated name" + + def test_no_reuse_when_persist_across_processes_disabled(monkeypatch): """Opt-out path: ``persist_across_processes=False`` skips the ps probe entirely and always starts a fresh container, matching the pre-fix diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 5a0af2692b..b87bdb125d 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -854,13 +854,31 @@ class DockerEnvironment(BaseEnvironment): "sleep", "infinity", # no fixed lifetime — idle reaper handles cleanup ] logger.debug(f"Starting container: {' '.join(run_cmd)}") - result = subprocess.run( - run_cmd, - capture_output=True, - text=True, - timeout=120, # image pull may take a while - check=True, - ) + try: + result = subprocess.run( + run_cmd, + capture_output=True, + text=True, + timeout=120, # image pull may take a while + check=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + # Docker may create the container object before `docker run` + # fails to start it (e.g. exit code 125 when the daemon isn't + # ready, or a timeout mid-pull). That orphan is left in + # "Created" state — which the exited-only orphan reaper + # (reap_orphan_containers, status=exited) never catches, so it + # leaks permanently. Remove it by its known name before + # re-raising. See #7439. + logger.warning( + "docker run failed for %s, cleaning up orphaned container: %s", + container_name, e, + ) + subprocess.run( + [self._docker_exe, "rm", "-f", container_name], + capture_output=True, timeout=10, + ) + raise self._container_id = result.stdout.strip() logger.info(f"Started container {container_name} ({self._container_id[:12]})") From 2c98dc0a961364d2449df94619fd682e24a5482f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:28:29 -0700 Subject: [PATCH 29/52] fix(desktop): offer remote sign-in on a gated-gateway boot failure (#39402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a remote gateway with username/password (or OAuth) auth restarts, its session cookie lapses and Desktop boots into the recovery overlay with a session-expired error. That overlay only exposed local-recovery actions — Retry (resets the local bootstrap latch) and Repair (re-runs the installer) — neither of which can re-establish a remote session, so the user is stuck in a no-op Retry loop with no way to sign in again. The overlay now detects a remote-reauth boot failure from the saved connection config (remote + gated + not currently connected + has a URL) and surfaces a primary 'Sign in to remote gateway' button that opens the gateway login window (the username/password form for a basic gateway, the OAuth redirect otherwise) and reloads on success. Button copy is driven by a best-effort provider probe, matching the gateway-settings page. Detection and copy logic live in a pure helper module with unit coverage. --- .../src/components/boot-failure-overlay.tsx | 144 ++++++++++++++++-- .../components/boot-failure-reauth.test.ts | 99 ++++++++++++ .../src/components/boot-failure-reauth.ts | 67 ++++++++ 3 files changed, 296 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/components/boot-failure-reauth.test.ts create mode 100644 apps/desktop/src/components/boot-failure-reauth.ts diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index 9439813025..b8cc2205e1 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -2,11 +2,23 @@ import { useStore } from '@nanostores/react' import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' -import { AlertTriangle, FileText, Loader2, RefreshCw, Wrench } from '@/lib/icons' +import type { DesktopConnectionConfig } from '@/global' +import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons' import { $desktopBoot } from '@/store/boot' +import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding } from '@/store/onboarding' -type BusyAction = 'local' | 'repair' | 'retry' | null +import type { RemoteReauth } from './boot-failure-reauth' +import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' + +type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null + +// A remote gateway whose access cookie has lapsed (e.g. the dashboard +// restarted on the remote box) boots into this overlay with a reauth-shaped +// error. The local-recovery buttons (Retry resets the local bootstrap latch; +// Repair re-runs the installer) are no-ops for that case — the only fix is to +// re-establish the remote session. The detection + copy helpers live in +// ./boot-failure-reauth so they're unit-testable without a React render. // Recovery surface for a hard boot failure (gateway never came up, backend // exited during startup, bootstrap latched, …). Without this the app shell @@ -18,6 +30,7 @@ export function BootFailureOverlay() { const [busy, setBusy] = useState<BusyAction>(null) const [logs, setLogs] = useState<string[]>([]) const [showLogs, setShowLogs] = useState(false) + const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null) const visible = Boolean(boot.error) && !boot.running // While first-run onboarding owns the picker/flow we let it surface its own @@ -36,6 +49,59 @@ export function BootFailureOverlay() { .catch(() => undefined) }, [visible]) + // Resolve whether this boot failure is a remote-gateway reauth so we can + // offer the actionable "Sign in" path instead of the local-only recovery + // buttons. Runs whenever the overlay becomes visible. + useEffect(() => { + if (!visible) { + setRemoteReauth(null) + + return + } + + let cancelled = false + + void (async () => { + const desktop = window.hermesDesktop + + if (!desktop?.getConnectionConfig) { + return + } + + let config: DesktopConnectionConfig + + try { + config = await desktop.getConnectionConfig() + } catch { + return + } + + if (cancelled || !isRemoteReauthFailure(config)) { + return + } + + // Best-effort probe for the provider shape so the button copy matches + // what the user will see in the login window (password form vs OAuth + // redirect). Probe failure just keeps the generic copy. + let shape = deriveProviderShape(null) + + try { + const probe = await desktop.probeConnectionConfig(config.remoteUrl) + shape = deriveProviderShape(probe?.providers) + } catch { + // Generic copy is fine. + } + + if (!cancelled) { + setRemoteReauth({ url: config.remoteUrl, ...shape }) + } + })() + + return () => { + cancelled = true + } + }, [visible]) + if (!visible || suppressed) { return null } @@ -59,8 +125,44 @@ export function BootFailureOverlay() { setBusy(null) } + // Open the gateway's login window (renders the username/password form for a + // basic gateway, or the OAuth redirect otherwise — the desktop drives both + // through the same window). On a successful sign-in the session cookie is + // re-established in the persistent partition; reload so boot re-runs and the + // reconnect now mints a ticket against a live session. + const signInRemote = async () => { + if (!remoteReauth) { + return + } + + setBusy('signin') + + try { + const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url) + + if (result?.connected) { + notify({ kind: 'success', title: 'Signed in', message: 'Reconnecting to the remote gateway…' }) + window.location.reload() + + return + } + + notify({ + kind: 'warning', + title: 'Sign-in incomplete', + message: 'The login window closed before authentication finished.' + }) + } catch (err) { + notifyError(err, 'Sign-in failed') + } finally { + setBusy(null) + } + } + const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined) + const label = signInLabel(remoteReauth) + return ( <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6"> <div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm"> @@ -69,10 +171,13 @@ export function BootFailureOverlay() { <AlertTriangle className="size-5" /> </div> <div> - <h2 className="text-[0.9375rem] font-semibold tracking-tight">Hermes couldn't start</h2> + <h2 className="text-[0.9375rem] font-semibold tracking-tight"> + {remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"} + </h2> <p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)"> - The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your - chats or settings. + {remoteReauth + ? 'Your remote gateway session has expired (the dashboard likely restarted). Sign in again to reconnect — nothing here deletes your chats or settings.' + : "The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your chats or settings."} </p> </div> </div> @@ -84,14 +189,23 @@ export function BootFailureOverlay() { <div className="grid gap-2"> <div className="flex flex-wrap gap-2"> - <Button disabled={Boolean(busy)} onClick={() => void retry()}> - {busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />} - Retry - </Button> - <Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline"> - {busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />} - Repair install - </Button> + {remoteReauth ? ( + <Button disabled={Boolean(busy)} onClick={() => void signInRemote()}> + {busy === 'signin' ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />} + {label} + </Button> + ) : ( + <Button disabled={Boolean(busy)} onClick={() => void retry()}> + {busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />} + Retry + </Button> + )} + {!remoteReauth ? ( + <Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline"> + {busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />} + Repair install + </Button> + ) : null} <Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline"> {busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null} Use local gateway @@ -102,7 +216,9 @@ export function BootFailureOverlay() { </Button> </div> <p className="text-xs text-muted-foreground"> - Repair re-runs the installer and can take a few minutes on a fresh machine. + {remoteReauth + ? 'Opens the gateway login window. Use “Use local gateway” to switch to the bundled backend instead.' + : 'Repair re-runs the installer and can take a few minutes on a fresh machine.'} </p> </div> diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts new file mode 100644 index 0000000000..21d7f82297 --- /dev/null +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' + +import type { DesktopConnectionConfig } from '@/global' + +import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' + +function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig { + return { + envOverride: false, + mode: 'remote', + remoteAuthMode: 'oauth', + remoteOauthConnected: false, + remoteTokenPreview: null, + remoteTokenSet: false, + remoteUrl: 'https://box:9119', + ...overrides + } +} + +describe('isRemoteReauthFailure', () => { + it('true for a remote, gated, disconnected gateway with a URL', () => { + expect(isRemoteReauthFailure(config())).toBe(true) + }) + + it('false when the oauth session is still connected', () => { + expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }))).toBe(false) + }) + + it('false for a local gateway', () => { + expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false) + }) + + it('false for a token (non-gated) remote gateway', () => { + expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false) + }) + + it('false when there is no remote URL to sign in against', () => { + expect(isRemoteReauthFailure(config({ remoteUrl: '' }))).toBe(false) + }) + + it('false for null/undefined config', () => { + expect(isRemoteReauthFailure(null)).toBe(false) + expect(isRemoteReauthFailure(undefined)).toBe(false) + }) +}) + +describe('deriveProviderShape', () => { + it('generic copy when there are no providers', () => { + expect(deriveProviderShape([])).toEqual({ isPassword: false, providerLabel: 'your identity provider' }) + expect(deriveProviderShape(null)).toEqual({ isPassword: false, providerLabel: 'your identity provider' }) + }) + + it('password shape when the sole provider supports password', () => { + expect( + deriveProviderShape([{ name: 'basic', displayName: 'Username & Password', supportsPassword: true }]) + ).toEqual({ isPassword: true, providerLabel: 'Username & Password' }) + }) + + it('OAuth shape when the provider is a redirect IDP', () => { + expect(deriveProviderShape([{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }])).toEqual({ + isPassword: false, + providerLabel: 'Nous Research' + }) + }) + + it('mixed deployment keeps generic OAuth copy (not every provider is password)', () => { + const shape = deriveProviderShape([ + { name: 'basic', displayName: 'Username & Password', supportsPassword: true }, + { name: 'nous', displayName: 'Nous Research', supportsPassword: false } + ]) + + expect(shape.isPassword).toBe(false) + expect(shape.providerLabel).toBe('Username & Password / Nous Research') + }) + + it('falls back to name when displayName is empty', () => { + expect(deriveProviderShape([{ name: 'basic', displayName: '', supportsPassword: true }]).providerLabel).toBe( + 'basic' + ) + }) +}) + +describe('signInLabel', () => { + it('password gateway gets the plain "Sign in to remote gateway" copy', () => { + expect(signInLabel({ url: 'x', isPassword: true, providerLabel: 'Username & Password' })).toBe( + 'Sign in to remote gateway' + ) + }) + + it('OAuth gateway names the provider', () => { + expect(signInLabel({ url: 'x', isPassword: false, providerLabel: 'Nous Research' })).toBe( + 'Sign in with Nous Research' + ) + }) + + it('null reauth falls back to the generic provider phrase', () => { + expect(signInLabel(null)).toBe('Sign in with your identity provider') + }) +}) diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts new file mode 100644 index 0000000000..20ac68618a --- /dev/null +++ b/apps/desktop/src/components/boot-failure-reauth.ts @@ -0,0 +1,67 @@ +import type { DesktopAuthProvider, DesktopConnectionConfig } from '@/global' + +// Pure helpers for the boot-failure overlay's remote-reauth branch. Kept out +// of the .tsx so they can be unit-tested without a React/jsdom render (the +// jsx-dev-runtime resolution in this repo's vitest setup is flaky for +// component renders, but these are plain functions). + +export interface RemoteReauth { + url: string + // True when every advertised provider is username/password — drives the + // button copy ("Sign in to remote gateway" vs "Sign in with <provider>"), + // mirroring the gateway-settings page. Probe is best-effort. + isPassword: boolean + providerLabel: string +} + +// A remote, gated (oauth-bucket), not-currently-connected gateway is a +// remote-reauth boot failure: the access cookie lapsed (e.g. the remote +// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't +// fix it — only re-establishing the remote session can. A connected oauth +// session, or a token/local gateway, boots for some other reason the +// local-recovery buttons address, so those return false here. +export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean { + if (!config) { + return false + } + + return ( + config.mode === 'remote' && + config.remoteAuthMode === 'oauth' && + !config.remoteOauthConnected && + Boolean(config.remoteUrl) + ) +} + +// Derive the password flag + display label from the probed providers. A +// gateway is treated as password-style only when EVERY advertised provider +// supports password (a mixed deployment keeps the generic OAuth copy), so the +// button copy matches the login window the user is about to see. +export function deriveProviderShape(providers: DesktopAuthProvider[] | null | undefined): { + isPassword: boolean + providerLabel: string +} { + const list = providers ?? [] + + if (list.length === 0) { + return { isPassword: false, providerLabel: 'your identity provider' } + } + + const isPassword = list.every(p => Boolean(p.supportsPassword)) + + const providerLabel = + list.length === 1 + ? list[0].displayName || list[0].name + : list.map(p => p.displayName || p.name).join(' / ') + + return { isPassword, providerLabel } +} + +// Button copy for the remote sign-in action. +export function signInLabel(reauth: RemoteReauth | null): string { + if (reauth?.isPassword) { + return 'Sign in to remote gateway' + } + + return `Sign in with ${reauth?.providerLabel ?? 'your identity provider'}` +} From 54cae7d1cb94b7686dbbd67d091fe67732527d50 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:47:53 -0400 Subject: [PATCH 30/52] switch model order --- hermes_cli/models.py | 18 ++++++++++-------- tests/hermes_cli/test_models.py | 22 ++++++++++++---------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 558eb008a7..3e3a924a37 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -588,7 +588,8 @@ def union_with_portal_free_recommendations( pair where: * Portal free recommendations missing from ``curated_ids`` are - appended at the front (so the picker shows them first). + appended after the curated list (so the in-repo curated models + show first and Portal-only picks follow). * ``pricing`` gets a synthetic ``{"prompt": "0", "completion": "0"}`` entry for any free recommendation missing from the live pricing map, so :func:`partition_nous_models_by_tier` keeps it. @@ -623,11 +624,11 @@ def union_with_portal_free_recommendations( augmented_ids = list(curated_ids) seen = set(augmented_ids) - # Prepend Portal free recommendations that aren't already curated, so - # they appear first in the picker. + # Append Portal free recommendations that aren't already curated, so the + # in-repo curated ("HA") models show first and Portal-only picks follow. new_ones = [mid for mid in portal_free_ids if mid not in seen] if new_ones: - augmented_ids = new_ones + augmented_ids + augmented_ids = augmented_ids + new_ones return (augmented_ids, augmented_pricing) @@ -653,7 +654,8 @@ def union_with_portal_paid_recommendations( ``(model_ids, pricing)`` pair where: * Portal paid recommendations missing from ``curated_ids`` are - appended at the front (so the picker shows them first). + appended after the curated list (so the in-repo curated models + show first and Portal-only picks follow). * ``pricing`` is left untouched — we deliberately do NOT synthesize pricing entries for paid models. Live pricing is fetched separately via :func:`get_pricing_for_provider`; if the live endpoint hasn't @@ -688,11 +690,11 @@ def union_with_portal_paid_recommendations( augmented_ids = list(curated_ids) seen = set(augmented_ids) - # Prepend Portal paid recommendations that aren't already curated, so - # the Portal-blessed picks surface first in the picker. + # Append Portal paid recommendations that aren't already curated, so the + # in-repo curated ("HA") models show first and Portal-only picks follow. new_ones = [mid for mid in portal_paid_ids if mid not in seen] if new_ones: - augmented_ids = new_ones + augmented_ids + augmented_ids = augmented_ids + new_ones return (augmented_ids, dict(pricing)) diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index d6ae4b1dd5..21f1557d73 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -411,7 +411,7 @@ class TestUnionWithPortalFreeRecommendations: } def test_adds_portal_free_model_missing_from_curated(self): - """A Portal-advertised free model not in curated is prepended + priced free.""" + """A Portal-advertised free model not in curated is appended + priced free.""" curated = ["anthropic/claude-opus-4.6"] pricing = {"anthropic/claude-opus-4.6": self._PAID} with patch( @@ -420,8 +420,9 @@ class TestUnionWithPortalFreeRecommendations: ): ids, p = union_with_portal_free_recommendations(curated, pricing, "") - assert ids[0] == "qwen/qwen3.6-plus" # prepended - assert "anthropic/claude-opus-4.6" in ids + # Curated ("HA") models stay first; Portal-only picks follow. + assert ids[0] == "anthropic/claude-opus-4.6" + assert ids[-1] == "qwen/qwen3.6-plus" # appended # Synthetic free pricing entry created assert p["qwen/qwen3.6-plus"] == self._FREE # Existing pricing untouched @@ -509,7 +510,7 @@ class TestUnionWithPortalFreeRecommendations: }, ): ids, p = union_with_portal_free_recommendations(curated, pricing, "") - assert ids == ["qwen/qwen3.6-plus", "a"] + assert ids == ["a", "qwen/qwen3.6-plus"] assert p["qwen/qwen3.6-plus"] == self._FREE @@ -535,7 +536,7 @@ class TestUnionWithPortalPaidRecommendations: } def test_adds_portal_paid_model_missing_from_curated(self): - """A Portal-advertised paid model not in curated is prepended.""" + """A Portal-advertised paid model not in curated is appended.""" curated = ["anthropic/claude-opus-4.6"] pricing = {"anthropic/claude-opus-4.6": self._PAID} with patch( @@ -544,8 +545,9 @@ class TestUnionWithPortalPaidRecommendations: ): ids, p = union_with_portal_paid_recommendations(curated, pricing, "") - assert ids[0] == "openai/gpt-5.4" # prepended - assert "anthropic/claude-opus-4.6" in ids + # Curated ("HA") models stay first; Portal-only picks follow. + assert ids[0] == "anthropic/claude-opus-4.6" + assert ids[-1] == "openai/gpt-5.4" # appended # Existing pricing untouched assert p["anthropic/claude-opus-4.6"] == self._PAID @@ -634,12 +636,12 @@ class TestUnionWithPortalPaidRecommendations: }, ): ids, p = union_with_portal_paid_recommendations(curated, pricing, "") - assert ids == ["openai/gpt-5.4", "a"] + assert ids == ["a", "openai/gpt-5.4"] # No synthetic entry — pricing is untouched. assert "openai/gpt-5.4" not in p def test_preserves_relative_order_of_new_paid_models(self): - """Multiple new paid models are prepended in payload order.""" + """Multiple new paid models are appended in payload order, after curated.""" curated = ["anthropic/claude-opus-4.6"] pricing = {"anthropic/claude-opus-4.6": self._PAID} with patch( @@ -648,9 +650,9 @@ class TestUnionWithPortalPaidRecommendations: ): ids, _ = union_with_portal_paid_recommendations(curated, pricing, "") assert ids == [ + "anthropic/claude-opus-4.6", "openai/gpt-5.4", "openai/gpt-5.5", - "anthropic/claude-opus-4.6", ] From fd87c61078eb338dd4360dc599122623b074f5bd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:29:45 -0700 Subject: [PATCH 31/52] feat(models): add qwen/qwen3.7-plus to nous + openrouter catalogs (#39409) Adds qwen/qwen3.7-plus directly under qwen/qwen3.7-max in both the OpenRouter curated catalog (OPENROUTER_MODELS) and the Nous portal catalog (_PROVIDER_MODELS['nous']), then regenerates the docs-hosted model-catalog.json manifest from those source lists. --- hermes_cli/models.py | 2 ++ website/static/api/model-catalog.json | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 3e3a924a37..0ca52f6bfa 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -52,6 +52,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("deepseek/deepseek-v4-flash", ""), # Qwen ("qwen/qwen3.7-max", ""), + ("qwen/qwen3.7-plus", ""), ("qwen/qwen3.6-35b-a3b", ""), # MoonshotAI ("moonshotai/kimi-k2.6", "recommended"), @@ -169,6 +170,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "deepseek/deepseek-v4-flash", # Qwen "qwen/qwen3.7-max", + "qwen/qwen3.7-plus", "qwen/qwen3.6-35b-a3b", # MoonshotAI "moonshotai/kimi-k2.6", diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 8cf88bc5c9..a3669541f1 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-06-01T08:20:18Z", + "updated_at": "2026-06-04T23:57:51Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -68,6 +68,10 @@ "id": "qwen/qwen3.7-max", "description": "" }, + { + "id": "qwen/qwen3.7-plus", + "description": "" + }, { "id": "qwen/qwen3.6-35b-a3b", "description": "" @@ -171,6 +175,9 @@ { "id": "qwen/qwen3.7-max" }, + { + "id": "qwen/qwen3.7-plus" + }, { "id": "qwen/qwen3.6-35b-a3b" }, From c54b93587313d4e4bcce6fcb6ca81dff6709ca4b Mon Sep 17 00:00:00 2001 From: Ben Barclay <ben@nousresearch.com> Date: Fri, 5 Jun 2026 10:32:24 +1000 Subject: [PATCH 32/52] fix(desktop): rename session via session.title RPC so /title works (#39410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop `/title <name>` command 404s with "Session not found" on every platform (reported on Windows in #38508). Root cause: `session.create` returns two distinct ids — a *runtime* session id (held in `activeSessionIdRef`) and a `stored_session_id` (the DB `sessions.id`) — and deliberately does NOT persist a DB row until the first turn. Routing `/title` through the REST `PATCH /api/sessions/{id}` endpoint (as #38576 proposed) resolves the id against the `sessions` table, so the runtime id — or any brand-new, not-yet-persisted session — never resolves and returns 404. This is an id-type mismatch, not a Windows file-locking quirk, so it fails on macOS and Linux too. Fix: route `/title <name>` through the gateway's `session.title` RPC — the exact path the TUI already uses (`ui-tui/.../slash/commands/core.ts`). The RPC maps the runtime id to the in-memory session, writes through the gateway's own DB connection, and queues the title (`pending: true`) when the row isn't persisted yet, so it works for a fresh chat. The sidebar is then refreshed via the existing `refreshSessions()` plumbing. Keeps the sidebar-refresh wiring and `refreshSessions` threading from #38576; replaces only the broken REST/slash-worker write path. A bare `/title` (no arg) still falls through to the worker to show the current title. Tests rewritten to assert `session.title` routing with the runtime-vs- stored id distinction (which the original mock collapsed), plus the queued/`pending` fresh-chat case and the error path. Supersedes #38576. Fixes #38508. Co-authored-by: xxxigm <54813621+xxxigm@users.noreply.github.com> --- apps/desktop/src/app/desktop-controller.tsx | 1 + .../session/hooks/use-prompt-actions.test.tsx | 166 ++++++++++++++++++ .../app/session/hooks/use-prompt-actions.ts | 50 +++++- apps/desktop/src/app/types.ts | 8 + 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 020455b331..98879bcee1 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -506,6 +506,7 @@ export function DesktopController() { busyRef, createBackendSessionForSend, handleSkinCommand, + refreshSessions, requestGateway, selectedStoredSessionIdRef, startFreshSessionDraft, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx new file mode 100644 index 0000000000..a27bd2cbd2 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -0,0 +1,166 @@ +import { cleanup, render } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { useEffect } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $sessions, setSessions } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +import { usePromptActions } from './use-prompt-actions' + +vi.mock('@/hermes', () => ({ + transcribeAudio: vi.fn() +})) + +// The active id the desktop holds is the *runtime* session id from +// session.create — deliberately distinct from the stored DB id here, because +// that mismatch is the bug: the REST renameSession endpoint resolves against +// the stored sessions table and 404s on a runtime id. session.title accepts +// the runtime id directly. +const RUNTIME_SESSION_ID = 'rt-abc123' + +function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo { + return { + ended_at: null, + id: RUNTIME_SESSION_ID, + input_tokens: 0, + is_active: true, + last_active: 0, + message_count: 3, + model: null, + output_tokens: 0, + preview: null, + source: null, + started_at: 0, + title: 'Old title', + tool_call_count: 0, + ...overrides + } +} + +interface HarnessHandle { + submitText: (text: string) => Promise<boolean> +} + +function Harness({ + onReady, + refreshSessions, + requestGateway +}: { + onReady: (handle: HarnessHandle) => void + refreshSessions: () => Promise<void> + requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> +}) { + const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID } + const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID } + const busyRef = { current: false } + + const actions = usePromptActions({ + activeSessionId: RUNTIME_SESSION_ID, + activeSessionIdRef, + branchCurrentSession: async () => true, + busyRef, + createBackendSessionForSend: async () => RUNTIME_SESSION_ID, + handleSkinCommand: () => '', + refreshSessions, + requestGateway, + selectedStoredSessionIdRef, + startFreshSessionDraft: () => undefined, + sttEnabled: false, + updateSessionState: (_sessionId, updater) => + updater({ messages: [], busy: false, awaitingResponse: false } as never) + }) + + useEffect(() => { + onReady({ submitText: actions.submitText }) + }, [actions.submitText, onReady]) + + return null +} + +describe('usePromptActions /title', () => { + beforeEach(() => { + setSessions(() => [sessionInfo()]) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('renames via the session.title RPC (with the runtime id), updates the sidebar store, and refreshes', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async (method: string) => + (method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never + ) + + let handle: HarnessHandle | null = null + render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title New title') + + // Routes through session.title with the runtime session id — NOT the slash + // worker (slash.exec) and NOT the REST endpoint. This is the path that + // resolves the runtime id and persists reliably across platforms. + expect(requestGateway).toHaveBeenCalledWith('session.title', { + session_id: RUNTIME_SESSION_ID, + title: 'New title' + }) + expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) + expect(refreshSessions).toHaveBeenCalledTimes(1) + expect($sessions.get()[0]?.title).toBe('New title') + }) + + it('reports the queued state when the session row is not persisted yet', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async (method: string) => + (method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never + ) + + let handle: HarnessHandle | null = null + render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title Fresh chat') + + expect(requestGateway).toHaveBeenCalledWith('session.title', { + session_id: RUNTIME_SESSION_ID, + title: 'Fresh chat' + }) + // Even when queued, the sidebar reflects the chosen title optimistically. + expect(refreshSessions).toHaveBeenCalledTimes(1) + expect($sessions.get()[0]?.title).toBe('Fresh chat') + }) + + it('falls through to the slash worker for a bare /title (show current title)', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never) + + let handle: HarnessHandle | null = null + render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title') + + expect(requestGateway).not.toHaveBeenCalledWith('session.title', expect.anything()) + expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'title' })) + }) + + it('surfaces a rename error without touching the sidebar store', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.title') { + throw new Error('Title too long') + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title way too long title') + + expect(requestGateway).toHaveBeenCalledWith('session.title', expect.objectContaining({ title: 'way too long title' })) + expect(refreshSessions).not.toHaveBeenCalled() + expect($sessions.get()[0]?.title).toBe('Old title') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 535010f489..9abd797435 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -37,10 +37,11 @@ import { setAwaitingResponse, setBusy, setMessages, + setSessions, setYoloActive } from '@/store/session' -import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types' +import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types' function blobToDataUrl(blob: Blob): Promise<string> { return new Promise((resolve, reject) => { @@ -77,6 +78,7 @@ interface PromptActionsOptions { branchCurrentSession: () => Promise<boolean> createBackendSessionForSend: (preview?: string | null) => Promise<string | null> handleSkinCommand: (arg: string) => string + refreshSessions: () => Promise<void> requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> selectedStoredSessionIdRef: MutableRefObject<string | null> startFreshSessionDraft: () => void @@ -141,6 +143,7 @@ export function usePromptActions({ branchCurrentSession, createBackendSessionForSend, handleSkinCommand, + refreshSessions, requestGateway, selectedStoredSessionIdRef, startFreshSessionDraft, @@ -455,6 +458,50 @@ export function usePromptActions({ const renderSlashOutput = (text: string) => appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text) + // /title <name> renames the session. Route through the gateway's + // `session.title` RPC — the same path the TUI uses — NOT the REST + // renameSession endpoint and NOT the slash worker. + // + // Why not the slash worker: it's a separate HermesCLI subprocess whose + // SQLite write to the shared state.db can silently fail (notably on + // Windows), and it never refreshes the sidebar. + // + // Why not REST renameSession: `sessionId` here is the *runtime* session + // id returned by session.create — it is NOT the stored DB `sessions.id`, + // and session.create deliberately does not persist a DB row until the + // first turn. The REST PATCH endpoint resolves against the sessions + // table, so a runtime id (or a brand-new, not-yet-persisted session) + // 404s with "Session not found" on every platform. See #38508 / #38576. + // + // session.title maps the runtime id to the in-memory session, writes + // through the gateway's own DB connection, and QUEUES the title + // (`pending: true`) when the row isn't persisted yet — so it works for a + // fresh chat too. refreshSessions() then pulls the authoritative title + // back into the sidebar. A bare `/title` (no arg) still falls through to + // the worker to display the current title. + if (normalizedName === 'title' && arg) { + try { + const result = await requestGateway<SessionTitleResponse>('session.title', { + session_id: sessionId, + title: arg + }) + const finalTitle = (result?.title || arg).trim() + const queued = result?.pending === true + + setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s))) + await refreshSessions().catch(() => undefined) + renderSlashOutput( + finalTitle + ? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}` + : 'Session title cleared.' + ) + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + + return + } + if (normalizedName === 'skin') { renderSlashOutput(handleSkinCommand(arg)) @@ -555,6 +602,7 @@ export function usePromptActions({ busyRef, createBackendSessionForSend, handleSkinCommand, + refreshSessions, requestGateway, startFreshSessionDraft, submitPromptText diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index d6164a70d8..1ad9e3be9f 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -25,6 +25,14 @@ export interface SlashExecResponse { warning?: string } +export interface SessionTitleResponse { + title?: string + // True when the session row isn't persisted yet and the title was queued + // to be applied on the first turn (see tui_gateway session.title handler). + pending?: boolean + session_key?: string +} + export interface ExecCommandDispatchResponse { type: 'exec' | 'plugin' output?: string From 8a888441d777b1988f27ea7eb4f7c87253bf5e79 Mon Sep 17 00:00:00 2001 From: Ben Barclay <ben@nousresearch.com> Date: Fri, 5 Jun 2026 10:33:44 +1000 Subject: [PATCH 33/52] fix(docker): recover from out-of-band container removal in persistent mode (salvage #36631) (#39415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvage of #36631 (@annguyenNous), rebased onto current main with regression tests added. Fixes #36266. When a persistent Docker sandbox container is removed out-of-band (idle reaper, `docker prune`, OOM kill, daemon restart), the gateway kept issuing `docker exec` against the dead container ID, returning "No such container" on every subsequent tool call — the agent was permanently blocked until the gateway process restarted. DockerEnvironment.execute() now detects the "No such container" / "is not running" error after a non-zero exit (gated on persist_across_processes) and calls _recreate_container(): it tries label-based reuse first, falls back to a fresh container replaying the same image + full all_run_args set, re-runs init_session(), and retries the command once. A genuine non-zero exit is NOT misclassified as container-gone. Differs from #36631 as submitted: adds the tests the original lacked. tests/tools/test_docker_environment.py covers _is_container_gone pattern matching (incl. the negative/control case), the recover-and-retry path, the persist_across_processes=False opt-out (no recovery), and the ordinary-failure passthrough (no spurious recreation). _make_dummy_env now forwards persist_across_processes. Verified: - Unit: 67/67 in test_docker_environment.py (4 new + existing). - Live E2E against the real docker daemon: started a persistent container, `docker rm -f`'d it out-of-band, and the next execute() transparently recreated a fresh container and succeeded; a follow-up command worked in the recovered container; a real `exit N` passed through without triggering recovery. Co-authored-by: annguyenNous <annguyenNous@users.noreply.github.com> --- tests/tools/test_docker_environment.py | 126 +++++++++++++++++++++++++ tools/environments/docker.py | 121 ++++++++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 90f4d5dcd8..04935d81df 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -44,6 +44,7 @@ def _make_dummy_env(**kwargs): auto_mount_cwd=kwargs.get("auto_mount_cwd", False), env=kwargs.get("env"), run_as_host_user=kwargs.get("run_as_host_user", False), + persist_across_processes=kwargs.get("persist_across_processes", True), ) @@ -1707,3 +1708,128 @@ def test_plain_image_keeps_docker_init_and_run_noexec(monkeypatch): assert "noexec" in run_mounts[0], ( f"/run must stay noexec for non-s6 images, got: {run_mounts[0]}" ) + + +# --------------------------------------------------------------------------- +# Out-of-band container removal recovery (issue #36266, PR #36631) +# --------------------------------------------------------------------------- + + +def test_is_container_gone_matches_removal_errors(monkeypatch): + """``_is_container_gone`` recognizes the docker errors that mean the + container no longer exists, and does NOT match ordinary command failures. + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + _mock_subprocess_run(monkeypatch) + env = _make_dummy_env() + + # Positive: the daemon's "container gone" phrasings. + assert env._is_container_gone( + "Error response from daemon: No such container: hermes-abc123" + ) + assert env._is_container_gone("Error: No such container: deadbeef") + assert env._is_container_gone( + "Error response from daemon: Container abc is not running" + ) + + # Control / negative: a real command failure must NOT be misclassified as + # the container being gone — otherwise every non-zero exit would trigger a + # spurious container recreation. + assert not env._is_container_gone("bash: nonsuch: command not found") + assert not env._is_container_gone("Traceback (most recent call last): ...") + assert not env._is_container_gone("") + assert not env._is_container_gone("permission denied") + + +def test_execute_recovers_from_out_of_band_removal(monkeypatch): + """When a persistent container is removed out-of-band, ``execute`` detects + the "No such container" error, recreates the container, and retries once — + returning success transparently. + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + _mock_subprocess_run(monkeypatch) + env = _make_dummy_env( + persistent_filesystem=True, + persist_across_processes=True, + ) + + # First execute() sees a dead container; second (post-recovery) succeeds. + outputs = iter([ + {"output": "Error response from daemon: No such container: hermes-x", "returncode": 1}, + {"output": "ok", "returncode": 0}, + ]) + + def _fake_super_execute(self, command, cwd="", **kwargs): + return next(outputs) + + recreate_calls = [] + + def _fake_recreate(self): + recreate_calls.append(True) + self._container_id = "recovered-container-id" + return True + + monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute) + monkeypatch.setattr( + docker_env.DockerEnvironment, "_recreate_container", _fake_recreate + ) + + result = env.execute("echo hi") + + assert recreate_calls == [True], "recovery should have been attempted exactly once" + assert result.get("returncode") == 0, f"expected success after recovery, got {result!r}" + assert result.get("output") == "ok" + + +def test_execute_does_not_recover_when_not_persistent(monkeypatch): + """A non-persistent session must NOT trigger container recreation on a + "No such container" error — recovery is only meaningful for the persistent, + cross-process container that can be removed out-of-band. + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + _mock_subprocess_run(monkeypatch) + env = _make_dummy_env( + persistent_filesystem=True, + persist_across_processes=False, + ) + + def _fake_super_execute(self, command, cwd="", **kwargs): + return {"output": "No such container: x", "returncode": 1} + + def _fail_recreate(self): + pytest.fail("recreation must not run when persist_across_processes is False") + + monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute) + monkeypatch.setattr( + docker_env.DockerEnvironment, "_recreate_container", _fail_recreate + ) + + result = env.execute("echo hi") + assert result.get("returncode") == 1, "the original error must pass through unchanged" + + +def test_execute_does_not_recover_on_ordinary_failure(monkeypatch): + """A genuine non-zero exit that is NOT a container-gone error must pass + through without triggering recovery (guards against over-eager recreation). + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + _mock_subprocess_run(monkeypatch) + env = _make_dummy_env( + persistent_filesystem=True, + persist_across_processes=True, + ) + + def _fake_super_execute(self, command, cwd="", **kwargs): + return {"output": "bash: badcmd: command not found", "returncode": 127} + + def _fail_recreate(self): + pytest.fail("recreation must not run for an ordinary command failure") + + monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute) + monkeypatch.setattr( + docker_env.DockerEnvironment, "_recreate_container", _fail_recreate + ) + + result = env.execute("badcmd") + assert result.get("returncode") == 127 + assert "command not found" in result.get("output", "") diff --git a/tools/environments/docker.py b/tools/environments/docker.py index b87bdb125d..421eb71be8 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -537,6 +537,10 @@ class DockerEnvironment(BaseEnvironment): self._env = _normalize_env_dict(env) self._container_id: Optional[str] = None self._labels: dict[str, str] = {} + self._image: str = "" + self._container_name: str = "" + self._image_uses_s6_init: bool = False + self._all_run_args: list[str] = [] logger.info(f"DockerEnvironment volumes: {volumes}") # Ensure volumes is a list (config.yaml could be malformed) if volumes is not None and not isinstance(volumes, list): @@ -791,6 +795,12 @@ class DockerEnvironment(BaseEnvironment): "--label", f"hermes-task-id={task_label}", "--label", f"hermes-profile={profile_name}", ] + # Save args for container recreation on "No such container" recovery. + self._image = image + self._container_name = container_name + self._image_uses_s6_init = image_uses_s6_init + self._all_run_args = all_run_args + self._labels = { "hermes-agent": "1", "hermes-task-id": task_label, @@ -945,6 +955,117 @@ class DockerEnvironment(BaseEnvironment): return _popen_bash(cmd, stdin_data) + # ------------------------------------------------------------------ + # "No such container" recovery (issue #36266) + # ------------------------------------------------------------------ + + _NO_CONTAINER_PATTERNS = ( + "No such container", + "is not running", + "no such container", + ) + + def _is_container_gone(self, output: str) -> bool: + """Return True if the output indicates the container no longer exists.""" + return any(p in output for p in self._NO_CONTAINER_PATTERNS) + + def _recreate_container(self) -> bool: + """Recreate the container after it was removed out-of-band. + + Tries label-based reuse first; if no existing container is found, + starts a fresh one with the same image and run-args. Returns True + on success, False if recreation fails (caller should surface the + original error). + """ + old_id = (self._container_id or "")[:12] + logger.warning( + "Container %s appears to be gone — attempting recovery", old_id, + ) + self._container_id = None + + # 1. Try label-based reuse (another process may have recreated it). + task_label = self._labels.get("hermes-task-id", "") + profile_label = self._labels.get("hermes-profile", "") + existing = self._find_reusable_container(task_label, profile_label) + if existing is not None: + cid, state = existing + if state == "running": + self._container_id = cid + logger.info("Recovery: reusing running container %s", cid[:12]) + else: + try: + subprocess.run( + [self._docker_exe, "start", cid], + capture_output=True, text=True, timeout=30, check=True, + ) + self._container_id = cid + logger.info("Recovery: restarted container %s", cid[:12]) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + logger.warning("Recovery: failed to start container %s: %s", cid[:12], e) + + # 2. No reusable container — create a fresh one. + if not self._container_id: + if not self._image: + logger.error("Recovery: no saved image name, cannot recreate container") + return False + try: + import uuid as _uuid + new_name = f"hermes-{_uuid.uuid4().hex[:8]}" + init_args = [] if self._image_uses_s6_init else ["--init"] + label_args = [] + for k, v in self._labels.items(): + label_args.extend(["--label", f"{k}={v}"]) + run_cmd = [ + self._docker_exe, "run", "-d", + *init_args, + "--name", new_name, + *label_args, + "-w", self.cwd, + *self._all_run_args, + self._image, + "sleep", "infinity", + ] + result = subprocess.run( + run_cmd, capture_output=True, text=True, timeout=120, check=True, + ) + self._container_id = result.stdout.strip() + self._container_name = new_name + logger.info( + "Recovery: created fresh container %s (%s)", + new_name, self._container_id[:12], + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e: + logger.error("Recovery: failed to create new container: %s", e) + return False + + # 3. Re-initialize session snapshot in the (re)created container. + try: + self._snapshot_ready = False + self.init_session() + except Exception as e: + logger.error("Recovery: init_session failed in new container: %s", e) + return False + + logger.info("Recovery successful — new container %s", (self._container_id or "")[:12]) + return True + + def execute(self, command: str, cwd: str = "", **kwargs) -> dict: + """Execute a command, auto-recovering from dead containers. + + If the container was removed out-of-band (idle reaper, docker prune, + OOM kill, daemon restart), detect the error and recreate the container + transparently before retrying once. + """ + result = super().execute(command, cwd, **kwargs) + if ( + result.get("returncode", 0) != 0 + and self._is_container_gone(result.get("output", "")) + and self._persist_across_processes + ): + if self._recreate_container(): + result = super().execute(command, cwd, **kwargs) + return result + @staticmethod def _storage_opt_supported() -> bool: """Check if Docker's storage driver supports --storage-opt size=. From b20fcffa54d7c8b62ba6e85702bdf66888467d99 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:38:49 -0700 Subject: [PATCH 34/52] docs: make dashboard/gateway prerequisites explicit for remote-backend connection (#39128) Both the desktop and web-dashboard remote-backend sections now state up front that the 'remote backend' is a running 'hermes dashboard' process the desktop app attaches to (it does not start it for you), and that the gateway is a separate process needed only for messaging channels. --- website/docs/user-guide/desktop.md | 8 +++++++- website/docs/user-guide/features/web-dashboard.md | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 561d6c428e..4655e45cdd 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -110,6 +110,10 @@ The packaged app ships only the Electron shell. On first launch it installs the By default the app starts and manages its own **local** backend. You can instead point it at a Hermes backend running on another machine — a VPS, a home server, or a Mini behind Tailscale. +:::info The remote backend is a running `hermes dashboard` process +"Remote backend" means a **`hermes dashboard`** server running on the remote machine — that is the process the desktop app connects to. Nothing in this section works unless that dashboard is actually up and reachable. The desktop app does not start it for you; you (or a `systemd` service) keep `hermes dashboard` running on the remote host, and the app attaches to it. If you also use messaging channels (Telegram, Discord, etc.), the **gateway** is a *separate* long-running process you start independently — see the note after the setup steps. +::: + The connection has two halves: on the backend you protect the dashboard with a **username and password**, and in the app you enter the backend's URL and sign in with those credentials. Binding the dashboard to a non-loopback address automatically engages its auth gate, so the username/password provider is what lets the desktop app through. ### On the backend (the remote machine) @@ -133,7 +137,9 @@ chmod 600 ~/.hermes/.env hermes dashboard --no-open --host 0.0.0.0 --port 9119 ``` -Make sure the **gateway is running** on the remote host as well if you rely on messaging channels — the desktop app drives the agent, but your gateway sessions are managed separately. See [Messaging](./messaging/index.md) for gateway setup. +Keep that `hermes dashboard` process running for as long as you want the desktop app to be able to connect — if it stops, the app can no longer reach the backend. Run it under `systemd`, `tmux`, or your process manager of choice so it survives logout and reboots. + +Separately, make sure the **gateway is running** on the remote host if you rely on messaging channels — the dashboard backend is what the desktop app talks to, but your Telegram/Discord/Slack gateway sessions are a different process that you start and keep running on their own. See [Messaging](./messaging/index.md) for gateway setup. Prefer not to keep a plaintext password at rest? Set `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` to a scrypt hash instead — compute it with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Full configuration surface (config.yaml keys, every env var, the rate limiter): [Web Dashboard → Username/password provider](./features/web-dashboard.md#usernamepassword-provider-no-oauth-idp). diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index 798a76c1b0..50292bbf0a 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -95,6 +95,10 @@ To point [Hermes Desktop](#connecting-hermes-desktop-to-a-remote-backend) at a d Hermes Desktop normally launches its own local backend, but it can also attach to a dashboard running on a remote machine (a VM, a homelab box, etc.) via **Settings → Gateway → Remote gateway**. This is the most common source of "Desktop says the backend is ready but chat never works" reports, because Desktop's readiness check verifies less than the live chat connection actually needs. +:::info Prerequisite: a `hermes dashboard` must be running on the remote host +The "remote backend" Desktop connects to **is** a `hermes dashboard` process running on the remote machine — the same server this page documents. It has to be up and reachable before any of the steps below matter; Desktop attaches to it, it doesn't start it for you. Keep it running under `systemd`/`tmux`/etc. so it survives logout and reboots. The **gateway** (Telegram/Discord/Slack/etc.) is a *separate* long-running process — start it independently if you rely on messaging channels; it is not the thing the desktop app connects to. +::: + Desktop's "remote backend is ready" probe only hits `GET /api/status`, which is a public endpoint — it answers as soon as *any* dashboard is running on the host. The live chat connection is a **separate** WebSocket to `/api/ws` (and `/api/pty`), and that socket is gated by two more checks the status probe never touches: 1. **You must be authenticated.** When the dashboard is bound to a non-loopback address it engages its auth gate. Protect it with a username and password (the bundled [username/password provider](#usernamepassword-provider-no-oauth-idp)); Desktop signs in once and reuses the resulting session for the WebSocket via a single-use ticket. Without a configured provider, a non-loopback dashboard **fails closed at startup**. From c14c37d46b902b407292838274111d3c5c2fda30 Mon Sep 17 00:00:00 2001 From: Kewe63 <Kewe63@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:34:44 +0300 Subject: [PATCH 35/52] =?UTF-8?q?fix(openviking):=20add=20missing=20/agent?= =?UTF-8?q?/{agent}/=20segment=20to=20memory=20URI=20=E2=80=94=20fixes=20#?= =?UTF-8?q?36969?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _build_memory_uri produced URIs of the form: viking://user/{user}/memories/{subdir}/mem_{slug}.md The /agent/{agent}/ segment was missing, causing every agent under the same user to write into the same flat namespace. In multi-agent deployments agents silently overwrite each other's memories and vector retrieval cross-pollinates results. self._agent was already populated correctly (from OPENVIKING_AGENT env var, default 'hermes') and sent via X-OpenViking-Agent header — it was simply not interpolated into the URI. Fix: add the missing segment so URIs follow the documented shape: viking://user/{user}/agent/{agent}/memories/{subdir}/mem_{slug}.md Tests: 4 new regression tests in TestOpenVikingMemoryUriBuilder, 13/13 passed (9 existing + 4 new). --- plugins/memory/openviking/__init__.py | 4 +- tests/openviking_plugin/test_openviking.py | 50 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 42925fa74a..810f2db43e 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -627,9 +627,9 @@ class OpenVikingMemoryProvider(MemoryProvider): logger.warning("OpenViking session commit failed: %s", e) def _build_memory_uri(self, subdir: str) -> str: - """Build a viking:// memory URI under the configured user/subdir.""" + """Build a viking:// memory URI under the configured user/agent/subdir.""" slug = uuid.uuid4().hex[:12] - return f"viking://user/{self._user}/memories/{subdir}/mem_{slug}.md" + return f"viking://user/{self._user}/agent/{self._agent}/memories/{subdir}/mem_{slug}.md" def on_memory_write( self, diff --git a/tests/openviking_plugin/test_openviking.py b/tests/openviking_plugin/test_openviking.py index 6848afc475..505ac54eb3 100644 --- a/tests/openviking_plugin/test_openviking.py +++ b/tests/openviking_plugin/test_openviking.py @@ -231,3 +231,53 @@ class TestOpenVikingBrowse: "/api/v1/fs/ls", {"uri": "viking://user/hermes"}, )] + + +class TestOpenVikingMemoryUriBuilder: + """Regression tests for _build_memory_uri — fixes #36969. + + Before the fix the URI omitted /agent/{agent}/, causing all agents + under the same user to share the same memory namespace. + """ + + def _make_provider(self, user="alice", agent="coder"): + p = OpenVikingMemoryProvider.__new__(OpenVikingMemoryProvider) + p._user = user + p._agent = agent + return p + + def test_uri_layout_includes_agent_segment(self): + """URI must contain /agent/{agent}/ between user and memories.""" + p = self._make_provider(user="alice", agent="coder") + uri = p._build_memory_uri("preferences") + assert uri.startswith("viking://user/alice/agent/coder/memories/preferences/mem_") + assert uri.endswith(".md") + + def test_uri_uses_configured_agent_not_default(self): + """_agent value must be interpolated — not hardcoded to 'hermes'.""" + p = self._make_provider(user="alice", agent="research-bot") + uri = p._build_memory_uri("entities") + assert "/agent/research-bot/" in uri + assert "/agent/hermes/" not in uri + + def test_uri_slug_is_twelve_hex_chars_and_unique(self): + """Slug must be 12 hex chars and differ between calls.""" + import re + p = self._make_provider() + uri1 = p._build_memory_uri("preferences") + uri2 = p._build_memory_uri("preferences") + slug1 = uri1.split("/mem_")[1].replace(".md", "") + slug2 = uri2.split("/mem_")[1].replace(".md", "") + assert re.fullmatch(r"[0-9a-f]{12}", slug1) + assert re.fullmatch(r"[0-9a-f]{12}", slug2) + assert slug1 != slug2 + + def test_uri_subdir_placed_correctly_for_all_categories(self): + """All five category subdirs must appear between memories/ and slug.""" + p = self._make_provider(user="u", agent="a") + subdirs = ["preferences", "entities", "events", "cases", "patterns"] + for subdir in subdirs: + uri = p._build_memory_uri(subdir) + assert f"/memories/{subdir}/mem_" in uri, ( + f"subdir '{subdir}' not placed correctly in URI: {uri}" + ) From fe4e327bb5f544c319f012b5417fae187e673c73 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 05:25:51 -0700 Subject: [PATCH 36/52] chore: add Kewe63 to release AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index adf20ceaac..c53ba9e715 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -65,6 +65,7 @@ AUTHOR_MAP = { "david.gutowsky@gmail.com": "davidgut1982", "drpelagik@gmail.com": "SeaXen", "lengr@users.noreply.github.com": "LengR", + "Kewe63@users.noreply.github.com": "Kewe63", "17255546+CharZhou@users.noreply.github.com": "CharZhou", "metalclaudbot@gmail.com": "HashClawAI", "tonybear55665566@gmail.com": "TonyPepeBear", From 74e845c000de1f32cd325758407ea706f18b7c36 Mon Sep 17 00:00:00 2001 From: dirtyren <dirtyren@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:43:34 +0000 Subject: [PATCH 37/52] fix(slack): pass thread_ts in standalone send_message tool path The standalone `_send_slack()` function used by the send_message tool and cron delivery fallback was not passing `thread_ts` to the Slack API, causing messages to post to the top-level channel instead of inside threads. - Add `thread_ts` parameter to `_send_slack()` - Include `thread_ts` in the chat.postMessage payload when present - Pass `thread_id` from `_send_to_platform()` to `_send_slack()` Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- tests/tools/test_send_message_tool.py | 1 + tools/send_message_tool.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 10a4868655..56b196a47c 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -595,6 +595,7 @@ class TestSendToPlatformChunking: "***", "C123", "*hello* from <https://example.com|Hermes>", + thread_ts=None, ) def test_slack_bold_italic_formatted_before_send(self, monkeypatch): diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 88bcb4005c..f8386a51e5 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -767,7 +767,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = None for chunk in chunks: if platform == Platform.SLACK: - result = await _send_slack(pconfig.token, chat_id, chunk) + result = await _send_slack(pconfig.token, chat_id, chunk, thread_ts=thread_id) elif platform == Platform.WHATSAPP: result = await _send_whatsapp(pconfig.extra, chat_id, chunk) elif platform == Platform.SIGNAL: @@ -1049,7 +1049,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No return _error(f"Telegram send failed: {e}") -async def _send_slack(token, chat_id, message): +async def _send_slack(token, chat_id, message, thread_ts=None): """Send via Slack Web API.""" try: import aiohttp @@ -1063,6 +1063,8 @@ async def _send_slack(token, chat_id, message): headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: payload = {"channel": chat_id, "text": message, "mrkdwn": True} + if thread_ts: + payload["thread_ts"] = thread_ts async with session.post(url, headers=headers, json=payload, **_req_kw) as resp: data = await resp.json() if data.get("ok"): From 0538c5ed19ffa2dccf389d48654c278db46d0128 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:33:57 -0700 Subject: [PATCH 38/52] chore: add dirtyren to AUTHOR_MAP for PR #38177 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index c53ba9e715..f6f5ebb3a4 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "dirtyren@users.noreply.github.com": "dirtyren", "zhaolei.vc@bytedance.com": "zhaoleibd", "jeffrobodie@gmail.com": "jeffrobodie-glitch", "kyssta-exe@users.noreply.github.com": "kyssta-exe", From 36f1cd7deae3cb0cb31d3b21ce9613c2feb6d8e6 Mon Sep 17 00:00:00 2001 From: ethernet <arilotter@gmail.com> Date: Thu, 4 Jun 2026 20:35:18 -0400 Subject: [PATCH 39/52] feat(installer): do shallow clones no need to get the whole repo history :) --- scripts/install.ps1 | 4 ++-- scripts/install.sh | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a66ba9e8df..d30717b4c0 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1138,7 +1138,7 @@ function Install-Repository { Write-Info "Trying SSH clone..." $env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5" try { - git -c windows.appendAtomically=false clone --branch $Branch $RepoUrlSsh $InstallDir + git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlSsh $InstallDir if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } } catch { } $env:GIT_SSH_COMMAND = $null @@ -1147,7 +1147,7 @@ function Install-Repository { if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue } Write-Info "SSH failed, trying HTTPS..." try { - git -c windows.appendAtomically=false clone --branch $Branch $RepoUrlHttps $InstallDir + git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlHttps $InstallDir if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } } catch { } } diff --git a/scripts/install.sh b/scripts/install.sh index c5d6732e4e..d3095c93ea 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1126,12 +1126,12 @@ clone_repo() { # so SSH fails fast instead of hanging when no key is configured. log_info "Trying SSH clone..." if GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=5" \ - git clone --branch "$BRANCH" "$REPO_URL_SSH" "$INSTALL_DIR" 2>/dev/null; then + git clone --depth 1 --branch "$BRANCH" "$REPO_URL_SSH" "$INSTALL_DIR" 2>/dev/null; then log_success "Cloned via SSH" else rm -rf "$INSTALL_DIR" 2>/dev/null # Clean up partial SSH clone log_info "SSH failed, trying HTTPS..." - if git clone --branch "$BRANCH" "$REPO_URL_HTTPS" "$INSTALL_DIR"; then + if git clone --depth 1 --branch "$BRANCH" "$REPO_URL_HTTPS" "$INSTALL_DIR"; then log_success "Cloned via HTTPS" else log_error "Failed to clone repository" From 99cee124dc446a087684a48c3eafea837c04f67a Mon Sep 17 00:00:00 2001 From: bedirhancode <bedirhan@codeway.co> Date: Fri, 5 Jun 2026 03:49:55 +0300 Subject: [PATCH 40/52] docs(install): warn that VPS browser consoles mangle special chars (#36279) (#38811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some VPS providers (Hetzner Cloud and others) offer a browser-based console for managing hosts. These consoles transmit special characters incorrectly — ':' may arrive as ';', '@' may be mis-rendered, and non-English keyboard layouts fare worse — which silently corrupts 'docker run' arguments like '-v ~/.hermes:/opt/data', '-e KEY=value', and pasted API keys / tokens. Adds a :::caution admonition above the Quick start 'docker run' block in website/docs/user-guide/docker.md recommending SSH for copy-paste- safe command entry, with manual-typing guidance as a fallback. Pure docs change, no code touched. Closes #36279 Co-authored-by: Bedirhan Celayir <bedirhancode@users.noreply.github.com> --- website/docs/user-guide/docker.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index de1e5587fa..c86fff431a 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -17,6 +17,19 @@ This page covers option 1. The container stores all user data (config, API keys, If this is your first time running Hermes Agent, create a data directory on the host and start the container interactively to run the setup wizard: +:::caution Avoid browser-based VPS consoles for the install commands +Some VPS providers (Hetzner Cloud, and several others) offer a browser-based +console for managing hosts. These consoles transmit special characters +incorrectly — `:` may arrive as `;`, `@` may be mis-rendered, and non-English +keyboard layouts fare worse — which silently corrupts `docker run` arguments +like `-v ~/.hermes:/opt/data`, `-e KEY=value`, and pasted API keys / tokens. + +**Connect over SSH instead** (`ssh root@<host>`) for copy-paste-safe command +entry. If you must use the browser console, type the commands manually +instead of pasting, and double-check every `:`, `@`, `=`, and `/` in the +result before hitting Enter. +::: + ```sh mkdir -p ~/.hermes docker run -it --rm \ From 4a4b9bd2dc86b1c360e31e4e104b226f6f1b0521 Mon Sep 17 00:00:00 2001 From: Kewe63 <Kewe63@users.noreply.github.com> Date: Wed, 13 May 2026 00:00:28 +0300 Subject: [PATCH 41/52] fix(test): add platform guard for grp import Tests in test_gateway_service.py imported grp inline without a platform guard, causing ImportError on systems where grp is unavailable (e.g. macOS, WSL without grp module). Added pytest.importorskip('grp') at module level alongside the existing pwd guard, and removed three redundant inline import grp statements. Fixes #24531 --- tests/hermes_cli/test_gateway_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index c529fa59a8..b1d9792008 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -8,6 +8,7 @@ from types import SimpleNamespace import pytest pwd = pytest.importorskip("pwd") +grp = pytest.importorskip("grp") import hermes_cli.gateway as gateway_cli from gateway import status @@ -1331,7 +1332,6 @@ class TestSystemServiceIdentityRootHandling: def test_explicit_root_is_allowed(self, monkeypatch): """When root is explicitly passed via --run-as-user root, allow it.""" - import grp root_info = pwd.getpwnam("root") root_group = grp.getgrgid(root_info.pw_gid).gr_name From f736d2be86b8a76d2ced3d41ced8b172c24a1eeb Mon Sep 17 00:00:00 2001 From: Kewe63 <Kewe63@users.noreply.github.com> Date: Fri, 15 May 2026 16:56:05 +0300 Subject: [PATCH 42/52] fix(vision): detect vision-capable custom providers via ProviderProfile flag _supports_media_in_tool_results() had a hardcoded provider allowlist that missed custom providers and newer vision-capable providers like xiaomi. Added ProviderProfile.supports_vision flag and made the function check: 1. Registered provider profile (supports_vision flag) 2. Model capabilities from models.dev catalog (supports_vision) 3. Existing hardcoded allowlist (unchanged) This fixes HTTP 400 "text is not set" errors when vision-capable custom providers receive text-only tool results instead of multipart image content. Related: #25594 --- plugins/model-providers/xiaomi/__init__.py | 1 + providers/base.py | 9 ++++++++ tools/vision_tools.py | 25 +++++++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/plugins/model-providers/xiaomi/__init__.py b/plugins/model-providers/xiaomi/__init__.py index aed0d8424f..93c7dbb29e 100644 --- a/plugins/model-providers/xiaomi/__init__.py +++ b/plugins/model-providers/xiaomi/__init__.py @@ -9,6 +9,7 @@ xiaomi = ProviderProfile( env_vars=("XIAOMI_API_KEY",), base_url="https://api.xiaomimimo.com/v1", supports_health_check=False, # /v1/models returns 401 even with valid key + supports_vision=True, # mimo-v2-omni is vision-capable ) register_provider(xiaomi) diff --git a/providers/base.py b/providers/base.py index 01023ff55c..d7ff470d89 100644 --- a/providers/base.py +++ b/providers/base.py @@ -56,6 +56,15 @@ class ProviderProfile: auth_type: str = "api_key" # api_key|oauth_device_code|oauth_external|copilot|aws_sdk supports_health_check: bool = True # False → doctor skips /models probe for this provider + # ── Vision support ──────────────────────────────────────── + # True when the provider's API accepts image content inside + # tool-result messages natively. Set on providers that expose + # multimodal models via tool results (Anthropic Messages API, + # OpenAI Chat Completions, Gemini, Xiaomi, MiniMax, etc.). + # Falls back to model-catalog lookup when False and the provider + # has no registered profile. + supports_vision: bool = False + # ── Model catalog ───────────────────────────────────────── # fallback_models: curated list shown in /model picker when live fetch fails. # Only agentic models that support tool calling should appear here. diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 10e97298a2..253856b9bf 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -540,7 +540,9 @@ def _supports_media_in_tool_results(provider: str, model: str) -> bool: results. Older Gemini does NOT. For unknown / legacy providers we conservatively return False — the - caller falls back to the legacy aux-LLM text path. + caller falls back to the legacy aux-LLM text path. The check is relaxed + when the provider's ``ProviderProfile`` declares ``supports_vision=True`` + or when ``get_model_capabilities`` reports vision support for the model. """ if not isinstance(provider, str): return False @@ -577,6 +579,27 @@ def _supports_media_in_tool_results(provider: str, model: str) -> bool: return True return False + # Check the provider's registered profile for the supports_vision flag. + # This covers vision-capable providers like xiaomi, minimax, etc. that + # aren't in the hardcoded list above. + try: + from providers import get_provider_profile + profile = get_provider_profile(p) + if profile is not None and profile.supports_vision: + return True + except Exception: + pass + + # Check model capabilities from the models.dev catalog as a final + # fallback for custom providers whose models happen to be registered. + try: + from agent.models_dev import get_model_capabilities + caps = get_model_capabilities(provider, model) + if caps is not None and bool(getattr(caps, "supports_vision", False)): + return True + except Exception: + pass + # Other vision-capable provider stacks. Conservative default: False. # Add explicit entries here as we verify each provider's tool-result # multimodal support empirically. From d33d23c8526c543ca38ca704f76171c3cec44c3f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:31:51 -0700 Subject: [PATCH 43/52] fix(vision): drop models.dev catalog fallback, keep explicit profile flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The models.dev supports_vision field reflects model IMAGE-INPUT capability, which is not the same contract as 'provider API accepts images inside tool-result messages' — the looser heuristic could re-introduce the exact HTTP 400 'text is not set' it aims to fix. Keep only the explicit, opt-in ProviderProfile.supports_vision flag (set on xiaomi); add catalog-based detection later if a concrete provider needs it. --- tools/vision_tools.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 253856b9bf..0def281429 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -541,8 +541,7 @@ def _supports_media_in_tool_results(provider: str, model: str) -> bool: For unknown / legacy providers we conservatively return False — the caller falls back to the legacy aux-LLM text path. The check is relaxed - when the provider's ``ProviderProfile`` declares ``supports_vision=True`` - or when ``get_model_capabilities`` reports vision support for the model. + when the provider's ``ProviderProfile`` declares ``supports_vision=True``. """ if not isinstance(provider, str): return False @@ -590,16 +589,6 @@ def _supports_media_in_tool_results(provider: str, model: str) -> bool: except Exception: pass - # Check model capabilities from the models.dev catalog as a final - # fallback for custom providers whose models happen to be registered. - try: - from agent.models_dev import get_model_capabilities - caps = get_model_capabilities(provider, model) - if caps is not None and bool(getattr(caps, "supports_vision", False)): - return True - except Exception: - pass - # Other vision-capable provider stacks. Conservative default: False. # Add explicit entries here as we verify each provider's tool-result # multimodal support empirically. From 19db9cd0760e05dafcd1ae637db946cdd65322ce Mon Sep 17 00:00:00 2001 From: kewe63 <kewe.3217@gmail.com> Date: Mon, 13 Apr 2026 21:45:23 +0300 Subject: [PATCH 44/52] fix(acp): replace direct db._lock/_conn access with public update_session_meta() session.py _persist() bypassed SessionDB's thread-safe write path by accessing private internals db._lock and db._conn directly: with db._lock: db._conn.execute("UPDATE sessions SET model_config = ? ...") db._conn.commit() This was fragile for three reasons: 1. It bypassed _execute_write()'s BEGIN IMMEDIATE + jitter-retry logic, so concurrent writes could hit SQLite BUSY without retrying. 2. It called db._conn.commit() manually, breaking the transactional contract that _execute_write() enforces. 3. Any internal rename of _lock or _conn would silently break this call site with an AttributeError at runtime. Fix: - Add SessionDB.update_session_meta(session_id, model_config_json, model) to hermes_state.py. Routes through _execute_write() for the standard BEGIN IMMEDIATE + lock + jitter-retry guarantee. Uses COALESCE so passing model=None leaves the stored model column unchanged. - Replace the db._lock / db._conn block in session.py _persist() with a single db.update_session_meta() call. Tests (tests/acp/test_session_db_private_access.py, 11 tests): - Unit tests for update_session_meta: updates model_config, updates model, preserves existing model on None, routes through _execute_write, no-op on non-existent session. - AST checks: db._lock and db._conn not referenced in session.py; _persist() calls update_session_meta(). - Integration round-trips: cwd and model persisted correctly; COALESCE prevents overwriting an existing model with NULL. --- acp_adapter/session.py | 7 +- hermes_state.py | 18 ++ tests/acp/test_session_db_private_access.py | 201 ++++++++++++++++++++ 3 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 tests/acp/test_session_db_private_access.py diff --git a/acp_adapter/session.py b/acp_adapter/session.py index c40553f267..c124229bec 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -457,12 +457,7 @@ class SessionManager: else: # Update model_config (contains cwd) if changed. try: - with db._lock: - db._conn.execute( - "UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?", - (cwd_json, model_str, state.session_id), - ) - db._conn.commit() + db.update_session_meta(state.session_id, cwd_json, model_str) except Exception: logger.debug("Failed to update ACP session metadata", exc_info=True) diff --git a/hermes_state.py b/hermes_state.py index 9c67779a64..fef4a0d18d 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1104,6 +1104,24 @@ class SessionDB: return None return row["holder"] if isinstance(row, sqlite3.Row) else row[0] + def update_session_meta( + self, + session_id: str, + model_config_json: str, + model: Optional[str] = None, + ) -> None: + """Update model_config and optionally model for an existing session. + + Uses COALESCE so that passing model=None leaves the stored model + column unchanged. Routes through _execute_write for the standard + BEGIN IMMEDIATE + jitter-retry + lock guarantee. + """ + def _do(conn): + conn.execute( + "UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?", + (model_config_json, model, session_id), + ) + self._execute_write(_do) def update_system_prompt(self, session_id: str, system_prompt: str) -> None: """Store the full assembled system prompt snapshot.""" diff --git a/tests/acp/test_session_db_private_access.py b/tests/acp/test_session_db_private_access.py new file mode 100644 index 0000000000..8c1015b5ba --- /dev/null +++ b/tests/acp/test_session_db_private_access.py @@ -0,0 +1,201 @@ +"""Tests for the update_session_meta fix. + +Verifies that: +1. SessionDB.update_session_meta() exists and works correctly via the + public _execute_write path (not db._lock / db._conn directly). +2. session.py _persist() no longer touches db._lock or db._conn. +3. update_session_meta updates the correct columns atomically. +""" + +import ast +import json +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch, call + +import pytest + +from hermes_state import SessionDB +from acp_adapter.session import SessionManager + + +def _tmp_db(tmp_path): + return SessionDB(db_path=tmp_path / "state.db") + + +def _mock_agent(): + return MagicMock(name="MockAIAgent") + + +# --------------------------------------------------------------------------- +# hermes_state.SessionDB.update_session_meta — unit tests +# --------------------------------------------------------------------------- + +class TestUpdateSessionMeta: + """Direct unit tests for the new public method.""" + + def test_method_exists(self, tmp_path): + db = _tmp_db(tmp_path) + assert hasattr(db, "update_session_meta"), ( + "SessionDB must have update_session_meta() public method" + ) + assert callable(db.update_session_meta) + + def test_updates_model_config(self, tmp_path): + db = _tmp_db(tmp_path) + db.create_session("s1", source="acp", model="gpt-4") + + new_meta = json.dumps({"cwd": "/new/path", "provider": "openai"}) + db.update_session_meta("s1", new_meta, model=None) + + row = db.get_session("s1") + stored = json.loads(row["model_config"]) + assert stored["cwd"] == "/new/path" + assert stored["provider"] == "openai" + + def test_updates_model_when_provided(self, tmp_path): + db = _tmp_db(tmp_path) + db.create_session("s2", source="acp", model="gpt-3.5") + + db.update_session_meta("s2", json.dumps({"cwd": "."}), model="gpt-4o") + + row = db.get_session("s2") + assert row["model"] == "gpt-4o" + + def test_preserves_existing_model_when_none(self, tmp_path): + """Passing model=None must leave the stored model unchanged (COALESCE).""" + db = _tmp_db(tmp_path) + db.create_session("s3", source="acp", model="claude-3") + + db.update_session_meta("s3", json.dumps({"cwd": "."}), model=None) + + row = db.get_session("s3") + assert row["model"] == "claude-3" + + def test_uses_execute_write_not_private_api(self, tmp_path): + """update_session_meta must route through _execute_write, not _conn directly.""" + db = _tmp_db(tmp_path) + db.create_session("s4", source="acp") + + call_count = [0] + original = db._execute_write + + def patched(fn): + call_count[0] += 1 + return original(fn) + + db._execute_write = patched + db.update_session_meta("s4", json.dumps({"cwd": "."}), model="m") + + assert call_count[0] >= 1, ( + "update_session_meta must call _execute_write at least once" + ) + + def test_noop_on_nonexistent_session(self, tmp_path): + """Updating a non-existent session must not raise.""" + db = _tmp_db(tmp_path) + db.update_session_meta("ghost", json.dumps({"cwd": "."}), model=None) + + +# --------------------------------------------------------------------------- +# AST check: session.py must not access db._lock or db._conn +# --------------------------------------------------------------------------- + +class TestNoPrviateDBAccess: + """_persist() in session.py must not access db._lock or db._conn.""" + + def test_no_db_private_lock_access(self): + with open("acp_adapter/session.py", encoding="utf-8") as f: + source = f.read() + + tree = ast.parse(source) + + violations = [] + for node in ast.walk(tree): + # Looking for: db._lock or db._conn + if isinstance(node, ast.Attribute): + if isinstance(node.value, ast.Name) and node.value.id == "db": + if node.attr in ("_lock", "_conn"): + violations.append( + f"db.{node.attr} at line {node.lineno}" + ) + + assert violations == [], ( + "session.py accesses private SessionDB internals: " + + ", ".join(violations) + + " — use db.update_session_meta() instead" + ) + + def test_persist_calls_update_session_meta(self): + """AST check: _persist must call db.update_session_meta().""" + with open("acp_adapter/session.py", encoding="utf-8") as f: + tree = ast.parse(f.read()) + + found = False + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "_persist": + for child in ast.walk(node): + if isinstance(child, ast.Call): + func = child.func + if isinstance(func, ast.Attribute): + if func.attr == "update_session_meta": + found = True + break + break + + assert found, ( + "_persist() must call db.update_session_meta() " + "instead of db._conn.execute() directly" + ) + + +# --------------------------------------------------------------------------- +# Integration: _persist round-trip via SessionManager +# --------------------------------------------------------------------------- + +class TestPersistRoundTrip: + """End-to-end: save a session and verify DB state is correct.""" + + def test_cwd_persisted_via_update_session_meta(self, tmp_path): + db = _tmp_db(tmp_path) + manager = SessionManager(agent_factory=_mock_agent, db=db) + + state = manager.create_session(cwd="/original") + assert db.get_session(state.session_id) is not None + + # Simulate cwd change and save + state.cwd = "/updated" + manager.save_session(state.session_id) + + row = db.get_session(state.session_id) + mc = json.loads(row["model_config"]) + assert mc["cwd"] == "/updated" + + def test_model_persisted_via_update_session_meta(self, tmp_path): + db = _tmp_db(tmp_path) + manager = SessionManager(agent_factory=_mock_agent, db=db) + + state = manager.create_session() + state.model = "new-model-xyz" + manager.save_session(state.session_id) + + row = db.get_session(state.session_id) + assert row["model"] == "new-model-xyz" + + def test_existing_model_not_cleared_on_save(self, tmp_path): + """If state.model is empty, the DB model column must not be overwritten.""" + db = _tmp_db(tmp_path) + manager = SessionManager(agent_factory=_mock_agent, db=db) + + state = manager.create_session() + # Manually set a model in DB + db.update_session_meta(state.session_id, json.dumps({"cwd": "."}), model="stored-model") + + # Now save with empty model + state.model = "" + manager.save_session(state.session_id) + + row = db.get_session(state.session_id) + assert row["model"] == "stored-model", ( + "COALESCE must preserve the existing model when new value is NULL" + ) From 76c7512dbfbe22d3279285d19ff64aa7f74ae7ea Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:42:33 -0700 Subject: [PATCH 45/52] chore: add Kewe63 gmail to release AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index f6f5ebb3a4..68c141d561 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -67,6 +67,7 @@ AUTHOR_MAP = { "drpelagik@gmail.com": "SeaXen", "lengr@users.noreply.github.com": "LengR", "Kewe63@users.noreply.github.com": "Kewe63", + "kewe.3217@gmail.com": "Kewe63", "17255546+CharZhou@users.noreply.github.com": "CharZhou", "metalclaudbot@gmail.com": "HashClawAI", "tonybear55665566@gmail.com": "TonyPepeBear", From 46b2afc56b79b9dac1d99e2f6324574a56df5f34 Mon Sep 17 00:00:00 2001 From: Kewe63 <Kewe63@users.noreply.github.com> Date: Wed, 13 May 2026 17:35:44 +0300 Subject: [PATCH 46/52] fix(state): use TRUNCATE WAL checkpoint to prevent unbounded WAL growth PASSIVE checkpoint never shrinks the WAL file, causing state.db-wal to grow without bound. Change to TRUNCATE in _try_wal_checkpoint() and close() so the WAL is truncated regularly. Fixes #24034 --- hermes_state.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index fef4a0d18d..ca7ea5bd03 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -595,17 +595,27 @@ class SessionDB: ) def _try_wal_checkpoint(self) -> None: - """Best-effort PASSIVE WAL checkpoint. Never blocks, never raises. + """Best-effort TRUNCATE WAL checkpoint. Never raises. - Flushes committed WAL frames back into the main DB file for any - frames that no other connection currently needs. Keeps the WAL - from growing unbounded when many processes hold persistent + Flushes committed WAL frames back into the main DB file and + truncates the WAL file to zero bytes. Keeps the WAL from + growing unbounded when many processes hold persistent connections. + + PASSIVE checkpoint was previously used here, but it never + truncates the WAL file — the file stays at its high-water + mark until an explicit TRUNCATE is called (which only + happened inside the infrequent vacuum()). + + TRUNCATE may block writers briefly while checkpointing, but + _try_wal_checkpoint is called off the hot path (every 50 + writes) and already runs under ``self._lock``, so the + additional hold time is negligible. """ try: with self._lock: result = self._conn.execute( - "PRAGMA wal_checkpoint(PASSIVE)" + "PRAGMA wal_checkpoint(TRUNCATE)" ).fetchone() if result and result[1] > 0: logger.debug( @@ -618,13 +628,13 @@ class SessionDB: def close(self): """Close the database connection. - Attempts a PASSIVE WAL checkpoint first so that exiting processes - help keep the WAL file from growing unbounded. + Attempts a TRUNCATE WAL checkpoint first so that exiting processes + help shrink the WAL file. """ with self._lock: if self._conn: try: - self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)") + self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") except Exception: pass self._conn.close() From c60952ba9441a87f572d6bd095b4f98cde69d466 Mon Sep 17 00:00:00 2001 From: kewe63 <kewe.3217@gmail.com> Date: Thu, 4 Jun 2026 05:57:11 -0700 Subject: [PATCH 47/52] fix(web): run URL SSRF checks off the event loop in async paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add async_is_safe_url() wrapping is_safe_url via asyncio.to_thread, and route all async SSRF call sites through it: web_extract_tool, the vision/video preflight checks, and both download redirect guards. socket.getaddrinfo blocks; calling it inline from async tool paths froze the event loop for the duration of DNS resolution. vision_tools: split _validate_image_url into _image_url_shape_ok (no DNS) + sync _validate_image_url (for sync callers/tests) + async _validate_image_url_async. Widened beyond the original PR #3691 to sibling async sites that also blocked the loop (second redirect guard, video preflight). Salvage of #3691 by @Kewe63 — surgically re-applied onto current main because the original branch was too stale to cherry-pick cleanly (would have reverted the web_crawl_tool refactor). Co-authored-by: Kewe63 <kewe.3217@gmail.com> --- tests/test_model_tools_async_bridge.py | 6 ++-- tests/tools/test_url_safety.py | 19 +++++++++++ tests/tools/test_vision_tools.py | 9 ++++-- tests/tools/test_website_policy.py | 10 ++++-- tools/url_safety.py | 10 ++++++ tools/vision_tools.py | 45 +++++++++++++------------- tools/web_tools.py | 4 +-- 7 files changed, 72 insertions(+), 31 deletions(-) diff --git a/tests/test_model_tools_async_bridge.py b/tests/test_model_tools_async_bridge.py index 81ffb2cc62..54fce36d2e 100644 --- a/tests/test_model_tools_async_bridge.py +++ b/tests/test_model_tools_async_bridge.py @@ -372,7 +372,8 @@ class TestVisionDispatchLoopSafety: side_effect=lambda url, dest, **kw: _write_fake_image(dest), ), patch( - "tools.vision_tools._validate_image_url", + "tools.vision_tools._validate_image_url_async", + new_callable=AsyncMock, return_value=True, ), patch( @@ -416,7 +417,8 @@ class TestVisionDispatchLoopSafety: side_effect=lambda url, dest, **kw: _write_fake_image(dest), ), patch( - "tools.vision_tools._validate_image_url", + "tools.vision_tools._validate_image_url_async", + new_callable=AsyncMock, return_value=True, ), patch( diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index 8513a848be..a5e00dcf64 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -5,6 +5,7 @@ from unittest.mock import patch from tools.url_safety import ( is_safe_url, + async_is_safe_url, is_always_blocked_url, _is_blocked_ip, _global_allow_private_urls, @@ -195,6 +196,24 @@ class TestIsSafeUrl: assert is_safe_url("https://multimedia.nt.qq.com.cn/download?id=123") is False +class TestAsyncIsSafeUrl: + """async_is_safe_url must match is_safe_url (runs DNS in a thread pool).""" + + @pytest.mark.asyncio + async def test_public_url_allowed(self): + with patch("socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("93.184.216.34", 0)), + ]): + assert await async_is_safe_url("https://example.com/x") is True + + @pytest.mark.asyncio + async def test_localhost_blocked(self): + with patch("socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("127.0.0.1", 0)), + ]): + assert await async_is_safe_url("http://localhost:8080/") is False + + class TestIsBlockedIp: """Direct tests for the _is_blocked_ip helper.""" diff --git a/tests/tools/test_vision_tools.py b/tests/tools/test_vision_tools.py index 2edff071eb..9373d08f25 100644 --- a/tests/tools/test_vision_tools.py +++ b/tests/tools/test_vision_tools.py @@ -297,7 +297,7 @@ class TestErrorLoggingExcInfo: async def test_analysis_error_logs_exc_info(self, caplog): """When vision_analyze_tool encounters an error, it should log with exc_info.""" with ( - patch("tools.vision_tools._validate_image_url", return_value=True), + patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True), patch( "tools.vision_tools._download_image", new_callable=AsyncMock, @@ -329,7 +329,7 @@ class TestErrorLoggingExcInfo: return dest with ( - patch("tools.vision_tools._validate_image_url", return_value=True), + patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True), patch("tools.vision_tools._download_image", side_effect=fake_download), patch( "tools.vision_tools._image_to_base64_data_url", @@ -451,7 +451,7 @@ class TestVisionSafetyGuards: with ( patch("tools.vision_tools.check_website_access", return_value=blocked), - patch("tools.vision_tools._validate_image_url", return_value=True), + patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True), patch("tools.vision_tools._download_image", new_callable=AsyncMock) as mock_download, ): result = json.loads(await vision_analyze_tool("https://blocked.test/cat.png", "describe")) @@ -549,7 +549,9 @@ class TestTildeExpansion: img = fake_home / "test_image.png" img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8) + # Windows expanduser() prefers USERPROFILE over HOME; POSIX uses HOME. monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) mock_response = MagicMock() mock_choice = MagicMock() @@ -580,6 +582,7 @@ class TestTildeExpansion: fake_home = tmp_path / "fakehome" fake_home.mkdir() monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) result = await vision_analyze_tool( "~/nonexistent.png", "describe this", "test/model" diff --git a/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py index bfe222ef89..712a372867 100644 --- a/tests/tools/test_website_policy.py +++ b/tests/tools/test_website_policy.py @@ -372,7 +372,10 @@ class TestWebToolPolicy: from plugins.web.firecrawl import provider as firecrawl_provider # Allow test URLs past SSRF check so website policy is what gets tested - monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + async def _allow_ssrf(_url: str) -> bool: + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf) # The per-URL website-policy gate moved into the firecrawl plugin's # extract() during the web-provider migration. Patch it at the new # location. @@ -406,7 +409,10 @@ class TestWebToolPolicy: from plugins.web.firecrawl import provider as firecrawl_provider # Allow test URLs past SSRF check so website policy is what gets tested - monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + async def _allow_ssrf(_url: str) -> bool: + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf) def fake_check(url): if url == "https://allowed.test": diff --git a/tools/url_safety.py b/tools/url_safety.py index a0ce297a92..13117d7603 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -27,6 +27,7 @@ import ipaddress import logging import os import socket +import asyncio from urllib.parse import urlparse from utils import is_truthy_value @@ -349,3 +350,12 @@ def is_safe_url(url: str) -> bool: # become SSRF bypass vectors logger.warning("Blocked request — URL safety check error for %s: %s", url, exc) return False + + +async def async_is_safe_url(url: str) -> bool: + """Same rules as :func:`is_safe_url`, but run the DNS work off the event loop. + + ``socket.getaddrinfo`` can block; call this from async code paths (gateway, + ``web_extract_tool``, vision download hooks) instead of ``is_safe_url``. + """ + return await asyncio.to_thread(is_safe_url, url) diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 0def281429..3187f54763 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -74,35 +74,36 @@ _VISION_DOWNLOAD_TIMEOUT = _resolve_download_timeout() _VISION_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 -def _validate_image_url(url: str) -> bool: - """ - Basic validation of image URL format. - - Args: - url (str): The URL to validate - - Returns: - bool: True if URL appears to be valid, False otherwise - """ +def _image_url_shape_ok(url: str) -> bool: + """HTTP(S) shape check only (scheme, netloc). No DNS.""" if not url or not isinstance(url, str): return False - # Basic HTTP/HTTPS URL check if not url.startswith(("http://", "https://")): return False - # Parse to ensure we at least have a network location; still allow URLs # without file extensions (e.g. CDN endpoints that redirect to images). parsed = urlparse(url) if not parsed.netloc: return False + return True + +def _validate_image_url(url: str) -> bool: + """Validate image URL for sync callers and tests (SSRF via sync DNS check).""" + if not _image_url_shape_ok(url): + return False # Block private/internal addresses to prevent SSRF from tools.url_safety import is_safe_url - if not is_safe_url(url): - return False + return is_safe_url(url) - return True + +async def _validate_image_url_async(url: str) -> bool: + """Validate remote image URL without blocking the event loop on DNS.""" + if not _image_url_shape_ok(url): + return False + from tools.url_safety import async_is_safe_url + return await async_is_safe_url(url) def _detect_image_mime_type(image_path: Path) -> Optional[str]: @@ -181,8 +182,8 @@ async def _download_image(image_url: str, destination: Path, max_retries: int = """ if response.is_redirect and response.next_request: redirect_url = str(response.next_request.url) - from tools.url_safety import is_safe_url - if not is_safe_url(redirect_url): + from tools.url_safety import async_is_safe_url + if not await async_is_safe_url(redirect_url): raise ValueError( f"Blocked redirect to private/internal address: {redirect_url}" ) @@ -716,7 +717,7 @@ async def _vision_analyze_native( if local_path.is_file(): temp_image_path = local_path should_cleanup = False - elif _validate_image_url(image_url): + elif await _validate_image_url_async(image_url): blocked = check_website_access(image_url) if blocked: return tool_error(blocked["message"], success=False) @@ -870,7 +871,7 @@ async def vision_analyze_tool( logger.info("Using local image file: %s", image_url) temp_image_path = local_path should_cleanup = False # Don't delete cached/local files - elif _validate_image_url(image_url): + elif await _validate_image_url_async(image_url): # Remote URL -- download to a temporary location blocked = check_website_access(image_url) if blocked: @@ -1265,8 +1266,8 @@ async def _download_video(video_url: str, destination: Path, max_retries: int = async def _ssrf_redirect_guard(response): if response.is_redirect and response.next_request: redirect_url = str(response.next_request.url) - from tools.url_safety import is_safe_url - if not is_safe_url(redirect_url): + from tools.url_safety import async_is_safe_url + if not await async_is_safe_url(redirect_url): raise ValueError( f"Blocked redirect to private/internal address: {redirect_url}" ) @@ -1372,7 +1373,7 @@ async def video_analyze_tool( logger.info("Using local video file: %s", video_url) temp_video_path = local_path should_cleanup = False - elif _validate_image_url(video_url): + elif await _validate_image_url_async(video_url): blocked = check_website_access(video_url) if blocked: raise PermissionError(blocked["message"]) diff --git a/tools/web_tools.py b/tools/web_tools.py index 8f5275da22..a97370c483 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -102,7 +102,7 @@ from tools.tool_backend_helpers import ( # noqa: F401 nous_tool_gateway_unavailable_message, prefers_gateway, ) -from tools.url_safety import is_safe_url +from tools.url_safety import async_is_safe_url import sys logger = logging.getLogger(__name__) @@ -934,7 +934,7 @@ async def web_extract_tool( safe_urls = [] ssrf_blocked: List[Dict[str, Any]] = [] for url in urls: - if not is_safe_url(url): + if not await async_is_safe_url(url): ssrf_blocked.append({ "url": url, "title": "", "content": "", "error": "Blocked: URL targets a private or internal network address", From 93b5df31890fe45d306eb0cb45b84c8f562dc2b1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 06:29:10 -0700 Subject: [PATCH 48/52] fix(test): patch async_is_safe_url in web-provider SSRF mocks web_tools.is_safe_url was replaced by async_is_safe_url, but three web-provider test files still monkeypatched the old sync name, raising AttributeError. Patch the async variant with an async lambda. --- tests/tools/test_web_providers_brave_free.py | 5 ++++- tests/tools/test_web_providers_ddgs.py | 5 ++++- tests/tools/test_web_providers_searxng.py | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_web_providers_brave_free.py b/tests/tools/test_web_providers_brave_free.py index a75b9d38e4..7801b28bd6 100644 --- a/tests/tools/test_web_providers_brave_free.py +++ b/tests/tools/test_web_providers_brave_free.py @@ -259,7 +259,10 @@ class TestBraveFreeSearchOnlyErrors: monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "brave-free"}) monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) - monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + async def _allow_ssrf(_url: str) -> bool: + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) result_str = asyncio.get_event_loop().run_until_complete( diff --git a/tests/tools/test_web_providers_ddgs.py b/tests/tools/test_web_providers_ddgs.py index 7919931614..283a25f0a1 100644 --- a/tests/tools/test_web_providers_ddgs.py +++ b/tests/tools/test_web_providers_ddgs.py @@ -229,7 +229,10 @@ class TestDDGSSearchOnlyErrors: monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "ddgs"}) monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True) monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) - monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + async def _allow_ssrf(_url: str) -> bool: + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) result_str = asyncio.get_event_loop().run_until_complete( diff --git a/tests/tools/test_web_providers_searxng.py b/tests/tools/test_web_providers_searxng.py index 31bbaeb47c..3a4f6d8d6e 100644 --- a/tests/tools/test_web_providers_searxng.py +++ b/tests/tools/test_web_providers_searxng.py @@ -318,7 +318,10 @@ class TestSearXNGOnlyExtractCrawlErrors: monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "searxng"}) monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) - monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + async def _allow_ssrf(_url: str) -> bool: + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) result_str = asyncio.get_event_loop().run_until_complete( From ea44011d152e6140b5732b72d0700c7ecc59c4d1 Mon Sep 17 00:00:00 2001 From: asill-livestream <copii.list@gmail.com> Date: Thu, 4 Jun 2026 16:11:51 +0900 Subject: [PATCH 49/52] fix(desktop): prevent thinking block from closing mid-streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When reasoning text grows during streaming, new parts can be appended beyond endIndex. The pending check used slice(startIndex, endIndex) which excluded these new parts — if the original part completed, the block would close while new reasoning was still streaming. Fix: remove the endIndex cap from slice() so all parts from startIndex onward are checked. During non-streaming, the array is stable and all parts are within range anyway. --- apps/desktop/src/components/assistant-ui/thread.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 513aaabcaf..96c0b71a5a 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -438,7 +438,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star s.thread.isRunning && s.message.status?.type === 'running' && s.message.parts - .slice(Math.max(0, startIndex), Math.min(s.message.parts.length, endIndex)) + .slice(Math.max(0, startIndex)) .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') ) From 46abf040122803911d0e7126f72646321a9cf5ab Mon Sep 17 00:00:00 2001 From: kewe63 <kewe.3217@gmail.com> Date: Mon, 13 Apr 2026 14:55:08 +0300 Subject: [PATCH 50/52] fix(ssh): handle WinError 1314 symlink failure with shutil.copy2 fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, os.symlink() raises OSError (WinError 1314) unless the process has Administrator rights or Developer Mode is enabled. The SSH bulk-upload staging logic used symlinks to mirror the remote layout before piping through tar; this caused all ssh_bulk_upload tests to fail on Windows. - ssh.py: wrap os.symlink() in try/except OSError and fall back to shutil.copy2() so staging works on every platform. shutil was already imported, no new dependency introduced. - file_sync.py: replace str(Path(remote).parent) with posixpath.dirname(remote) in unique_parent_dirs(). pathlib.Path uses the host separator (\ on Windows), but these paths are sent to a remote Linux host over SSH and must always use forward slashes. - test_ssh_bulk_upload.py: make test_staging_symlinks_mirror_remote_layout platform-agnostic — assert file existence and content instead of os.path.islink() + os.readlink(), since the staged entry may be a copy on Windows. --- tests/tools/test_ssh_bulk_upload.py | 16 ++++++++++++---- tools/environments/file_sync.py | 3 ++- tools/environments/ssh.py | 7 ++++++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_ssh_bulk_upload.py b/tests/tools/test_ssh_bulk_upload.py index a2fa82e6c4..c7d38f182a 100644 --- a/tests/tools/test_ssh_bulk_upload.py +++ b/tests/tools/test_ssh_bulk_upload.py @@ -90,7 +90,12 @@ class TestSSHBulkUpload: assert "/home/testuser/.hermes/credentials" in mkdir_str def test_staging_symlinks_mirror_remote_layout(self, mock_env, tmp_path): - """Symlinks in staging dir should mirror the .hermes-relative layout.""" + """Staged file in staging dir should mirror the remote path structure. + + On platforms where symlinks are available (Linux/macOS) the staged + entry is a symlink; on Windows it may be a regular copy. Either way + the file must exist at the expected path and contain the right data. + """ f1 = tmp_path / "local_a.txt" f1.write_text("content a") @@ -105,11 +110,14 @@ class TestSSHBulkUpload: # Capture the staging dir from -C argument c_idx = cmd.index("-C") staging_dir = cmd[c_idx + 1] - # Check the symlink exists + # Check the staged entry exists at the base-relative path expected = os.path.join(staging_dir, "skills/my_skill.md") staging_paths.append(expected) - assert os.path.islink(expected), f"Expected symlink at {expected}" - assert os.readlink(expected) == os.path.abspath(str(f1)) + # File must exist (either as symlink or copy) + assert os.path.exists(expected), f"Expected staged file at {expected}" + # Content must match the source + with open(expected, "r") as fh: + assert fh.read() == "content a" mock = MagicMock() mock.stdout = MagicMock() diff --git a/tools/environments/file_sync.py b/tools/environments/file_sync.py index 6de78c87b8..89f712693f 100644 --- a/tools/environments/file_sync.py +++ b/tools/environments/file_sync.py @@ -9,6 +9,7 @@ view) and don't need this. import hashlib import logging import os +import posixpath import shlex import shutil import signal @@ -87,7 +88,7 @@ def quoted_mkdir_command(dirs: list[str]) -> str: def unique_parent_dirs(files: list[tuple[str, str]]) -> list[str]: """Extract sorted unique parent directories from (host, remote) pairs.""" - return sorted({str(Path(remote).parent) for _, remote in files}) + return sorted({posixpath.dirname(remote) for _, remote in files}) def _sha256_file(path: str) -> str: diff --git a/tools/environments/ssh.py b/tools/environments/ssh.py index 8924d76895..fac9d5d6ce 100644 --- a/tools/environments/ssh.py +++ b/tools/environments/ssh.py @@ -179,6 +179,8 @@ class SSHEnvironment(BaseEnvironment): raise RuntimeError(f"remote mkdir failed: {result.stderr.strip()}") # Symlink staging avoids fragile GNU tar --transform rules. + # On Windows, symlink creation requires admin rights or Developer Mode, + # so fall back to copying the file when os.symlink raises OSError. with tempfile.TemporaryDirectory(prefix="hermes-ssh-bulk-") as staging: for host_path, remote_path in files: try: @@ -195,7 +197,10 @@ class SSHEnvironment(BaseEnvironment): staged = os.path.join(staging, rel_remote) os.makedirs(os.path.dirname(staged), exist_ok=True) - os.symlink(os.path.abspath(host_path), staged) + try: + os.symlink(os.path.abspath(host_path), staged) + except OSError: + shutil.copy2(host_path, staged) tar_cmd = ["tar", "-chf", "-", "-C", staging, "."] ssh_cmd = self._build_ssh_command() From dfe6fbb0b3fbfbbfe396e3248eab4ae4a54a89cb Mon Sep 17 00:00:00 2001 From: kewe63 <kewe.3217@gmail.com> Date: Mon, 13 Apr 2026 15:20:17 +0300 Subject: [PATCH 51/52] fix(ssh): narrow symlink fallback to WinError 1314 only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous catch-all except OSError would silently swallow real errors (disk full, bad path, permission issues unrelated to symlink privilege). Narrow the handler to winerror == 1314 — the specific Windows error code for "A required privilege is not held by the client" — and re-raise every other OSError so genuine failures are not hidden. --- tools/environments/ssh.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tools/environments/ssh.py b/tools/environments/ssh.py index fac9d5d6ce..509e88ef8b 100644 --- a/tools/environments/ssh.py +++ b/tools/environments/ssh.py @@ -179,8 +179,10 @@ class SSHEnvironment(BaseEnvironment): raise RuntimeError(f"remote mkdir failed: {result.stderr.strip()}") # Symlink staging avoids fragile GNU tar --transform rules. - # On Windows, symlink creation requires admin rights or Developer Mode, - # so fall back to copying the file when os.symlink raises OSError. + # On Windows without Developer Mode, symlink creation raises + # OSError with winerror 1314 (privilege not held). Catch only + # that specific error and fall back to a plain copy; all other + # OSErrors (e.g. disk full, bad path) are re-raised as normal. with tempfile.TemporaryDirectory(prefix="hermes-ssh-bulk-") as staging: for host_path, remote_path in files: try: @@ -199,8 +201,12 @@ class SSHEnvironment(BaseEnvironment): os.makedirs(os.path.dirname(staged), exist_ok=True) try: os.symlink(os.path.abspath(host_path), staged) - except OSError: - shutil.copy2(host_path, staged) + except OSError as e: + # WinError 1314: symlink privilege not held (Windows without Dev Mode) + if getattr(e, "winerror", None) == 1314: + shutil.copy2(host_path, staged) + else: + raise tar_cmd = ["tar", "-chf", "-", "-C", staging, "."] ssh_cmd = self._build_ssh_command() From 80672754a875fe7ac0f231054918d47272097f9e Mon Sep 17 00:00:00 2001 From: ethernet <arilotter@gmail.com> Date: Thu, 4 Jun 2026 16:02:46 -0400 Subject: [PATCH 52/52] fix(docs): update all install instructions everywhere --- README.md | 6 +- README.zh-CN.md | 2 +- apps/desktop/README.md | 9 +- hermes_cli/main.py | 2 +- hermes_cli/uninstall.py | 4 +- scripts/install.cmd | 6 +- scripts/install.ps1 | 4 +- scripts/install.sh | 4 +- .../hermes-agent/SKILL.md | 2 +- website/docs/getting-started/installation.md | 83 +++--------- website/docs/getting-started/quickstart.md | 33 +++-- website/docs/getting-started/termux.md | 2 +- .../docs/guides/run-nemotron-3-ultra-free.md | 10 +- website/docs/index.mdx | 118 +++++++++++++----- website/docs/reference/faq.md | 8 +- website/docs/user-guide/desktop.md | 14 +-- .../autonomous-ai-agents-hermes-agent.md | 2 +- website/docs/user-guide/windows-native.md | 106 ++++++++-------- .../docs/user-guide/windows-wsl-quickstart.md | 2 +- website/docusaurus.config.ts | 6 +- .../current/getting-started/installation.md | 40 +++--- .../current/getting-started/quickstart.md | 2 +- .../current/getting-started/termux.md | 2 +- .../current/index.mdx | 87 ++++++++----- .../current/reference/faq.md | 8 +- .../autonomous-ai-agents-hermes-agent.md | 2 +- .../current/user-guide/windows-native.md | 96 +++++++------- .../user-guide/windows-wsl-quickstart.md | 2 +- 28 files changed, 349 insertions(+), 313 deletions(-) diff --git a/README.md b/README.md index bda0c5ed3c..b8fe211714 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open ### Linux, macOS, WSL2, Termux ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` ### Windows (native, PowerShell) @@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri Run this in PowerShell: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands. @@ -52,7 +52,7 @@ If you already have Git installed, the installer detects it and uses that instea > **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies. > -> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively). +> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively). After installation: diff --git a/README.zh-CN.md b/README.zh-CN.md index 38c8bf9312..e40b65990f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,7 +31,7 @@ ## 快速安装 ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 1f4a693c41..525e9ab77a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -27,7 +27,7 @@ Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --include-desktop +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop ``` Already have the Hermes CLI? Just run: @@ -40,7 +40,7 @@ It builds and launches the GUI against your existing install — same config, ke ### Prebuilt installers -When a release ships desktop installers they're attached to its [releases page](https://github.com/NousResearch/hermes-agent/releases) — `.dmg` (macOS), `.exe` / `.msi` (Windows), `.AppImage` / `.deb` / `.rpm` (Linux). These are published manually, so the install-with-Hermes path above is the most reliable way to get the latest. +Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop). --- @@ -56,10 +56,7 @@ hermes update ## Requirements -The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). The only thing worth knowing: - -- **Windows** — the installer bundles its own Git and Python; no admin rights or system changes required. -- **macOS / Linux** — uses your system Python 3.11+ (installed automatically if missing). +The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). --- diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9c91e7a905..313fe0c6c1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10218,7 +10218,7 @@ def _cmd_update_impl(args, gateway_mode: bool): return print("✗ Not a git repository. Please reinstall:") print( - " curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash" + " curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash" ) sys.exit(1) diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index 2c666ddd2c..d6b809fe09 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -734,9 +734,9 @@ def run_uninstall(args): print() print("To reinstall later with your existing settings:") if _is_windows(): - print(color(" iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)", Colors.DIM)) + print(color(" iex (irm https://hermes-agent.nousresearch.com/install.ps1)", Colors.DIM)) else: - print(color(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash", Colors.DIM)) + print(color(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", Colors.DIM)) print() if _is_windows(): diff --git a/scripts/install.cmd b/scripts/install.cmd index 23e40ed65b..e60b4ff344 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -8,7 +8,7 @@ REM Usage: REM curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd REM REM Or if you're already in PowerShell, use the direct command instead: -REM iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +REM iex (irm https://hermes-agent.nousresearch.com/install.ps1) REM ============================================================================ echo. @@ -16,12 +16,12 @@ echo Hermes Agent Installer echo Launching PowerShell installer... echo. -powershell -ExecutionPolicy ByPass -NoProfile -Command "iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)" +powershell -ExecutionPolicy ByPass -NoProfile -Command "iex (irm https://hermes-agent.nousresearch.com/install.ps1)" if %ERRORLEVEL% NEQ 0 ( echo. echo Installation failed. Please try running PowerShell directly: - echo powershell -ExecutionPolicy ByPass -c "iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)" + echo powershell -ExecutionPolicy ByPass -c "iex (irm https://hermes-agent.nousresearch.com/install.ps1)" echo. pause exit /b 1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d30717b4c0..d295749eb3 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -5,7 +5,7 @@ # Uses uv for fast Python provisioning and package management. # # Usage: -# iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +# iex (irm https://hermes-agent.nousresearch.com/install.ps1) # # Or download and run with options: # .\install.ps1 -NoVenv -SkipSetup @@ -2844,7 +2844,7 @@ try { Write-Err "Installation failed: $_" Write-Host "" Write-Info "If the error is unclear, try downloading and running the script directly:" - Write-Host " Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow + Write-Host " Invoke-WebRequest -Uri 'https://hermes-agent.nousresearch.com/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow Write-Host " .\install.ps1" -ForegroundColor Yellow Write-Host "" } diff --git a/scripts/install.sh b/scripts/install.sh index d3095c93ea..06758f1f0b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,7 +6,7 @@ # Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux. # # Usage: -# curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +# curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash # # Or with options: # curl -fsSL ... | bash -s -- --no-venv --skip-setup @@ -451,7 +451,7 @@ detect_os() { OS="windows" DISTRO="windows" log_error "Windows detected. Please use the PowerShell installer:" - log_info " iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)" + log_info " iex (irm https://hermes-agent.nousresearch.com/install.ps1)" exit 1 ;; *) diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index d6188ec492..08a4fd2b43 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -35,7 +35,7 @@ People use Hermes for software development, research, system administration, dat ```bash # Install -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash # Interactive chat (default) hermes diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index d1b13ed557..c47f70d9ea 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -6,80 +6,37 @@ description: "Install Hermes Agent on Linux, macOS, WSL2, native Windows, or And # Installation -Get Hermes Agent up and running in under two minutes with the one-line installer. +Get Hermes Agent up and running in under two minutes! ## Quick Install +### With the Hermes Desktop installer on macOS or Windows (recommended) +To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. -### Desktop App (macOS + Windows) - -Prefer a native installer? - -- **Desktop downloads:** [GitHub Releases](https://github.com/NousResearch/hermes-agent/releases/latest) - -Desktop builds ship signed/notarized macOS artifacts and Windows installers with checksum files. - -### One-Line CLI Installer (Linux / macOS / WSL2) - -For a git-based install that tracks `main` and gives you the latest changes immediately: - +### With the Hermes Desktop installer on Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash ``` -### Windows (native, PowerShell) +### Without Hermes Desktop: +For a command-line only install without Hermes Desktop, run: -Native Windows runs Hermes without WSL — the CLI, gateway, TUI, and tools all work natively. (Both native and WSL2 installs coexist cleanly; see the feature note below for the one WSL2-only feature.) Found a bug? Please [file issues](https://github.com/NousResearch/hermes-agent/issues). +#### Linux / macOS / WSL2 / Android (Termux) +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +``` -Open PowerShell and run: +#### Windows (native) +Run in powershell: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` -The installer handles **everything**: `uv`, Python 3.11, Node.js 22, `ripgrep`, `ffmpeg`, **and a portable Git Bash** (PortableGit — a self-contained Git-for-Windows distribution that ships `bash.exe` and the full POSIX toolchain Hermes uses for shell commands; on 32-bit Windows the installer falls back to MinGit, which lacks bash and disables terminal-tool / agent-browser features). It clones the repo under `%LOCALAPPDATA%\hermes\hermes-agent`, creates a virtualenv, and adds `hermes` to your **User PATH**. Restart your terminal (or open a new PowerShell window) after the install so PATH picks up. - -**How Git is handled:** -1. If `git` is already on your PATH, the installer uses your existing install. -2. Otherwise it downloads portable **PortableGit** (~50MB, from the official `git-for-windows` GitHub release) and unpacks it to `%LOCALAPPDATA%\hermes\git`. No admin rights required. Completely isolated — it won't interfere with any system Git install, broken or otherwise. (On 32-bit Windows it falls back to MinGit because PortableGit ships only 64-bit and ARM64 assets; bash-dependent Hermes features won't work on 32-bit hosts.) - -**Why not use winget?** Earlier designs auto-installed Git via `winget install Git.Git`, but winget fails badly when a system Git install is in a partial or broken state (exactly when users need the installer to just work). The portable Git approach sidesteps winget, the Windows installer registry, and any existing system Git entirely. If the Hermes Git install itself ever breaks, `Remove-Item %LOCALAPPDATA%\hermes\git` and re-run the installer — no system impact, no uninstall drama. - -The installer also sets `HERMES_GIT_BASH_PATH` to the located `bash.exe` so Hermes resolves it deterministically in fresh shells. - -If you prefer WSL2, the Linux installer above works inside it; both native and WSL installs can coexist without conflict (native data lives under `%LOCALAPPDATA%\hermes`, WSL data lives under `~/.hermes`). - -**Desktop installer (alternative):** A thin GUI installer is also available — download Hermes Desktop, run the `.exe`, and on first launch it calls `install.ps1` under the hood to provision Python (via `uv`), Node, PortableGit, and the rest of the dependencies. The desktop app and the PowerShell-installed CLI share the same install and data directories, so you can use either or both. See the [Windows (Native) guide](../user-guide/windows-native#desktop-installer-alternative) for details. - -### Android / Termux - -Hermes now ships a Termux-aware installer path too: - +If you want to install & run Hermes Desktop after a command-line only install, simply run ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +hermes desktop ``` -The installer detects Termux automatically and switches to a tested Android flow: -- uses Termux `pkg` for system dependencies (`git`, `python`, `nodejs`, `ripgrep`, `ffmpeg`, build tools) -- creates the virtualenv with `python -m venv` -- exports `ANDROID_API_LEVEL` automatically for Android wheel builds -- prefers the broad `.[termux-all]` extra and falls back to the smaller `.[termux]` extra (and finally a base install) if the first attempt fails to compile -- skips the untested browser / WhatsApp bootstrap by default - -If you want the fully explicit path, follow the dedicated [Termux guide](./termux.md). - -:::note Windows Feature Parity - -Everything except the browser-based dashboard chat terminal runs natively on Windows: -- **CLI (`hermes chat`, `hermes setup`, `hermes gateway`, …)** — native, uses your default terminal -- **Gateway (Telegram, Discord, Slack, …)** — native, runs as a background PowerShell process -- **Cron scheduler** — native -- **Browser tool** — native (Chromium via Node.js) -- **MCP servers** — native (stdio and HTTP transports both supported) -- **Dashboard `/chat` terminal pane** — **WSL2 only** (uses a POSIX PTY; native Windows has no equivalent). The rest of the dashboard (sessions, jobs, metrics) works natively — only the embedded PTY terminal tab is gated. - -Set `HERMES_DISABLE_WINDOWS_UTF8=1` in your environment if you hit an encoding-related bug and want to fall back to the legacy cp1252 stdio path (useful for bisecting). -::: - ### What the Installer Does The installer handles everything automatically — all dependencies (Python, Node.js, ripgrep, ffmpeg), the repo clone, virtual environment, global `hermes` command setup, and LLM provider configuration. By the end, you're ready to chat. @@ -129,9 +86,7 @@ That logs you in, sets Nous as your provider, and turns on the Tool Gateway in o ## Prerequisites -**pip install:** No prerequisites beyond Python 3.11+. Everything else is handled automatically. - -**Git installer:** The only prerequisite is **Git**. The installer automatically handles everything else: +**Installer:** On non-Windows platforms, the only prerequisite is **Git**. The installer automatically handles everything else: - **uv** (fast Python package manager) - **Python 3.11** (via uv, no sudo needed) @@ -169,12 +124,12 @@ Running Hermes as a dedicated unprivileged user (e.g. a `hermes` systemd service 2. **As the unprivileged service user**, run the regular installer. It will detect the missing sudo, skip `--with-deps`, and install Chromium into the user's local Playwright cache: ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` If you want to skip the Playwright step entirely — for example because you're running headless and don't need browser automation — pass `--skip-browser`: ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-browser + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-browser ``` 3. **Make `hermes` available to the service user's shells.** The installer writes the launcher to `~/.local/bin/hermes`. System service accounts often have a minimal PATH that doesn't include `~/.local/bin`. Either add it to the user's environment, or symlink the launcher into a system location: diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index f76b993747..6a40593cae 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -47,35 +47,32 @@ Pick the row that matches your goal: --- ## 1. Install Hermes Agent +### With the Hermes Desktop installer on macOS or Windows (recommended) +To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. -**Option A — pip (simplest):** - +### With the Hermes Desktop installer on Linux: ```bash -pip install hermes-agent -hermes postinstall # optional: installs Node.js, browser, ripgrep, ffmpeg + runs setup +curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash +``` +### Without Hermes Desktop: +For a command-line only install without Hermes Desktop, run: + +#### Linux / macOS / WSL2 / Android (Termux) +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` -PyPI releases track tagged versions (major/minor releases), not every commit on `main`. For bleeding-edge, use Option B. +#### Windows (native) -**Option B — git installer (tracks main branch):** - -```bash -# Linux / macOS / WSL2 / Android (Termux) -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +Run in powershell: +```powershell +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` -Prefer native installers for desktop use? - -- **Desktop downloads:** [GitHub Releases](https://github.com/NousResearch/hermes-agent/releases/latest) - :::tip Android / Termux If you're installing on a phone, see the dedicated [Termux guide](./termux.md) for the tested manual path, supported extras, and current Android-specific limitations. ::: -:::tip Windows Users -Install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) first, then run the command above inside your WSL2 terminal. -::: - After it finishes, reload your shell: ```bash diff --git a/website/docs/getting-started/termux.md b/website/docs/getting-started/termux.md index 41647dbc86..80aae287ae 100644 --- a/website/docs/getting-started/termux.md +++ b/website/docs/getting-started/termux.md @@ -46,7 +46,7 @@ That does not stop Hermes from working well as a phone-native CLI agent — it j Hermes now ships a Termux-aware installer path: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` On Termux, the installer automatically: diff --git a/website/docs/guides/run-nemotron-3-ultra-free.md b/website/docs/guides/run-nemotron-3-ultra-free.md index 9f8934f466..0192fe105a 100644 --- a/website/docs/guides/run-nemotron-3-ultra-free.md +++ b/website/docs/guides/run-nemotron-3-ultra-free.md @@ -42,14 +42,22 @@ Click **Start chatting**. That's it — you're talking to Nemotron 3 Ultra, free ## Option B — Command line -Prefer the terminal? You'll need macOS, Linux, or Windows via [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) with `curl` installed (`curl` is preinstalled on most systems). +Prefer the terminal? ### 1. Install Hermes Agent +On macOS/Linux/WSL2/Android, run + ```bash curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` +On Windows, run + +```powershell +iex (irm https://hermes-agent.nousresearch.com/install.ps1) +``` + Prefer to review first? Download [`install.sh`](https://hermes-agent.nousresearch.com/install.sh), inspect it, then run it. After it finishes, reload your shell: diff --git a/website/docs/index.mdx b/website/docs/index.mdx index 9a93638e51..2d5bc44431 100644 --- a/website/docs/index.mdx +++ b/website/docs/index.mdx @@ -7,34 +7,90 @@ hide_table_of_contents: true displayed_sidebar: docs --- -import Link from '@docusaurus/Link'; +import Link from "@docusaurus/Link"; # Hermes Agent The self-improving AI agent built by [Nous Research](https://nousresearch.com). The only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, and builds a deepening model of who you are across sessions. -<div style={{display: 'flex', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap'}}> - <Link to="/getting-started/installation" style={{display: 'inline-block', padding: '0.6rem 1.2rem', backgroundColor: '#FFD700', color: '#07070d', borderRadius: '8px', fontWeight: 600, textDecoration: 'none'}}>Get Started →</Link> - <a href="https://github.com/NousResearch/hermes-agent/releases/latest" style={{display: 'inline-block', padding: '0.6rem 1.2rem', border: '1px solid rgba(255,215,0,0.2)', borderRadius: '8px', textDecoration: 'none'}}>Download Desktop</a> - <a href="https://github.com/NousResearch/hermes-agent" style={{display: 'inline-block', padding: '0.6rem 1.2rem', border: '1px solid rgba(255,215,0,0.2)', borderRadius: '8px', textDecoration: 'none'}}>View on GitHub</a> +<div + style={{ + display: "flex", + gap: "1rem", + marginBottom: "2rem", + flexWrap: "wrap", + }} +> + <Link + to="/getting-started/installation" + style={{ + display: "inline-block", + padding: "0.6rem 1.2rem", + backgroundColor: "#FFD700", + color: "#07070d", + borderRadius: "8px", + fontWeight: 600, + textDecoration: "none", + }} + > + Get Started → + </Link> + <a + href="https://hermes-agent.nousresearch.com/desktop" + style={{ + display: "inline-block", + padding: "0.6rem 1.2rem", + border: "1px solid rgba(255,215,0,0.2)", + borderRadius: "8px", + textDecoration: "none", + }} + > + Download Desktop + </a> + <a + href="https://github.com/NousResearch/hermes-agent" + style={{ + display: "inline-block", + padding: "0.6rem 1.2rem", + border: "1px solid rgba(255,215,0,0.2)", + borderRadius: "8px", + textDecoration: "none", + }} + > + View on GitHub + </a> </div> ## Install -**Linux / macOS / WSL2** +### Windows or macOS + +To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. + +### Linux ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash ``` -**Windows (native, PowerShell)** — *[details →](/user-guide/windows-native)* +### Without Hermes Desktop: + +For a command-line only install without Hermes Desktop, run: + +#### Linux / macOS / WSL2 / Android (Termux) + +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +``` + +#### Windows (native) + +Run in powershell: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` -**Android (Termux)** — same curl one-liner as Linux; the installer auto-detects Termux. - See the full **[Installation Guide](/getting-started/installation)** for what the installer does, the per-user vs root layout, and Windows-specific notes. :::tip Fastest path to a working agent @@ -47,26 +103,26 @@ It's not a coding copilot tethered to an IDE or a chatbot wrapper around a singl ## Quick Links -| | | -|---|---| -| 🚀 **[Installation](/getting-started/installation)** | Install in 60 seconds on Linux, macOS, WSL2, or native Windows | -| 📖 **[Quickstart Tutorial](/getting-started/quickstart)** | Your first conversation and key features to try | -| 🗺️ **[Learning Path](/getting-started/learning-path)** | Find the right docs for your experience level | -| ⚙️ **[Configuration](/user-guide/configuration)** | Config file, providers, models, and options | -| 💬 **[Messaging Gateway](/user-guide/messaging)** | Set up Telegram, Discord, Slack, WhatsApp, Teams, or more | -| 🔧 **[Tools & Toolsets](/user-guide/features/tools)** | 60+ built-in tools and how to configure them | -| 🧠 **[Memory System](/user-guide/features/memory)** | Persistent memory that grows across sessions | -| 📚 **[Skills System](/user-guide/features/skills)** | Procedural memory the agent creates and reuses | -| 🔌 **[MCP Integration](/user-guide/features/mcp)** | Connect to MCP servers, filter their tools, and extend Hermes safely | -| 🧭 **[Use MCP with Hermes](/guides/use-mcp-with-hermes)** | Practical MCP setup patterns, examples, and tutorials | -| 🎙️ **[Voice Mode](/user-guide/features/voice-mode)** | Real-time voice interaction in CLI, Telegram, Discord, and Discord VC | -| 🗣️ **[Use Voice Mode with Hermes](/guides/use-voice-mode-with-hermes)** | Hands-on setup and usage patterns for Hermes voice workflows | -| 🎭 **[Personality & SOUL.md](/user-guide/features/personality)** | Define Hermes' default voice with a global SOUL.md | -| 📄 **[Context Files](/user-guide/features/context-files)** | Project context files that shape every conversation | -| 🔒 **[Security](/user-guide/security)** | Command approval, authorization, container isolation | -| 💡 **[Tips & Best Practices](/guides/tips)** | Quick wins to get the most out of Hermes | -| 🏗️ **[Architecture](/developer-guide/architecture)** | How it works under the hood | -| ❓ **[FAQ & Troubleshooting](/reference/faq)** | Common questions and solutions | +| | | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------- | +| 🚀 **[Installation](/getting-started/installation)** | Install in 60 seconds on Linux, macOS, WSL2, or native Windows | +| 📖 **[Quickstart Tutorial](/getting-started/quickstart)** | Your first conversation and key features to try | +| 🗺️ **[Learning Path](/getting-started/learning-path)** | Find the right docs for your experience level | +| ⚙️ **[Configuration](/user-guide/configuration)** | Config file, providers, models, and options | +| 💬 **[Messaging Gateway](/user-guide/messaging)** | Set up Telegram, Discord, Slack, WhatsApp, Teams, or more | +| 🔧 **[Tools & Toolsets](/user-guide/features/tools)** | 60+ built-in tools and how to configure them | +| 🧠 **[Memory System](/user-guide/features/memory)** | Persistent memory that grows across sessions | +| 📚 **[Skills System](/user-guide/features/skills)** | Procedural memory the agent creates and reuses | +| 🔌 **[MCP Integration](/user-guide/features/mcp)** | Connect to MCP servers, filter their tools, and extend Hermes safely | +| 🧭 **[Use MCP with Hermes](/guides/use-mcp-with-hermes)** | Practical MCP setup patterns, examples, and tutorials | +| 🎙️ **[Voice Mode](/user-guide/features/voice-mode)** | Real-time voice interaction in CLI, Telegram, Discord, and Discord VC | +| 🗣️ **[Use Voice Mode with Hermes](/guides/use-voice-mode-with-hermes)** | Hands-on setup and usage patterns for Hermes voice workflows | +| 🎭 **[Personality & SOUL.md](/user-guide/features/personality)** | Define Hermes' default voice with a global SOUL.md | +| 📄 **[Context Files](/user-guide/features/context-files)** | Project context files that shape every conversation | +| 🔒 **[Security](/user-guide/security)** | Command approval, authorization, container isolation | +| 💡 **[Tips & Best Practices](/guides/tips)** | Quick wins to get the most out of Hermes | +| 🏗️ **[Architecture](/developer-guide/architecture)** | How it works under the hood | +| ❓ **[FAQ & Troubleshooting](/reference/faq)** | Common questions and solutions | ## Key Features diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index 59968f1c8c..d3db90f03b 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -33,7 +33,7 @@ Set your provider with `hermes model` or by editing `~/.hermes/.env`. See the [E **Not natively.** Hermes Agent requires a Unix-like environment. On Windows, install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run Hermes from inside it. The standard install command works perfectly in WSL2: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` ### I run Hermes in WSL2. What's the best way to control my normal Windows Chrome? @@ -61,7 +61,7 @@ Yes — Hermes now has a tested Termux install path for Android phones. Quick install: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` For the fully explicit manual steps, supported extras, and current limitations, see the [Termux guide](../getting-started/termux.md). @@ -225,7 +225,7 @@ source ~/.bashrc # If you previously installed with sudo, clean up: sudo rm /usr/local/bin/hermes # Then re-run the standard installer -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` --- @@ -751,7 +751,7 @@ Skills with very long descriptions are truncated to 40 characters in the Telegra 1. Install Hermes Agent on the new machine: ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 2. On the **source machine**, create a full backup: diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 4655e45cdd..3c9e16a4a3 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -22,19 +22,7 @@ Pick whichever fits the moment. They share state, so you can start a session in ## Install -### With the Hermes Desktop installer on MacOS or Windows (recommended) - -[Download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. - -### With the CLI installer on Linux, MacOS, or Windows - -Add `--include-desktop` to the regular install script. - -```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --include-desktop -``` - -### With an existing Hermes installation +Follow the [installation instructions for Hermes Desktop](../getting-started/installation.md). If you already have Hermes installed, simply run diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index dc43372f1a..77f81db14b 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -52,7 +52,7 @@ People use Hermes for software development, research, system administration, dat ```bash # Install -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash # Interactive chat (default) hermes diff --git a/website/docs/user-guide/windows-native.md b/website/docs/user-guide/windows-native.md index a52631c80b..d15711fa74 100644 --- a/website/docs/user-guide/windows-native.md +++ b/website/docs/user-guide/windows-native.md @@ -17,10 +17,12 @@ If you prefer a real POSIX environment (for the dashboard's embedded terminal, ` ## Quick install -Open **PowerShell** (or Windows Terminal) and run: +[Download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. + +Or, for a command-line only install, open **PowerShell** (or Windows Terminal) and run: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` No admin rights required. The installer goes to `%LOCALAPPDATA%\hermes\` and adds `hermes` to your **User PATH** — open a new terminal after it finishes. @@ -28,38 +30,32 @@ No admin rights required. The installer goes to `%LOCALAPPDATA%\hermes\` and add **Installer options** (requires the scriptblock form to pass parameters): ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1))) -NoVenv -SkipSetup -Branch main +& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1))) -NoVenv -SkipSetup -Branch main ``` -| Parameter | Default | Purpose | -|---|---|---| -| `-Branch` | `main` | Clone a specific branch (useful for testing PRs) | -| `-Commit` | unset | Pin install to a specific commit SHA (overrides `-Branch`) | -| `-Tag` | unset | Pin install to a specific git tag (e.g. `v0.14.0`) | -| `-NoVenv` | off | Skip venv creation (advanced — you manage Python yourself) | -| `-SkipSetup` | off | Skip the post-install `hermes setup` wizard | -| `-HermesHome` | `%LOCALAPPDATA%\hermes` | Override data directory | -| `-InstallDir` | `%LOCALAPPDATA%\hermes\hermes-agent` | Override code location | +| Parameter | Default | Purpose | +| ------------- | ------------------------------------ | ---------------------------------------------------------- | +| `-Branch` | `main` | Clone a specific branch (useful for testing PRs) | +| `-Commit` | unset | Pin install to a specific commit SHA (overrides `-Branch`) | +| `-Tag` | unset | Pin install to a specific git tag (e.g. `v0.14.0`) | +| `-NoVenv` | off | Skip venv creation (advanced — you manage Python yourself) | +| `-SkipSetup` | off | Skip the post-install `hermes setup` wizard | +| `-HermesHome` | `%LOCALAPPDATA%\hermes` | Override data directory | +| `-InstallDir` | `%LOCALAPPDATA%\hermes\hermes-agent` | Override code location | The installer auto-retries flaky git fetches and strips BOM from any downloaded `install.ps1` payload, so a UTF-8 BOM picked up during HTTP transit no longer breaks the `[scriptblock]::Create((irm ...))` form. -### Desktop installer (alternative) - -A thin GUI installer is also available — useful if you'd rather double-click an `.exe` than open PowerShell. Download Hermes Desktop, run the installer, and on first launch the GUI calls `install.ps1` under the hood to provision Python (via `uv`), Node, PortableGit, and the rest of the dependency bootstrap described below. After the first run, the desktop app and the PowerShell-installed `hermes` CLI share the same `%LOCALAPPDATA%\hermes\hermes-agent` install and `%USERPROFILE%\.hermes` data directory — switch between the GUI and the CLI freely. - -Use the desktop installer when you want a familiar Windows install experience or you're handing Hermes to a non-developer; use the PowerShell one-liner when you're already in a terminal. - ### Dependency bootstrap (`dep_ensure`) On first launch (and on demand when a missing tool is detected), Hermes runs a small Python bootstrapper — `hermes_cli/dep_ensure.py` — that checks for and lazily installs the non-Python dependencies it needs. On Windows, the relevant ones are: -| Dependency | Why Hermes needs it | -|---|---| -| **PortableGit** | Provides `bash.exe` for the terminal tool and `git` for in-session clones. Provisioned at install time, not by `dep_ensure`. | -| **Node.js 22** | Required for the browser tool (`agent-browser`), the TUI's web bridge, and the WhatsApp bridge. | -| **ffmpeg** | Audio format conversion for TTS / voice messages. | -| **ripgrep** | Fast file search — falls back to `grep` if unavailable. | -| **npm packages** | `agent-browser`, Playwright Chromium, and any per-toolset Node deps are installed once at first browser-tool use. | +| Dependency | Why Hermes needs it | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| **PortableGit** | Provides `bash.exe` for the terminal tool and `git` for in-session clones. Provisioned at install time, not by `dep_ensure`. | +| **Node.js 22** | Required for the browser tool (`agent-browser`), the TUI's web bridge, and the WhatsApp bridge. | +| **ffmpeg** | Audio format conversion for TTS / voice messages. | +| **ripgrep** | Fast file search — falls back to `grep` if unavailable. | +| **npm packages** | `agent-browser`, Playwright Chromium, and any per-toolset Node deps are installed once at first browser-tool use. | Each dep has a `shutil.which(...)`-style check; if a binary is missing and the run is interactive, `dep_ensure` offers to install it (deferring to `scripts\install.ps1 -ensure <dep>` for the actual install logic). Non-interactive runs (gateway, cron, headless desktop launches) skip the prompt and surface a clear `this feature needs <dep>` error instead. @@ -86,18 +82,18 @@ On Windows, per-tool API key setup (Firecrawl, FAL, Browser Use, OpenAI TTS) is Everything except the dashboard's embedded terminal pane runs natively on Windows. -| Feature | Native Windows | WSL2 | -|---|---|---| -| CLI (`hermes chat`, `hermes setup`, `hermes gateway`, …) | ✓ | ✓ | -| Interactive TUI (`hermes --tui`) | ✓ | ✓ | -| Messaging gateway (Telegram, Discord, Slack, WhatsApp, 15+ platforms) | ✓ | ✓ | -| Cron scheduler | ✓ | ✓ | -| Browser tool (Chromium via Node) | ✓ | ✓ | -| MCP servers (stdio and HTTP) | ✓ | ✓ | -| Local Ollama / LM Studio / llama-server | ✓ | ✓ (via WSL networking) | -| Web dashboard (sessions, jobs, metrics, config) | ✓ | ✓ | -| Dashboard `/chat` embedded terminal pane | ✗ (needs POSIX PTY) | ✓ | -| Auto-start at login | ✓ (schtasks) | ✓ (systemd) | +| Feature | Native Windows | WSL2 | +| --------------------------------------------------------------------- | ------------------- | ---------------------- | +| CLI (`hermes chat`, `hermes setup`, `hermes gateway`, …) | ✓ | ✓ | +| Interactive TUI (`hermes --tui`) | ✓ | ✓ | +| Messaging gateway (Telegram, Discord, Slack, WhatsApp, 15+ platforms) | ✓ | ✓ | +| Cron scheduler | ✓ | ✓ | +| Browser tool (Chromium via Node) | ✓ | ✓ | +| MCP servers (stdio and HTTP) | ✓ | ✓ | +| Local Ollama / LM Studio / llama-server | ✓ | ✓ (via WSL networking) | +| Web dashboard (sessions, jobs, metrics, config) | ✓ | ✓ | +| Dashboard `/chat` embedded terminal pane | ✗ (needs POSIX PTY) | ✓ | +| Auto-start at login | ✓ (schtasks) | ✓ (systemd) | The dashboard's `/chat` tab embeds a real terminal via a POSIX PTY (`ptyprocess`). Native Windows has no equivalent primitive; Python's `pywinpty` / Windows ConPTY would work but is a separate implementation — treat as future work. **The rest of the dashboard works natively** — only that one tab shows a "use WSL2 for this" banner. @@ -140,12 +136,12 @@ Hermes's Windows stdio shim now sets `EDITOR=notepad` as a default. Notepad ship **User overrides still win** (they're checked before the setdefault): -| Editor | PowerShell command | -|---|---| -| VS Code | `$env:EDITOR = "code --wait"` | +| Editor | PowerShell command | +| --------- | ---------------------------------------------------------------------------------- | +| VS Code | `$env:EDITOR = "code --wait"` | | Notepad++ | `$env:EDITOR = "'C:\Program Files\Notepad++\notepad++.exe' -multiInst -nosession"` | -| Neovim | `$env:EDITOR = "nvim"` | -| Helix | `$env:EDITOR = "hx"` | +| Neovim | `$env:EDITOR = "nvim"` | +| Helix | `$env:EDITOR = "hx"` | The `--wait` flag on VS Code is critical — without it the editor returns immediately and Hermes gets a blank buffer back. @@ -200,13 +196,13 @@ Services require admin rights to install and tie the gateway's lifecycle to mach ## Data layout -| Path | Contents | -|---|---| -| `%LOCALAPPDATA%\hermes\hermes-agent\` | Git checkout + venv. Safe to `Remove-Item -Recurse` and reinstall. | -| `%LOCALAPPDATA%\hermes\git\` | PortableGit (only if the installer provisioned it). | -| `%LOCALAPPDATA%\hermes\node\` | Portable Node.js (only if the installer provisioned it). | -| `%LOCALAPPDATA%\hermes\bin\` | `hermes.cmd` shim, added to User PATH. | -| `%USERPROFILE%\.hermes\` | Your config, auth, skills, sessions, logs. **Survives reinstalls.** | +| Path | Contents | +| ------------------------------------- | ------------------------------------------------------------------- | +| `%LOCALAPPDATA%\hermes\hermes-agent\` | Git checkout + venv. Safe to `Remove-Item -Recurse` and reinstall. | +| `%LOCALAPPDATA%\hermes\git\` | PortableGit (only if the installer provisioned it). | +| `%LOCALAPPDATA%\hermes\node\` | Portable Node.js (only if the installer provisioned it). | +| `%LOCALAPPDATA%\hermes\bin\` | `hermes.cmd` shim, added to User PATH. | +| `%USERPROFILE%\.hermes\` | Your config, auth, skills, sessions, logs. **Survives reinstalls.** | The split is deliberate: `%LOCALAPPDATA%\hermes` is disposable infrastructure (you can blow it away and the one-liner restores it). `%USERPROFILE%\.hermes` is your data — config, memory, skills, session history — and is identical in shape to a Linux install. Mirror it between machines and your Hermes moves with you. @@ -248,11 +244,11 @@ Don't put secrets in User environment variables unless you specifically want eve These only affect native Windows installs: -| Variable | Effect | -|---|---| -| `HERMES_GIT_BASH_PATH` | Override bash.exe discovery. Point at any bash — full Git-for-Windows, WSL bash via symlink, MSYS2, Cygwin. The installer sets this automatically. | -| `HERMES_DISABLE_WINDOWS_UTF8` | Set to `1` to disable the UTF-8 stdio shim and fall back to the locale code page. Useful for bisecting an encoding bug. | -| `EDITOR` / `VISUAL` | Your editor for `/edit` and `Ctrl-X Ctrl-E`. Hermes defaults to `notepad` if both are unset. | +| Variable | Effect | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `HERMES_GIT_BASH_PATH` | Override bash.exe discovery. Point at any bash — full Git-for-Windows, WSL bash via symlink, MSYS2, Cygwin. The installer sets this automatically. | +| `HERMES_DISABLE_WINDOWS_UTF8` | Set to `1` to disable the UTF-8 stdio shim and fall back to the locale code page. Useful for bisecting an encoding bug. | +| `EDITOR` / `VISUAL` | Your editor for `/edit` and `Ctrl-X Ctrl-E`. Hermes defaults to `notepad` if both are unset. | ## Uninstall @@ -287,7 +283,7 @@ Consequence: any codepath that said "check if this PID is alive" via `os.kill(pi ## Common pitfalls **`hermes: command not found` right after install.** -Open a new PowerShell window. The installer added `%LOCALAPPDATA%\hermes\bin` to User PATH, but existing shells need to be restarted to pick it up. In the meantime you can run `& "$env:LOCALAPPDATA\hermes\bin\hermes.cmd"`. +Open a new PowerShell window. The installer added `%LOCALAPPDATA%\hermes\bin` to User PATH, but existing shells need to be restarted to pick it up. **`WinError 193: %1 is not a valid Win32 application` when running a tool.** You hit a shebang-script invocation that bypassed the `.cmd` shim. Hermes resolves commands through `shutil.which(cmd, path=local_bin)` so PATHEXT picks up `.CMD` — if you're invoking the tool via a hardcoded path instead, switch to the `.cmd` variant (e.g., `npx.cmd`, not `npx`). diff --git a/website/docs/user-guide/windows-wsl-quickstart.md b/website/docs/user-guide/windows-wsl-quickstart.md index 937c643a4d..2128b3be91 100644 --- a/website/docs/user-guide/windows-wsl-quickstart.md +++ b/website/docs/user-guide/windows-wsl-quickstart.md @@ -100,7 +100,7 @@ The `metadata` mount option above is important — without it, files on `/mnt/c/ Once you have a WSL2 shell open: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash source ~/.bashrc hermes ``` diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 265e1e5da9..9e55ad2d02 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -114,7 +114,7 @@ const config: Config = { position: 'left', }, { - href: 'https://github.com/NousResearch/hermes-agent/releases/latest', + href: 'https://hermes-agent.nousresearch.com/desktop', label: 'Download', position: 'left', }, @@ -155,14 +155,14 @@ const config: Config = { title: 'Community', items: [ { label: 'Discord', href: 'https://discord.gg/NousResearch' }, - { label: 'GitHub Discussions', href: 'https://github.com/NousResearch/hermes-agent/discussions' }, + { label: 'GitHub Issues', href: 'https://github.com/NousResearch/hermes-agent/issues' }, { label: 'Skills Hub', href: 'https://agentskills.io' }, ], }, { title: 'More', items: [ - { label: 'Desktop Download', href: 'https://github.com/NousResearch/hermes-agent/releases/latest' }, + { label: 'Desktop Download', href: 'https://hermes-agent.nousresearch.com/desktop' }, { label: 'GitHub', href: 'https://github.com/NousResearch/hermes-agent' }, { label: 'Nous Research', href: 'https://nousresearch.com' }, ], diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md index 700b1aaed4..6e91995e74 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md @@ -15,7 +15,7 @@ description: "在 Linux、macOS、WSL2、原生 Windows 或通过 Termux 在 And 基于 git 的安装方式,跟踪 `main` 分支,可立即获取最新变更: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` ### Windows(原生,PowerShell) @@ -25,12 +25,13 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri 打开 PowerShell 并运行: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` 安装程序处理**一切**:`uv`、Python 3.11、Node.js 22、`ripgrep`、`ffmpeg`,**以及一个便携式 Git Bash**(PortableGit——一个自包含的 Git-for-Windows 发行版,附带 `bash.exe` 和 Hermes 用于 shell 命令的完整 POSIX 工具链;在 32 位 Windows 上安装程序会回退到 MinGit,后者缺少 bash,终端工具和 agent 浏览器功能将被禁用)。它将仓库克隆到 `%LOCALAPPDATA%\hermes\hermes-agent`,创建虚拟环境,并将 `hermes` 添加到**用户 PATH**。安装完成后请重启终端(或打开新的 PowerShell 窗口)以使 PATH 生效。 **Git 的处理方式:** + 1. 如果 `git` 已在你的 PATH 中,安装程序将使用现有安装。 2. 否则,它会下载便携式 **PortableGit**(约 50MB,来自官方 `git-for-windows` GitHub 发布页)并解压到 `%LOCALAPPDATA%\hermes\git`。无需管理员权限,完全隔离——不会干扰任何系统 Git 安装,无论其状态如何。(在 32 位 Windows 上会回退到 MinGit,因为 PortableGit 仅提供 64 位和 ARM64 资产;依赖 bash 的 Hermes 功能在 32 位主机上无法使用。) @@ -47,10 +48,11 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script Hermes 现在也提供 Termux 感知的安装路径: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 安装程序会自动检测 Termux 并切换到经过测试的 Android 流程: + - 使用 Termux `pkg` 安装系统依赖(`git`、`python`、`nodejs`、`ripgrep`、`ffmpeg`、构建工具) - 使用 `python -m venv` 创建虚拟环境 - 自动导出 `ANDROID_API_LEVEL` 以用于 Android wheel 构建 @@ -62,6 +64,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri :::note Windows 功能对等性 除基于浏览器的 dashboard 聊天终端外,其余功能均可在 Windows 上原生运行: + - **CLI(`hermes chat`、`hermes setup`、`hermes gateway` 等)** — 原生,使用默认终端 - **Gateway(Telegram、Discord、Slack 等)** — 原生,作为后台 PowerShell 进程运行 - **Cron 调度器** — 原生 @@ -80,11 +83,11 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri 安装程序的存放位置取决于你是以普通用户还是 root 身份安装: -| 安装方式 | 代码位置 | `hermes` 二进制 | 数据目录 | -|---|---|---|---| -| pip install | Python site-packages | `~/.local/bin/hermes`(console_scripts) | `~/.hermes/` | -| 用户级(git 安装程序) | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes`(符号链接) | `~/.hermes/` | -| Root 模式(`sudo curl … \| sudo bash`) | `/usr/local/lib/hermes-agent/` | `/usr/local/bin/hermes` | `/root/.hermes/`(或 `$HERMES_HOME`) | +| 安装方式 | 代码位置 | `hermes` 二进制 | 数据目录 | +| --------------------------------------- | ------------------------------ | ---------------------------------------- | ------------------------------------- | +| pip install | Python site-packages | `~/.local/bin/hermes`(console_scripts) | `~/.hermes/` | +| 用户级(git 安装程序) | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes`(符号链接) | `~/.hermes/` | +| Root 模式(`sudo curl … \| sudo bash`) | `/usr/local/lib/hermes-agent/` | `/usr/local/bin/hermes` | `/root/.hermes/`(或 `$HERMES_HOME`) | Root 模式的 **FHS 布局**(`/usr/local/lib/…`、`/usr/local/bin/hermes`)与其他系统级开发工具在 Linux 上的安装位置一致。适用于共享机器部署场景,一次系统安装可服务所有用户。每个用户的个人配置(认证、技能、会话)仍位于各自的 `~/.hermes/` 或显式指定的 `HERMES_HOME` 下。 @@ -154,22 +157,27 @@ hermes setup --portal **推荐的分步方式(Debian/Ubuntu):** 1. **一次性操作,以具有 sudo 权限的管理员用户身份**,安装 Chromium 所需的系统库: + ```bash sudo npx playwright install-deps chromium ``` + (可在任意位置运行——`npx` 会自动获取 Playwright。) 2. **以非特权服务用户身份**,运行常规安装程序。它会检测到缺少 sudo,跳过 `--with-deps`,并将 Chromium 安装到用户本地的 Playwright 缓存中: + ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 如果想完全跳过 Playwright 步骤——例如在无头环境中运行且不需要浏览器自动化——传入 `--skip-browser`: + ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-browser + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-browser ``` 3. **使 `hermes` 对服务用户的 shell 可用。** 安装程序将启动器写入 `~/.local/bin/hermes`。系统服务账户通常具有不包含 `~/.local/bin` 的最小 PATH。可以将其添加到用户环境,或将启动器符号链接到系统位置: + ```bash # 方案 A — 添加到服务用户的 profile echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc @@ -186,14 +194,14 @@ hermes setup --portal ## 故障排查 -| 问题 | 解决方案 | -|---------|----------| -| `hermes: command not found` | 重新加载 shell(`source ~/.bashrc`)或检查 PATH | -| `API key not set` | 运行 `hermes model` 配置提供商,或 `hermes config set OPENROUTER_API_KEY your_key` | -| 更新后配置丢失 | 运行 `hermes config check`,然后运行 `hermes config migrate` | +| 问题 | 解决方案 | +| --------------------------- | ---------------------------------------------------------------------------------- | +| `hermes: command not found` | 重新加载 shell(`source ~/.bashrc`)或检查 PATH | +| `API key not set` | 运行 `hermes model` 配置提供商,或 `hermes config set OPENROUTER_API_KEY your_key` | +| 更新后配置丢失 | 运行 `hermes config check`,然后运行 `hermes config migrate` | 如需更多诊断信息,运行 `hermes doctor`——它会告诉你确切缺少什么以及如何修复。 ## 安装方式自动检测 -Hermes 会自动检测安装方式(`pip`、git 安装程序、Homebrew 或 NixOS),`hermes update` 会打印对应路径的更新命令。无需设置任何环境变量——检测基于安装目录结构(Python site-packages、`~/.hermes/hermes-agent/`、Homebrew 前缀或 Nix store 路径)。`hermes doctor` 也会在其环境摘要中显示检测到的安装方式。 \ No newline at end of file +Hermes 会自动检测安装方式(`pip`、git 安装程序、Homebrew 或 NixOS),`hermes update` 会打印对应路径的更新命令。无需设置任何环境变量——检测基于安装目录结构(Python site-packages、`~/.hermes/hermes-agent/`、Homebrew 前缀或 Nix store 路径)。`hermes doctor` 也会在其环境摘要中显示检测到的安装方式。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md index 2978485d98..7651bc95d2 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md @@ -61,7 +61,7 @@ PyPI 发布版本跟踪带标签的版本(主/次版本发布),而非 `mai ```bash # Linux / macOS / WSL2 / Android (Termux) -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` :::tip Android / Termux diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md index 72fdad9739..1500cc39e1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md @@ -46,7 +46,7 @@ python -m pip install -e '.[termux]' -c constraints-termux.txt Hermes 现已内置 Termux 感知的安装路径: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 在 Termux 上,安装程序会自动: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx index da6a3fa100..b4f7515680 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx @@ -7,15 +7,46 @@ hide_table_of_contents: true displayed_sidebar: docs --- -import Link from '@docusaurus/Link'; +import Link from "@docusaurus/Link"; # Hermes Agent 由 [Nous Research](https://nousresearch.com) 构建的自我改进 AI 智能体。唯一内置学习循环的智能体——它从经验中创建技能,在使用过程中持续改进,主动提示自身持久化知识,并在会话间不断深化对你的建模。 -<div style={{display: 'flex', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap'}}> - <Link to="/getting-started/installation" style={{display: 'inline-block', padding: '0.6rem 1.2rem', backgroundColor: '#FFD700', color: '#07070d', borderRadius: '8px', fontWeight: 600, textDecoration: 'none'}}>快速开始 →</Link> - <a href="https://github.com/NousResearch/hermes-agent" style={{display: 'inline-block', padding: '0.6rem 1.2rem', border: '1px solid rgba(255,215,0,0.2)', borderRadius: '8px', textDecoration: 'none'}}>在 GitHub 上查看</a> +<div + style={{ + display: "flex", + gap: "1rem", + marginBottom: "2rem", + flexWrap: "wrap", + }} +> + <Link + to="/getting-started/installation" + style={{ + display: "inline-block", + padding: "0.6rem 1.2rem", + backgroundColor: "#FFD700", + color: "#07070d", + borderRadius: "8px", + fontWeight: 600, + textDecoration: "none", + }} + > + 快速开始 → + </Link> + <a + href="https://github.com/NousResearch/hermes-agent" + style={{ + display: "inline-block", + padding: "0.6rem 1.2rem", + border: "1px solid rgba(255,215,0,0.2)", + borderRadius: "8px", + textDecoration: "none", + }} + > + 在 GitHub 上查看 + </a> </div> ## 安装 @@ -23,13 +54,13 @@ import Link from '@docusaurus/Link'; **Linux / macOS / WSL2** ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` -**Windows(原生,PowerShell)** — *早期测试版,[详情 →](/user-guide/windows-native)* +**Windows(原生,PowerShell)** — _早期测试版,[详情 →](/user-guide/windows-native)_ ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` **Android(Termux)** — 与 Linux 相同的 curl 一行命令;安装程序会自动检测 Termux。 @@ -42,26 +73,26 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script ## 快速链接 -| | | -|---|---| -| 🚀 **[安装](/getting-started/installation)** | 在 Linux、macOS、WSL2 或原生 Windows(早期测试版)上 60 秒完成安装 | -| 📖 **[快速入门教程](/getting-started/quickstart)** | 第一次对话及值得尝试的核心功能 | -| 🗺️ **[学习路径](/getting-started/learning-path)** | 根据你的经验水平找到合适的文档 | -| ⚙️ **[配置](/user-guide/configuration)** | 配置文件、提供商、模型及选项 | -| 💬 **[消息网关](/user-guide/messaging)** | 配置 Telegram、Discord、Slack、WhatsApp、Teams 等平台 | -| 🔧 **[工具与工具集](/user-guide/features/tools)** | 70+ 内置工具及其配置方式 | -| 🧠 **[记忆系统](/user-guide/features/memory)** | 跨会话持续增长的持久记忆 | -| 📚 **[技能系统](/user-guide/features/skills)** | 智能体创建并复用的程序性记忆 | -| 🔌 **[MCP 集成](/user-guide/features/mcp)** | 连接 MCP 服务器、过滤其工具,并安全扩展 Hermes | -| 🧭 **[在 Hermes 中使用 MCP](/guides/use-mcp-with-hermes)** | 实用的 MCP 配置模式、示例与教程 | -| 🎙️ **[语音模式](/user-guide/features/voice-mode)** | 在 CLI、Telegram、Discord 及 Discord 语音频道中进行实时语音交互 | -| 🗣️ **[在 Hermes 中使用语音模式](/guides/use-voice-mode-with-hermes)** | Hermes 语音工作流的实操配置与使用模式 | -| 🎭 **[个性与 SOUL.md](/user-guide/features/personality)** | 通过全局 SOUL.md 定义 Hermes 的默认风格 | -| 📄 **[上下文文件](/user-guide/features/context-files)** | 影响每次对话的项目上下文文件 | -| 🔒 **[安全](/user-guide/security)** | 命令审批、授权与容器隔离 | -| 💡 **[技巧与最佳实践](/guides/tips)** | 快速上手,充分发挥 Hermes 的潜力 | -| 🏗️ **[架构](/developer-guide/architecture)** | 底层工作原理 | -| ❓ **[常见问题与故障排查](/reference/faq)** | 常见问题及解决方案 | +| | | +| --------------------------------------------------------------------- | ------------------------------------------------------------------ | +| 🚀 **[安装](/getting-started/installation)** | 在 Linux、macOS、WSL2 或原生 Windows(早期测试版)上 60 秒完成安装 | +| 📖 **[快速入门教程](/getting-started/quickstart)** | 第一次对话及值得尝试的核心功能 | +| 🗺️ **[学习路径](/getting-started/learning-path)** | 根据你的经验水平找到合适的文档 | +| ⚙️ **[配置](/user-guide/configuration)** | 配置文件、提供商、模型及选项 | +| 💬 **[消息网关](/user-guide/messaging)** | 配置 Telegram、Discord、Slack、WhatsApp、Teams 等平台 | +| 🔧 **[工具与工具集](/user-guide/features/tools)** | 70+ 内置工具及其配置方式 | +| 🧠 **[记忆系统](/user-guide/features/memory)** | 跨会话持续增长的持久记忆 | +| 📚 **[技能系统](/user-guide/features/skills)** | 智能体创建并复用的程序性记忆 | +| 🔌 **[MCP 集成](/user-guide/features/mcp)** | 连接 MCP 服务器、过滤其工具,并安全扩展 Hermes | +| 🧭 **[在 Hermes 中使用 MCP](/guides/use-mcp-with-hermes)** | 实用的 MCP 配置模式、示例与教程 | +| 🎙️ **[语音模式](/user-guide/features/voice-mode)** | 在 CLI、Telegram、Discord 及 Discord 语音频道中进行实时语音交互 | +| 🗣️ **[在 Hermes 中使用语音模式](/guides/use-voice-mode-with-hermes)** | Hermes 语音工作流的实操配置与使用模式 | +| 🎭 **[个性与 SOUL.md](/user-guide/features/personality)** | 通过全局 SOUL.md 定义 Hermes 的默认风格 | +| 📄 **[上下文文件](/user-guide/features/context-files)** | 影响每次对话的项目上下文文件 | +| 🔒 **[安全](/user-guide/security)** | 命令审批、授权与容器隔离 | +| 💡 **[技巧与最佳实践](/guides/tips)** | 快速上手,充分发挥 Hermes 的潜力 | +| 🏗️ **[架构](/developer-guide/architecture)** | 底层工作原理 | +| ❓ **[常见问题与故障排查](/reference/faq)** | 常见问题及解决方案 | ## 核心功能 @@ -83,4 +114,4 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script - **[`/llms.txt`](/llms.txt)** — 每个文档页面的精选索引,附简短描述。约 17 KB,可安全加载到 LLM 上下文中。 - **[`/llms-full.txt`](/llms-full.txt)** — 所有文档页面拼接为单一 markdown 文件,支持一次性摄取。约 1.8 MB。 -两个文件同样可通过 `/docs/llms.txt` 和 `/docs/llms-full.txt` 访问。每次部署时全新生成。 \ No newline at end of file +两个文件同样可通过 `/docs/llms.txt` 和 `/docs/llms-full.txt` 访问。每次部署时全新生成。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md index 9cb1cd024f..36fc3c3132 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md @@ -33,7 +33,7 @@ Hermes Agent 可与任何兼容 OpenAI 的 API 配合使用。支持的提供商 **原生不支持。** Hermes Agent 需要类 Unix 环境。在 Windows 上,请安装 [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) 并在其中运行 Hermes。标准安装命令在 WSL2 中可完美运行: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` ### 我在 WSL2 中运行 Hermes,如何控制 Windows 上的普通 Chrome? @@ -61,7 +61,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri 快速安装: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 完整的手动步骤、支持的扩展及当前限制,请参阅 [Termux 指南](../getting-started/termux.md)。 @@ -225,7 +225,7 @@ source ~/.bashrc # 如果之前使用 sudo 安装,请先清理: sudo rm /usr/local/bin/hermes # 然后重新运行标准安装程序 -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` --- @@ -750,7 +750,7 @@ skills: 1. 在新机器上安装 Hermes Agent: ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 2. 在**源机器**上创建完整备份: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index da96b2f183..eee73a2b4a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -52,7 +52,7 @@ Hermes 的差异化特性: ```bash # 安装 -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash # 交互式聊天(默认) hermes diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md index 1d3b816779..89555b02cb 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md @@ -1,4 +1,4 @@ ---- +P--- title: "Windows(原生)指南" description: "在 Windows 10 / 11 上原生运行 Hermes Agent — 安装、功能矩阵、UTF-8 控制台、Git Bash、将 gateway 作为计划任务、编辑器处理、PATH、卸载及常见问题" sidebar_label: "Windows(原生)" @@ -20,7 +20,7 @@ Hermes 可在 Windows 10 和 Windows 11 上原生运行——无需 WSL、Cygwin 打开 **PowerShell**(或 Windows Terminal)并运行: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` 无需管理员权限。安装程序会写入 `%LOCALAPPDATA%\hermes\`,并将 `hermes` 添加到你的**用户 PATH**——安装完成后打开新终端即可使用。 @@ -28,18 +28,18 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script **安装程序选项**(需要使用 scriptblock 形式传递参数): ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1))) -NoVenv -SkipSetup -Branch main +& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1))) -NoVenv -SkipSetup -Branch main ``` -| 参数 | 默认值 | 用途 | -|---|---|---| -| `-Branch` | `main` | 克隆指定分支(用于测试 PR) | -| `-Commit` | 未设置 | 将安装固定到指定 commit SHA(覆盖 `-Branch`) | -| `-Tag` | 未设置 | 将安装固定到指定 git tag(如 `v0.14.0`) | -| `-NoVenv` | 关闭 | 跳过 venv 创建(高级用法——由你自行管理 Python) | -| `-SkipSetup` | 关闭 | 跳过安装后的 `hermes setup` 向导 | -| `-HermesHome` | `%LOCALAPPDATA%\hermes` | 覆盖数据目录 | -| `-InstallDir` | `%LOCALAPPDATA%\hermes\hermes-agent` | 覆盖代码存放位置 | +| 参数 | 默认值 | 用途 | +| ------------- | ------------------------------------ | ----------------------------------------------- | +| `-Branch` | `main` | 克隆指定分支(用于测试 PR) | +| `-Commit` | 未设置 | 将安装固定到指定 commit SHA(覆盖 `-Branch`) | +| `-Tag` | 未设置 | 将安装固定到指定 git tag(如 `v0.14.0`) | +| `-NoVenv` | 关闭 | 跳过 venv 创建(高级用法——由你自行管理 Python) | +| `-SkipSetup` | 关闭 | 跳过安装后的 `hermes setup` 向导 | +| `-HermesHome` | `%LOCALAPPDATA%\hermes` | 覆盖数据目录 | +| `-InstallDir` | `%LOCALAPPDATA%\hermes\hermes-agent` | 覆盖代码存放位置 | 安装程序会自动重试不稳定的 git 拉取,并剥离下载的 `install.ps1` 内容中的 BOM,因此 HTTP 传输中携带的 UTF-8 BOM 不再会破坏 `[scriptblock]::Create((irm ...))` 形式。 @@ -53,13 +53,13 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script 在首次启动时(以及检测到缺少工具时按需触发),Hermes 会运行一个小型 Python 引导程序——`hermes_cli/dep_ensure.py`——检查并懒加载安装所需的非 Python 依赖。在 Windows 上,相关依赖如下: -| 依赖 | Hermes 需要它的原因 | -|---|---| -| **PortableGit** | 为终端工具提供 `bash.exe`,为会话内克隆提供 `git`。在安装时配置,而非由 `dep_ensure` 负责。 | -| **Node.js 22** | 浏览器工具(`agent-browser`)、TUI 的 web 桥接以及 WhatsApp 桥接所必需。 | -| **ffmpeg** | TTS / 语音消息的音频格式转换。 | -| **ripgrep** | 快速文件搜索——不可用时回退到 `grep`。 | -| **npm 包** | `agent-browser`、Playwright Chromium 以及各工具集的 Node 依赖,在首次使用浏览器工具时安装一次。 | +| 依赖 | Hermes 需要它的原因 | +| --------------- | ----------------------------------------------------------------------------------------------- | +| **PortableGit** | 为终端工具提供 `bash.exe`,为会话内克隆提供 `git`。在安装时配置,而非由 `dep_ensure` 负责。 | +| **Node.js 22** | 浏览器工具(`agent-browser`)、TUI 的 web 桥接以及 WhatsApp 桥接所必需。 | +| **ffmpeg** | TTS / 语音消息的音频格式转换。 | +| **ripgrep** | 快速文件搜索——不可用时回退到 `grep`。 | +| **npm 包** | `agent-browser`、Playwright Chromium 以及各工具集的 Node 依赖,在首次使用浏览器工具时安装一次。 | 每个依赖都有类似 `shutil.which(...)` 的检查;如果二进制文件缺失且当前为交互式运行,`dep_ensure` 会提示安装(实际安装逻辑委托给 `scripts\install.ps1 -ensure <dep>`)。非交互式运行(gateway、cron、无头桌面启动)会跳过提示,并直接给出清晰的 `this feature needs <dep>` 错误。 @@ -86,18 +86,18 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script 除 dashboard 内嵌终端面板外,所有功能均可在 Windows 上原生运行。 -| 功能 | 原生 Windows | WSL2 | -|---|---|---| -| CLI(`hermes chat`、`hermes setup`、`hermes gateway` 等) | ✓ | ✓ | -| 交互式 TUI(`hermes --tui`) | ✓ | ✓ | -| 消息 gateway(Telegram、Discord、Slack、WhatsApp,15+ 平台) | ✓ | ✓ | -| Cron 调度器 | ✓ | ✓ | -| 浏览器工具(通过 Node 驱动 Chromium) | ✓ | ✓ | -| MCP 服务器(stdio 和 HTTP) | ✓ | ✓ | -| 本地 Ollama / LM Studio / llama-server | ✓ | ✓(通过 WSL 网络) | -| Web dashboard(会话、任务、指标、配置) | ✓ | ✓ | -| Dashboard `/chat` 内嵌终端面板 | ✗(需要 POSIX PTY) | ✓ | -| 登录时自动启动 | ✓(schtasks) | ✓(systemd) | +| 功能 | 原生 Windows | WSL2 | +| ------------------------------------------------------------ | ------------------- | ------------------ | +| CLI(`hermes chat`、`hermes setup`、`hermes gateway` 等) | ✓ | ✓ | +| 交互式 TUI(`hermes --tui`) | ✓ | ✓ | +| 消息 gateway(Telegram、Discord、Slack、WhatsApp,15+ 平台) | ✓ | ✓ | +| Cron 调度器 | ✓ | ✓ | +| 浏览器工具(通过 Node 驱动 Chromium) | ✓ | ✓ | +| MCP 服务器(stdio 和 HTTP) | ✓ | ✓ | +| 本地 Ollama / LM Studio / llama-server | ✓ | ✓(通过 WSL 网络) | +| Web dashboard(会话、任务、指标、配置) | ✓ | ✓ | +| Dashboard `/chat` 内嵌终端面板 | ✗(需要 POSIX PTY) | ✓ | +| 登录时自动启动 | ✓(schtasks) | ✓(systemd) | Dashboard 的 `/chat` 标签页通过 POSIX PTY(`ptyprocess`)内嵌了真实终端。原生 Windows 没有等效的原语;Python 的 `pywinpty` / Windows ConPTY 可以实现,但需要单独的实现——视为未来工作。**dashboard 的其余部分均可原生运行**——只有该标签页会显示"请使用 WSL2"的提示横幅。 @@ -140,12 +140,12 @@ Hermes 的 Windows stdio 垫片现在将 `EDITOR=notepad` 设为默认值。Note **用户覆盖仍然优先**(在 setdefault 之前检查): -| 编辑器 | PowerShell 命令 | -|---|---| -| VS Code | `$env:EDITOR = "code --wait"` | +| 编辑器 | PowerShell 命令 | +| --------- | ---------------------------------------------------------------------------------- | +| VS Code | `$env:EDITOR = "code --wait"` | | Notepad++ | `$env:EDITOR = "'C:\Program Files\Notepad++\notepad++.exe' -multiInst -nosession"` | -| Neovim | `$env:EDITOR = "nvim"` | -| Helix | `$env:EDITOR = "hx"` | +| Neovim | `$env:EDITOR = "nvim"` | +| Helix | `$env:EDITOR = "hx"` | VS Code 的 `--wait` 标志至关重要——没有它,编辑器会立即返回,Hermes 收到的是空缓冲区。 @@ -200,13 +200,13 @@ hermes gateway uninstall # 移除 schtasks 条目、Startup 快捷方式、pid ## 数据布局 -| 路径 | 内容 | -|---|---| +| 路径 | 内容 | +| ------------------------------------- | --------------------------------------------------------------- | | `%LOCALAPPDATA%\hermes\hermes-agent\` | Git 检出 + venv。可安全执行 `Remove-Item -Recurse` 后重新安装。 | -| `%LOCALAPPDATA%\hermes\git\` | PortableGit(仅在安装程序配置时存在)。 | -| `%LOCALAPPDATA%\hermes\node\` | 便携式 Node.js(仅在安装程序配置时存在)。 | -| `%LOCALAPPDATA%\hermes\bin\` | `hermes.cmd` 垫片,已添加到用户 PATH。 | -| `%USERPROFILE%\.hermes\` | 你的配置、认证、技能、会话、日志。**重装后保留。** | +| `%LOCALAPPDATA%\hermes\git\` | PortableGit(仅在安装程序配置时存在)。 | +| `%LOCALAPPDATA%\hermes\node\` | 便携式 Node.js(仅在安装程序配置时存在)。 | +| `%LOCALAPPDATA%\hermes\bin\` | `hermes.cmd` 垫片,已添加到用户 PATH。 | +| `%USERPROFILE%\.hermes\` | 你的配置、认证、技能、会话、日志。**重装后保留。** | 这种分离是有意为之:`%LOCALAPPDATA%\hermes` 是可丢弃的基础设施(可以删除后用一行命令恢复)。`%USERPROFILE%\.hermes` 是你的数据——配置、记忆、技能、会话历史——其结构与 Linux 安装完全相同。在机器间同步它,你的 Hermes 就随之迁移。 @@ -248,11 +248,11 @@ TELEGRAM_BOT_TOKEN=... 这些变量仅影响原生 Windows 安装: -| 变量 | 效果 | -|---|---| -| `HERMES_GIT_BASH_PATH` | 覆盖 bash.exe 的发现逻辑。可指向任意 bash——完整 Git-for-Windows、通过符号链接的 WSL bash、MSYS2、Cygwin。安装程序会自动设置此变量。 | -| `HERMES_DISABLE_WINDOWS_UTF8` | 设为 `1` 可禁用 UTF-8 stdio 垫片,回退到区域设置代码页。用于排查编码 bug。 | -| `EDITOR` / `VISUAL` | 用于 `/edit` 和 `Ctrl-X Ctrl-E` 的编辑器。如果两者均未设置,Hermes 默认使用 `notepad`。 | +| 变量 | 效果 | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `HERMES_GIT_BASH_PATH` | 覆盖 bash.exe 的发现逻辑。可指向任意 bash——完整 Git-for-Windows、通过符号链接的 WSL bash、MSYS2、Cygwin。安装程序会自动设置此变量。 | +| `HERMES_DISABLE_WINDOWS_UTF8` | 设为 `1` 可禁用 UTF-8 stdio 垫片,回退到区域设置代码页。用于排查编码 bug。 | +| `EDITOR` / `VISUAL` | 用于 `/edit` 和 `Ctrl-X Ctrl-E` 的编辑器。如果两者均未设置,Hermes 默认使用 `notepad`。 | ## 卸载 @@ -322,4 +322,4 @@ UTF-8 stdio 垫片未激活。检查 `HERMES_DISABLE_WINDOWS_UTF8` 是否**未** - **[Windows(WSL2)指南](./windows-wsl-quickstart.md)** — 如果你需要 POSIX 语义或 dashboard 终端面板。 - **[CLI 参考](../reference/cli-commands.md)** — 所有 `hermes` 子命令。 - **[FAQ](../reference/faq.md)** — 常见的非 Windows 专属问题。 -- **[消息 Gateway](./messaging/index.md)** — 在 Windows 上运行 Telegram/Discord/Slack。 \ No newline at end of file +- **[消息 Gateway](./messaging/index.md)** — 在 Windows 上运行 Telegram/Discord/Slack。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md index e428ab305a..7b108b8f89 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md @@ -100,7 +100,7 @@ wsl --shutdown 打开 WSL2 shell 后执行: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash source ~/.bashrc hermes ```