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
This commit is contained in:
127
api/models.py
127
api/models.py
@@ -192,3 +192,130 @@ def load_projects():
|
|||||||
def save_projects(projects):
|
def save_projects(projects):
|
||||||
"""Write project list to disk."""
|
"""Write project list to disk."""
|
||||||
PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8')
|
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
|
||||||
|
|||||||
101
api/routes.py
101
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 (
|
from api.models import (
|
||||||
Session, get_session, new_session, all_sessions, title_from,
|
Session, get_session, new_session, all_sessions, title_from,
|
||||||
_write_session_index, SESSION_INDEX_FILE,
|
_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 (
|
from api.workspace import (
|
||||||
load_workspaces, save_workspaces, get_last_workspace, set_last_workspace,
|
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]
|
sid = parse_qs(parsed.query).get('session_id', [''])[0]
|
||||||
if not sid:
|
if not sid:
|
||||||
return j(handler, {'error': 'session_id is required'}, status=400)
|
return j(handler, {'error': 'session_id is required'}, status=400)
|
||||||
s = get_session(sid)
|
try:
|
||||||
return j(handler, {'session': s.compact() | {
|
s = get_session(sid)
|
||||||
'messages': s.messages,
|
return j(handler, {'session': s.compact() | {
|
||||||
'tool_calls': getattr(s, 'tool_calls', []),
|
'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':
|
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':
|
if parsed.path == '/api/projects':
|
||||||
return j(handler, {'projects': load_projects()})
|
return j(handler, {'projects': load_projects()})
|
||||||
@@ -542,6 +578,10 @@ def handle_post(handler, parsed):
|
|||||||
if parsed.path == '/api/session/import':
|
if parsed.path == '/api/session/import':
|
||||||
return _handle_session_import(handler, body)
|
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) ──
|
# ── Auth endpoints (POST) ──
|
||||||
if parsed.path == '/api/auth/login':
|
if parsed.path == '/api/auth/login':
|
||||||
from api.auth import verify_password, create_session, set_auth_cookie, is_auth_enabled
|
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)})
|
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):
|
def _handle_session_import(handler, body):
|
||||||
"""Import a session from a JSON export. Creates a new session with a new ID."""
|
"""Import a session from a JSON export. Creates a new session with a new ID."""
|
||||||
if not body or not isinstance(body, dict):
|
if not body or not isinstance(body, dict):
|
||||||
|
|||||||
@@ -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;}
|
.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;}
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user