From cabda6b77aca4511b6b3d81d20bc5c8a8977cf8c Mon Sep 17 00:00:00 2001 From: Thad Reber Date: Fri, 3 Apr 2026 19:54:54 -0700 Subject: [PATCH] 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; +}