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):
|
||||
"""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
|
||||
|
||||
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 (
|
||||
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):
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user