From cabda6b77aca4511b6b3d81d20bc5c8a8977cf8c Mon Sep 17 00:00:00 2001 From: Thad Reber Date: Fri, 3 Apr 2026 19:54:54 -0700 Subject: [PATCH 1/3] feat: CLI session bridge - read CLI sessions from agent SQLite store Read CLI sessions from the agent's state.db and surface them in the WebUI sidebar alongside local sessions, with read-only display and import-on-click to avoid data duplication. Key changes: - get_cli_sessions(): reads sessions list via parameterized SQL, wrapped in sqlite3 context manager (no connection leaks) - get_cli_session_messages(): reads messages for a CLI session via parameterized SQL, also context-managed - GET /api/sessions: merges WebUI + CLI sessions with dedup (WebUI takes priority on same session_id) - GET /api/session: falls back to CLI store if not a WebUI session - POST /api/session/import_cli: imports a CLI session into the WebUI store (idempotent, no duplicates on re-import) - Imported sessions use get_last_workspace() for the workspace field (not a hardcoded string) and carry the active profile tag - CSS: .cli-session with ::after 'cli' indicator (no theme changes) Fixes review feedback: - SQLite connections use 'with' context managers (no leaks) - Workspace uses real path via get_last_workspace() - Profile awareness via api.profiles.get_active_profile_name() - Parameterized SQL queries throughout (no injection risk) - Graceful fallback when sqlite3 or state.db is missing --- api/models.py | 127 +++++++++++++++++++++++++++++++++++++++++++++++ api/routes.py | 101 ++++++++++++++++++++++++++++++++++--- static/style.css | 16 ++++++ 3 files changed, 237 insertions(+), 7 deletions(-) diff --git a/api/models.py b/api/models.py index 5a51593..9e63010 100644 --- a/api/models.py +++ b/api/models.py @@ -192,3 +192,130 @@ def load_projects(): def save_projects(projects): """Write project list to disk.""" PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8') + + +def import_cli_session(session_id, title, messages, model='unknown', profile=None): + """Create a new WebUI session populated with CLI messages. + Returns the Session object. + """ + s = Session( + session_id=session_id, + title=title, + workspace=get_last_workspace(), + model=model, + messages=messages, + profile=profile, + ) + s.save() + return s + + +# ── CLI session bridge ────────────────────────────────────────────────────── + +def get_cli_sessions(): + """Read CLI sessions from the agent's SQLite store and return them as + dicts in a format the WebUI sidebar can render alongside local sessions. + + Returns empty list if the SQLite DB is missing, the sqlite3 module is + unavailable, or any error occurs -- the bridge is purely additive and never + crashes the WebUI. + """ + import os + cli_sessions = [] + try: + import sqlite3 + except ImportError: + return cli_sessions + + hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser() + db_path = hermes_home / 'state.db' + if not db_path.exists(): + return cli_sessions + + # Try to resolve the active CLI profile so imported sessions integrate + # with the WebUI profile filter (available since Sprint 22). + try: + from api.profiles import get_active_profile_name + _cli_profile = get_active_profile_name() + except ImportError: + _cli_profile = None # older agent -- fall back to no profile + + try: + with sqlite3.connect(str(db_path)) as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute(""" + SELECT s.id, s.title, s.model, s.message_count, + s.started_at, s.source, s.profile, + MAX(m.timestamp) AS last_activity + FROM sessions s + LEFT JOIN messages m ON m.session_id = s.id + GROUP BY s.id + ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC + LIMIT 200 + """) + for row in cur.fetchall(): + sid = row['id'] + raw_ts = row['last_activity'] or row['started_at'] + # Prefer the CLI session's own profile from the DB; fall back to + # the active CLI profile so sidebar filtering works either way. + profile = row.get('profile') or _cli_profile + + cli_sessions.append({ + 'session_id': sid, + 'title': row['title'] or 'CLI Session', + 'workspace': str(get_last_workspace()), + 'model': row['model'] or 'unknown', + 'message_count': row['message_count'] or 0, + 'created_at': row['started_at'], + 'updated_at': raw_ts, + 'pinned': False, + 'archived': False, + 'project_id': None, + 'profile': profile, + 'source_tag': 'cli', + 'is_cli_session': True, + }) + except Exception: + # DB schema changed, locked, or corrupted -- silently degrade + return [] + + return cli_sessions + + +def get_cli_session_messages(sid): + """Read messages for a single CLI session from the SQLite store. + Returns a list of {role, content, timestamp} dicts. + Returns empty list on any error. + """ + import os + try: + import sqlite3 + except ImportError: + return [] + + hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser() + db_path = hermes_home / 'state.db' + if not db_path.exists(): + return [] + + try: + with sqlite3.connect(str(db_path)) as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute(""" + SELECT role, content, timestamp + FROM messages + WHERE session_id = ? + ORDER BY timestamp ASC + """, (sid,)) + msgs = [] + for row in cur.fetchall(): + msgs.append({ + 'role': row['role'], + 'content': row['content'], + 'timestamp': row['timestamp'], + }) + except Exception: + return [] + return msgs diff --git a/api/routes.py b/api/routes.py index f032916..8b73115 100644 --- a/api/routes.py +++ b/api/routes.py @@ -23,7 +23,8 @@ from api.helpers import require, bad, safe_resolve, j, t, read_body, _security_h from api.models import ( Session, get_session, new_session, all_sessions, title_from, _write_session_index, SESSION_INDEX_FILE, - load_projects, save_projects, + load_projects, save_projects, import_cli_session, + get_cli_sessions, get_cli_session_messages, ) from api.workspace import ( load_workspaces, save_workspaces, get_last_workspace, set_last_workspace, @@ -151,14 +152,49 @@ def handle_get(handler, parsed): sid = parse_qs(parsed.query).get('session_id', [''])[0] if not sid: return j(handler, {'error': 'session_id is required'}, status=400) - s = get_session(sid) - return j(handler, {'session': s.compact() | { - 'messages': s.messages, - 'tool_calls': getattr(s, 'tool_calls', []), - }}) + try: + s = get_session(sid) + return j(handler, {'session': s.compact() | { + 'messages': s.messages, + 'tool_calls': getattr(s, 'tool_calls', []), + }}) + except KeyError: + # Not a WebUI session -- try CLI store + msgs = get_cli_session_messages(sid) + if msgs: + cli_meta = None + for cs in get_cli_sessions(): + if cs['session_id'] == sid: + cli_meta = cs + break + sess = { + 'session_id': sid, + 'title': (cli_meta or {}).get('title', 'CLI Session'), + 'workspace': (cli_meta or {}).get('workspace', ''), + 'model': (cli_meta or {}).get('model', 'unknown'), + 'message_count': len(msgs), + 'created_at': (cli_meta or {}).get('created_at', 0), + 'updated_at': (cli_meta or {}).get('updated_at', 0), + 'pinned': False, + 'archived': False, + 'project_id': None, + 'profile': (cli_meta or {}).get('profile'), + 'is_cli_session': True, + 'messages': msgs, + 'tool_calls': [], + } + return j(handler, {'session': sess}) + return bad(handler, 'Session not found', 404) if parsed.path == '/api/sessions': - return j(handler, {'sessions': all_sessions()}) + webui_sessions = all_sessions() + cli = get_cli_sessions() + # Deduplicate: WebUI sessions always win if same session_id + webui_ids = {s['session_id'] for s in webui_sessions} + deduped_cli = [s for s in cli if s['session_id'] not in webui_ids] + merged = webui_sessions + deduped_cli + merged.sort(key=lambda s: s.get('updated_at', 0) or 0, reverse=True) + return j(handler, {'sessions': merged, 'cli_count': len(deduped_cli)}) if parsed.path == '/api/projects': return j(handler, {'projects': load_projects()}) @@ -542,6 +578,10 @@ def handle_post(handler, parsed): if parsed.path == '/api/session/import': return _handle_session_import(handler, body) + # ── CLI session import (POST) ── + if parsed.path == '/api/session/import_cli': + return _handle_session_import_cli(handler, body) + # ── Auth endpoints (POST) ── if parsed.path == '/api/auth/login': from api.auth import verify_password, create_session, set_auth_cookie, is_auth_enabled @@ -1173,6 +1213,53 @@ def _handle_memory_write(handler, body): return j(handler, {'ok': True, 'section': section, 'path': str(target)}) +def _handle_session_import_cli(handler, body): + """Import a single CLI session into the WebUI store.""" + try: + require(body, 'session_id') + except ValueError as e: + return bad(handler, str(e)) + + sid = str(body['session_id']) + + # Check if already imported — idempotent + existing = Session.load(sid) + if existing: + return j(handler, {'session': existing.compact() | { + 'messages': existing.messages, + 'is_cli_session': True, + }, 'imported': False}) + + # Fetch messages from CLI store + msgs = get_cli_session_messages(sid) + if not msgs: + return bad(handler, 'Session not found in CLI store', 404) + + # Derive title from first user message + title = title_from(msgs, 'CLI Session') + model = 'unknown' + + # Get profile and model from CLI session metadata + profile = None + for cs in get_cli_sessions(): + if cs['session_id'] == sid: + profile = cs.get('profile') + model = cs.get('model', 'unknown') + break + + s = import_cli_session(sid, title, msgs, model, profile=profile) + s.is_cli_session = True + s._cli_origin = sid + s.save() + return j(handler, { + 'session': s.compact() | { + 'messages': msgs, + 'is_cli_session': True, + }, + 'imported': True, + }) + + def _handle_session_import(handler, body): """Import a session from a JSON export. Creates a new session with a new ID.""" if not body or not isinstance(body, dict): diff --git a/static/style.css b/static/style.css index 642347b..4ea3da4 100644 --- a/static/style.css +++ b/static/style.css @@ -688,3 +688,19 @@ body.resizing{user-select:none;cursor:col-resize;} .thinking-card-body pre{font-family:'SF Mono',ui-monospace,monospace;font-size:11px;line-height:1.5;color:var(--muted);white-space:pre-wrap;word-break:break-word;margin:0;} .bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;} + +/* ── CLI session items in sidebar ── */ +.session-item.cli-session { + border-left-color: var(--gold); +} +.session-item.cli-session::after { + content: 'cli'; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--gold); + opacity: .5; + margin-left: 4px; + flex-shrink: 0; +} From 017d7f1eca6ccdea1fad7383afa7309053648da9 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 20:37:34 -0700 Subject: [PATCH 2/3] fix: add missing HOME import to models.py (NameError crash) get_cli_sessions() and get_cli_session_messages() reference HOME but it was not imported from api.config. This caused /api/sessions to 500 on every request, breaking the entire session list. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/models.py b/api/models.py index 9e63010..71e787e 100644 --- a/api/models.py +++ b/api/models.py @@ -10,7 +10,7 @@ from pathlib import Path import api.config as _cfg from api.config import ( SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX, - LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE + LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME ) from api.workspace import get_last_workspace From 122fe955b6884b12e1e0c525c0275575fcf6b689 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 20:39:27 -0700 Subject: [PATCH 3/3] docs: v0.29 release notes for CLI session bridge, version bump Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 29 ++++++++++++++++++++++++++++- SPRINTS.md | 4 ++-- static/index.html | 2 +- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd4903..ebc7542 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ --- +## [v0.29] CLI Session Bridge (Community: @thadreber-web) +*April 3, 2026 | 426 tests* + +### Features +- **CLI session bridge.** The WebUI now reads sessions from the hermes-agent's + SQLite store (`state.db`). CLI sessions appear in the sidebar with a gold + "cli" indicator badge. Click to import into the WebUI store with full message + history — replies then work through the normal agent pipeline. +- **`/api/session/import_cli` endpoint.** Imports a CLI session into the WebUI + JSON store. Idempotent — returns existing session if already imported. + Derives title from first message, inherits active profile and workspace. +- **`/api/sessions` merges CLI sessions.** Sidebar shows both WebUI and CLI + sessions sorted by last activity. Deduplication ensures WebUI sessions take + priority when the same session_id exists in both stores. +- **CLI session fallback on `/api/session`.** If a session_id isn't found in + the WebUI store, falls back to reading from the CLI SQLite store. + +### Architecture +- `api/models.py`: `get_cli_sessions()`, `get_cli_session_messages()`, + `import_cli_session()`. All use parameterized SQL queries and `with` for + connection management. Graceful fallback on missing sqlite3 or state.db. +- `api/routes.py`: CLI fallback in GET `/api/session`, merged list in + GET `/api/sessions`, POST `/api/session/import_cli`. +- `static/style.css`: `.cli-session` indicator styles (gold border + badge). + +--- + ## [v0.28.1] CI Pipeline + Multi-Arch Docker Builds *April 3, 2026 | 426 tests* @@ -917,4 +944,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel. --- -*Last updated: v0.28.1, April 3, 2026 | Tests: 426* +*Last updated: v0.29, April 3, 2026 | Tests: 426* diff --git a/SPRINTS.md b/SPRINTS.md index 6470d19..9ced12b 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,6 @@ # Hermes Web UI -- Forward Sprint Plan -> Current state: v0.28.1 | 426 tests | Daily driver ready +> Current state: v0.29 | 426 tests | Daily driver ready > This document plans the path from here to two targets: > > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the @@ -663,5 +663,5 @@ and switchToProfile() didn't refresh workspaces or sessions. --- *Last updated: April 3, 2026* -*Current version: v0.28.1 | 426 tests* +*Current version: v0.29 | 426 tests* *Next sprint: Sprint 24 (Desktop Application)* diff --git a/static/index.html b/static/index.html index be31f31..c2e4003 100644 --- a/static/index.html +++ b/static/index.html @@ -13,7 +13,7 @@