Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 233 additions & 1 deletion api/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Hermes Web UI -- Session model and in-memory session store."""
import collections
import datetime
import hashlib
import json
import logging
import os
Expand Down Expand Up @@ -1231,6 +1233,230 @@ def import_cli_session(

# ── CLI session bridge ──────────────────────────────────────────────────────

CLAUDE_CODE_SOURCE = 'claude_code'
CLAUDE_CODE_SOURCE_LABEL = 'Claude Code'
CLAUDE_CODE_MAX_FILES = 200
CLAUDE_CODE_MAX_FILE_BYTES = 10 * 1024 * 1024
CLAUDE_CODE_MAX_MESSAGES_PER_FILE = 1000
CLAUDE_CODE_MAX_CONTENT_CHARS = 200_000


def _default_claude_code_projects_dir() -> Path | None:
"""Resolve the Claude Code projects directory without touching real home in tests."""
override = os.getenv('HERMES_WEBUI_CLAUDE_PROJECTS_DIR')
if override:
return Path(override).expanduser()
if os.getenv('HERMES_WEBUI_TEST_STATE_DIR'):
return None
return Path.home() / '.claude' / 'projects'


def _claude_code_session_id(path: Path) -> str:
digest = hashlib.sha256(str(path.expanduser().resolve()).encode('utf-8')).hexdigest()[:24]
return f'{CLAUDE_CODE_SOURCE}_{digest}'


def _parse_claude_code_timestamp(value):
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return None
try:
return float(text)
except ValueError:
pass
try:
return datetime.datetime.fromisoformat(text.replace('Z', '+00:00')).timestamp()
except Exception:
return None


def _extract_claude_code_text(content) -> str:
if content is None:
return ''
if isinstance(content, str):
return content[:CLAUDE_CODE_MAX_CONTENT_CHARS]
if isinstance(content, list):
parts = []
used = 0
for item in content:
text = ''
if isinstance(item, str):
text = item
elif isinstance(item, dict):
text = item.get('text') or item.get('content') or ''
if not text:
continue
text = str(text)
remaining = CLAUDE_CODE_MAX_CONTENT_CHARS - used
if remaining <= 0:
break
parts.append(text[:remaining])
used += len(parts[-1])
return '\n'.join(parts)
if isinstance(content, dict):
return _extract_claude_code_text(content.get('text') or content.get('content'))
return str(content)[:CLAUDE_CODE_MAX_CONTENT_CHARS]


def _parse_claude_code_jsonl(path: Path, *, max_messages: int = CLAUDE_CODE_MAX_MESSAGES_PER_FILE) -> tuple[list[dict], str | None, float | None, float | None]:
messages: list[dict] = []
summary_title = None
first_ts = None
last_ts = None
try:
with path.open('r', encoding='utf-8', errors='replace') as fh:
for line in fh:
if len(messages) >= max_messages:
break
line = line.strip()
if not line:
continue
try:
raw = json.loads(line)
except Exception:
continue
if not isinstance(raw, dict):
continue
if not summary_title:
summary = raw.get('summary') or raw.get('title')
if isinstance(summary, str) and summary.strip():
summary_title = ' '.join(summary.split())[:80]
records = raw.get('messages') if isinstance(raw.get('messages'), list) else None
if records is None:
records = [raw.get('message') if isinstance(raw.get('message'), dict) else raw]
for record in records:
if len(messages) >= max_messages:
break
if not isinstance(record, dict):
continue
msg = record.get('message') if isinstance(record.get('message'), dict) else record
role = str(msg.get('role') or record.get('role') or raw.get('role') or raw.get('type') or '').strip().lower()
if role == 'human':
role = 'user'
if role not in {'user', 'assistant', 'system', 'tool'}:
continue
content = _extract_claude_code_text(msg.get('content') if 'content' in msg else record.get('content'))
if not content.strip():
continue
ts = _parse_claude_code_timestamp(
msg.get('timestamp')
or record.get('timestamp')
or raw.get('timestamp')
or raw.get('created_at')
)
if ts is not None:
first_ts = ts if first_ts is None else min(first_ts, ts)
last_ts = ts if last_ts is None else max(last_ts, ts)
item = {'role': role, 'content': content}
if ts is not None:
item['timestamp'] = ts
messages.append(item)
except Exception:
return [], None, None, None
return messages, summary_title, first_ts, last_ts


def _iter_claude_code_jsonl_files(projects_dir: Path | str | None = None, *, max_files: int = CLAUDE_CODE_MAX_FILES, max_file_bytes: int = CLAUDE_CODE_MAX_FILE_BYTES):
root = Path(projects_dir).expanduser() if projects_dir is not None else _default_claude_code_projects_dir()
if root is None:
return
try:
if root.is_symlink():
return
root = root.resolve(strict=False)
if not root.exists() or not root.is_dir():
return
yielded = 0
for project_dir in sorted(root.iterdir(), key=lambda p: p.name):
if yielded >= max_files:
return
try:
if project_dir.is_symlink() or not project_dir.is_dir():
continue
for path in sorted(project_dir.iterdir(), key=lambda p: p.name):
if yielded >= max_files:
return
if path.is_symlink() or not path.is_file() or path.suffix.lower() != '.jsonl':
continue
try:
if path.stat().st_size > max_file_bytes:
continue
except OSError:
continue
yielded += 1
yield path
except OSError:
continue
except OSError:
return


def _claude_code_title(messages: list[dict], summary_title: str | None) -> str:
if summary_title:
return summary_title
for msg in messages:
if msg.get('role') == 'user':
text = ' '.join(str(msg.get('content') or '').split())
if text:
return text[:80]
return 'Claude Code Session'


def get_claude_code_sessions(projects_dir: Path | str | None = None, *, max_files: int = CLAUDE_CODE_MAX_FILES, max_file_bytes: int = CLAUDE_CODE_MAX_FILE_BYTES) -> list:
"""Read Claude Code JSONL sessions as read-only external-agent rows.

The bridge is additive and defensive: it skips symlinks, oversized files,
malformed lines, and per-file errors rather than crashing WebUI session
listing. Tests pass ``projects_dir`` fixtures so Michael's real ~/.claude is
never read during test runs.
"""
sessions = []
for path in _iter_claude_code_jsonl_files(projects_dir, max_files=max_files, max_file_bytes=max_file_bytes) or []:
messages, summary_title, first_ts, last_ts = _parse_claude_code_jsonl(path)
if not messages:
continue
sid = _claude_code_session_id(path)
sessions.append({
'session_id': sid,
'title': _claude_code_title(messages, summary_title),
'workspace': str(get_last_workspace()),
'model': 'claude-code',
'message_count': len(messages),
'created_at': first_ts or last_ts or path.stat().st_mtime,
'updated_at': last_ts or first_ts or path.stat().st_mtime,
'last_message_at': last_ts or first_ts or path.stat().st_mtime,
'pinned': False,
'archived': False,
'project_id': None,
'profile': None,
'source_tag': CLAUDE_CODE_SOURCE,
'raw_source': CLAUDE_CODE_SOURCE,
'session_source': 'external_agent',
'source_label': CLAUDE_CODE_SOURCE_LABEL,
'is_cli_session': True,
'read_only': True,
})
sessions.sort(key=lambda s: s.get('last_message_at') or s.get('updated_at') or 0, reverse=True)
return sessions


def get_claude_code_session_messages(sid, projects_dir: Path | str | None = None) -> list:
"""Return messages for one read-only Claude Code JSONL session."""
sid = str(sid or '')
if not sid.startswith(f'{CLAUDE_CODE_SOURCE}_'):
return []
for path in _iter_claude_code_jsonl_files(projects_dir) or []:
if _claude_code_session_id(path) != sid:
continue
messages, _summary_title, _first_ts, _last_ts = _parse_claude_code_jsonl(path)
return messages
return []


def get_cli_sessions() -> list:
"""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.
Expand All @@ -1240,6 +1466,10 @@ def get_cli_sessions() -> list:
"""
import os
cli_sessions = []
try:
cli_sessions.extend(get_claude_code_sessions())
except Exception:
logger.debug("Claude Code session scan failed", exc_info=True)

# Use the active WebUI profile's HERMES_HOME to find state.db.
# The active profile is determined by what the user has selected in the UI
Expand Down Expand Up @@ -1362,11 +1592,13 @@ def _cron_pid():


def get_cli_session_messages(sid) -> list:
"""Read messages for a single CLI session from the SQLite store.
"""Read messages for a single CLI/external-agent session.
Returns a list of {role, content, timestamp} dicts.
Returns empty list on any error.
"""
import os
if str(sid or '').startswith(f'{CLAUDE_CODE_SOURCE}_'):
return get_claude_code_session_messages(sid)
try:
import sqlite3
except ImportError:
Expand Down
34 changes: 34 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2122,6 +2122,7 @@ def handle_get(handler, parsed) -> bool:
"raw_source": (cli_meta or {}).get("raw_source"),
"session_source": (cli_meta or {}).get("session_source"),
"source_label": (cli_meta or {}).get("source_label"),
"read_only": bool((cli_meta or {}).get("read_only")),
"messages": msgs,
"tool_calls": [],
}
Expand Down Expand Up @@ -2908,6 +2909,9 @@ def handle_post(handler, parsed) -> bool:
return bad(handler, "session_id is required")
if not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid):
return bad(handler, "Invalid session_id", 400)
cli_meta_for_delete = _lookup_cli_session_metadata(sid)
if cli_meta_for_delete.get("read_only"):
return bad(handler, "Read-only imported sessions cannot be deleted from WebUI", 400)
is_messaging_session = _is_messaging_session_id(sid)
# Delete from WebUI session store
with LOCK:
Expand Down Expand Up @@ -3509,6 +3513,8 @@ def handle_post(handler, parsed) -> bool:
cli_meta = _lookup_cli_session_metadata(sid)
if not cli_meta:
return bad(handler, "Session not found", 404)
if cli_meta.get("read_only"):
return bad(handler, "Read-only imported sessions cannot be archived from WebUI", 400)
if _is_messaging_session_record(cli_meta):
s = Session(
session_id=sid,
Expand Down Expand Up @@ -6997,6 +7003,7 @@ def _handle_session_import_cli(handler, body):
| {
"messages": existing.messages,
"is_cli_session": True,
"read_only": bool((cli_meta or {}).get("read_only")),
},
"imported": False,
},
Expand All @@ -7023,6 +7030,7 @@ def _handle_session_import_cli(handler, body):
cli_thread_id = None
cli_session_key = None
cli_platform = None
cli_read_only = False
for cs in get_cli_sessions():
if cs["session_id"] == sid:
profile = cs.get("profile")
Expand All @@ -7040,6 +7048,7 @@ def _handle_session_import_cli(handler, body):
cli_thread_id = cs.get("thread_id")
cli_session_key = cs.get("session_key")
cli_platform = cs.get("platform")
cli_read_only = bool(cs.get("read_only"))
break

# Use the CLI session title if available (e.g., cron job name), otherwise derive from messages
Expand All @@ -7050,6 +7059,31 @@ def _handle_session_import_cli(handler, body):
if is_cron_session(sid, cli_source_tag):
cron_project_id = ensure_cron_project()

if cli_read_only:
session_payload = {
"session_id": sid,
"title": title,
"workspace": str(get_last_workspace()),
"model": model,
"message_count": len(msgs),
"created_at": created_at,
"updated_at": updated_at,
"last_message_at": updated_at or created_at,
"pinned": False,
"archived": False,
"project_id": None,
"profile": profile,
"is_cli_session": True,
"source_tag": cli_source_tag,
"raw_source": cli_raw_source or cli_source_tag,
"session_source": cli_session_source,
"source_label": cli_source_label,
"read_only": True,
"messages": msgs,
"tool_calls": [],
}
return j(handler, {"session": session_payload, "imported": False})

s = import_cli_session(
sid,
title,
Expand Down
Binary file added docs/pr-media/674/claude-code-import-readonly.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ async function send(){
}
return;
}
if(S.session&&(S.session.read_only||S.session.is_read_only)){
if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000);
return;
}
// Slash command intercept -- local commands handled without agent round-trip.
// We push the user message BEFORE running the handler for echo-worthy
// commands so chat order is correct: some handlers (e.g. cmdHelp) push
Expand Down
16 changes: 14 additions & 2 deletions static/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ function syncAppTitlebar() {
const panel = (typeof _currentPanel === 'string' && _currentPanel) ? _currentPanel : 'chat';
let mainText = '';
let subText = '';
let sourceLabel = '';
if (panel === 'chat' && typeof S !== 'undefined' && S && S.session) {
mainText = S.session.title || (typeof t === 'function' ? t('untitled') : 'Untitled');
const vis = Array.isArray(S.messages) ? S.messages.filter(m => m && m.role && m.role !== 'tool') : [];
if (typeof t === 'function') subText = t('n_messages', vis.length);
if (S.session.is_cli_session) sourceLabel = S.session.source_label || S.session.source_tag || S.session.raw_source || '';
} else {
const key = APP_TITLEBAR_KEYS[panel];
mainText = key && typeof t === 'function' ? t(key) : (panel.charAt(0).toUpperCase() + panel.slice(1));
Expand All @@ -47,15 +49,25 @@ function syncAppTitlebar() {

titleEl.textContent = mainText;
if (subEl) {
if (subText) { subEl.textContent = subText; subEl.hidden = false; }
if (subText) {
subEl.textContent = subText;
if (sourceLabel) {
const badge = document.createElement('span');
badge.className = 'topbar-source-badge';
badge.textContent = sourceLabel + (S.session && S.session.read_only ? ' · read-only' : '');
subEl.appendChild(document.createTextNode(' '));
subEl.appendChild(badge);
}
subEl.hidden = false;
}
else { subEl.textContent = ''; subEl.hidden = true; }
}

// Double-click on the titlebar title → rename the active session (same behaviour
// as double-clicking a session title in the sidebar). Only active on the chat
// panel when a session is open.
titleEl.ondblclick = null; // remove any previous handler before adding a fresh one
if (panel === 'chat' && typeof S !== 'undefined' && S && S.session) {
if (panel === 'chat' && typeof S !== 'undefined' && S && S.session && !(S.session.read_only || S.session.is_read_only)) {
titleEl.ondblclick = (e) => {
e.stopPropagation();
e.preventDefault();
Expand Down
Loading
Loading