Files
webui/api/routes.py
deboste 2864c2b691 fix(api): resolve model provider from config to prevent misrouting
When the model dropdown sends a prefixed ID like "anthropic/claude-xxx",
AIAgent interprets the provider/model format as an OpenRouter path and
routes through OpenRouter instead of the direct Anthropic API.

Fix: read the configured provider from config.yaml model section. If
the model ID starts with the configured provider name followed by "/",
strip that prefix and pass the provider explicitly to AIAgent. This
ensures direct API providers (Anthropic, OpenAI, etc.) are used when
configured, regardless of the model ID format from the dropdown.
2026-03-31 15:00:43 +00:00

941 lines
38 KiB
Python

"""
Hermes Web UI -- Route handlers for GET and POST endpoints.
Extracted from server.py (Sprint 11) so server.py is a thin shell.
"""
import json
import os
import queue
import sys
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import parse_qs
from api.config import (
STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE, DEFAULT_MODEL,
SESSIONS, SESSIONS_MAX, LOCK, STREAMS, STREAMS_LOCK, CANCEL_FLAGS,
SERVER_START_TIME, CLI_TOOLSETS, _INDEX_HTML_PATH, get_available_models,
IMAGE_EXTS, MD_EXTS, MIME_MAP, MAX_FILE_BYTES, MAX_UPLOAD_BYTES,
CHAT_LOCK, load_settings, save_settings,
)
from api.helpers import require, bad, safe_resolve, j, t, read_body
from api.models import (
Session, get_session, new_session, all_sessions, title_from,
_write_session_index, SESSION_INDEX_FILE,
)
from api.workspace import (
load_workspaces, save_workspaces, get_last_workspace, set_last_workspace,
list_dir, read_file_content, safe_resolve_ws,
)
from api.upload import handle_upload
from api.streaming import _sse, _run_agent_streaming, cancel_stream
# Approval system (optional -- graceful fallback if agent not available)
try:
from tools.approval import (
has_pending, pop_pending, submit_pending,
approve_session, approve_permanent, save_permanent_allowlist,
is_approved, _pending, _lock, _permanent_approved,
)
except ImportError:
has_pending = lambda *a, **k: False
pop_pending = lambda *a, **k: None
submit_pending = lambda *a, **k: None
approve_session = lambda *a, **k: None
approve_permanent = lambda *a, **k: None
save_permanent_allowlist = lambda *a, **k: None
is_approved = lambda *a, **k: True
_pending = {}
_lock = threading.Lock()
_permanent_approved = set()
# ── GET routes ────────────────────────────────────────────────────────────────
def handle_get(handler, parsed):
"""Handle all GET routes. Returns True if handled, False for 404."""
if parsed.path in ('/', '/index.html'):
return t(handler, _INDEX_HTML_PATH.read_text(encoding='utf-8'),
content_type='text/html; charset=utf-8')
if parsed.path == '/favicon.ico':
handler.send_response(204); handler.end_headers(); return True
if parsed.path == '/health':
with STREAMS_LOCK: n_streams = len(STREAMS)
return j(handler, {
'status': 'ok', 'sessions': len(SESSIONS),
'active_streams': n_streams,
'uptime_seconds': round(time.time() - SERVER_START_TIME, 1),
})
if parsed.path == '/api/models':
return j(handler, get_available_models())
if parsed.path == '/api/settings':
return j(handler, load_settings())
if parsed.path.startswith('/static/'):
return _serve_static(handler, parsed)
if parsed.path == '/api/session':
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', []),
}})
if parsed.path == '/api/sessions':
return j(handler, {'sessions': all_sessions()})
if parsed.path == '/api/session/export':
return _handle_session_export(handler, parsed)
if parsed.path == '/api/workspaces':
return j(handler, {'workspaces': load_workspaces(), 'last': get_last_workspace()})
if parsed.path == '/api/sessions/search':
return _handle_sessions_search(handler, parsed)
if parsed.path == '/api/list':
return _handle_list_dir(handler, parsed)
if parsed.path == '/api/chat/stream/status':
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id})
if parsed.path == '/api/chat/cancel':
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
if not stream_id:
return bad(handler, 'stream_id required')
cancelled = cancel_stream(stream_id)
return j(handler, {'ok': True, 'cancelled': cancelled, 'stream_id': stream_id})
if parsed.path == '/api/chat/stream':
return _handle_sse_stream(handler, parsed)
if parsed.path == '/api/file/raw':
return _handle_file_raw(handler, parsed)
if parsed.path == '/api/file':
return _handle_file_read(handler, parsed)
if parsed.path == '/api/approval/pending':
return _handle_approval_pending(handler, parsed)
if parsed.path == '/api/approval/inject_test':
return _handle_approval_inject(handler, parsed)
# ── Cron API (GET) ──
if parsed.path == '/api/crons':
sys.path.insert(0, str(Path(__file__).parent.parent))
from cron.jobs import list_jobs
return j(handler, {'jobs': list_jobs(include_disabled=True)})
if parsed.path == '/api/crons/output':
return _handle_cron_output(handler, parsed)
if parsed.path == '/api/crons/recent':
return _handle_cron_recent(handler, parsed)
# ── Skills API (GET) ──
if parsed.path == '/api/skills':
from tools.skills_tool import skills_list as _skills_list
raw = _skills_list()
data = json.loads(raw) if isinstance(raw, str) else raw
return j(handler, {'skills': data.get('skills', [])})
if parsed.path == '/api/skills/content':
from tools.skills_tool import skill_view as _skill_view
name = parse_qs(parsed.query).get('name', [''])[0]
if not name: return j(handler, {'error': 'name required'}, status=400)
raw = _skill_view(name)
data = json.loads(raw) if isinstance(raw, str) else raw
return j(handler, data)
# ── Memory API (GET) ──
if parsed.path == '/api/memory':
return _handle_memory_read(handler)
return False # 404
# ── POST routes ───────────────────────────────────────────────────────────────
def handle_post(handler, parsed):
"""Handle all POST routes. Returns True if handled, False for 404."""
if parsed.path == '/api/upload':
return handle_upload(handler)
body = read_body(handler)
if parsed.path == '/api/session/new':
s = new_session(workspace=body.get('workspace'), model=body.get('model'))
return j(handler, {'session': s.compact() | {'messages': s.messages}})
if parsed.path == '/api/sessions/cleanup':
return _handle_sessions_cleanup(handler, body, zero_only=False)
if parsed.path == '/api/sessions/cleanup_zero_message':
return _handle_sessions_cleanup(handler, body, zero_only=True)
if parsed.path == '/api/session/rename':
try: require(body, 'session_id', 'title')
except ValueError as e: return bad(handler, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
s.title = str(body['title']).strip()[:80] or 'Untitled'
s.save()
return j(handler, {'session': s.compact()})
if parsed.path == '/api/session/update':
try: require(body, 'session_id')
except ValueError as e: return bad(handler, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
new_ws = str(Path(body.get('workspace', s.workspace)).expanduser().resolve())
s.workspace = new_ws; s.model = body.get('model', s.model); s.save()
set_last_workspace(new_ws)
return j(handler, {'session': s.compact() | {'messages': s.messages}})
if parsed.path == '/api/session/delete':
sid = body.get('session_id', '')
if not sid: return bad(handler, 'session_id is required')
with LOCK: SESSIONS.pop(sid, None)
p = SESSION_DIR / f'{sid}.json'
try: p.unlink(missing_ok=True)
except Exception: pass
try: SESSION_INDEX_FILE.unlink(missing_ok=True)
except Exception: pass
return j(handler, {'ok': True})
if parsed.path == '/api/session/clear':
try: require(body, 'session_id')
except ValueError as e: return bad(handler, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
s.messages = []; s.tool_calls = []; s.title = 'Untitled'; s.save()
return j(handler, {'ok': True, 'session': s.compact()})
if parsed.path == '/api/session/truncate':
try: require(body, 'session_id')
except ValueError as e: return bad(handler, str(e))
if body.get('keep_count') is None:
return bad(handler, 'Missing required field(s): keep_count')
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
keep = int(body['keep_count'])
s.messages = s.messages[:keep]; s.save()
return j(handler, {'ok': True, 'session': s.compact() | {'messages': s.messages}})
if parsed.path == '/api/chat/start':
return _handle_chat_start(handler, body)
if parsed.path == '/api/chat':
return _handle_chat_sync(handler, body)
# ── Cron API (POST) ──
if parsed.path == '/api/crons/create':
return _handle_cron_create(handler, body)
if parsed.path == '/api/crons/update':
return _handle_cron_update(handler, body)
if parsed.path == '/api/crons/delete':
return _handle_cron_delete(handler, body)
if parsed.path == '/api/crons/run':
return _handle_cron_run(handler, body)
if parsed.path == '/api/crons/pause':
return _handle_cron_pause(handler, body)
if parsed.path == '/api/crons/resume':
return _handle_cron_resume(handler, body)
# ── File ops (POST) ──
if parsed.path == '/api/file/delete':
return _handle_file_delete(handler, body)
if parsed.path == '/api/file/save':
return _handle_file_save(handler, body)
if parsed.path == '/api/file/create':
return _handle_file_create(handler, body)
if parsed.path == '/api/file/rename':
return _handle_file_rename(handler, body)
if parsed.path == '/api/file/create-dir':
return _handle_create_dir(handler, body)
# ── Workspace management (POST) ──
if parsed.path == '/api/workspaces/add':
return _handle_workspace_add(handler, body)
if parsed.path == '/api/workspaces/remove':
return _handle_workspace_remove(handler, body)
if parsed.path == '/api/workspaces/rename':
return _handle_workspace_rename(handler, body)
# ── Approval (POST) ──
if parsed.path == '/api/approval/respond':
return _handle_approval_respond(handler, body)
# ── Skills (POST) ──
if parsed.path == '/api/skills/save':
return _handle_skill_save(handler, body)
if parsed.path == '/api/skills/delete':
return _handle_skill_delete(handler, body)
# ── Memory (POST) ──
if parsed.path == '/api/memory/write':
return _handle_memory_write(handler, body)
# ── Settings (POST) ──
if parsed.path == '/api/settings':
return j(handler, save_settings(body))
# ── Session pin (POST) ──
if parsed.path == '/api/session/pin':
try: require(body, 'session_id')
except ValueError as e: return bad(handler, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
s.pinned = bool(body.get('pinned', True))
s.save()
return j(handler, {'ok': True, 'session': s.compact()})
# ── Session archive (POST) ──
if parsed.path == '/api/session/archive':
try: require(body, 'session_id')
except ValueError as e: return bad(handler, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
s.archived = bool(body.get('archived', True))
s.save()
return j(handler, {'ok': True, 'session': s.compact()})
# ── Session import from JSON (POST) ──
if parsed.path == '/api/session/import':
return _handle_session_import(handler, body)
return False # 404
# ── GET route helpers ─────────────────────────────────────────────────────────
def _serve_static(handler, parsed):
static_file = Path(__file__).parent.parent / parsed.path.lstrip('/')
if not static_file.exists() or not static_file.is_file():
return j(handler, {'error': 'not found'}, status=404)
ext = static_file.suffix.lower()
ct = {'css': 'text/css', 'js': 'application/javascript',
'html': 'text/html'}.get(ext.lstrip('.'), 'text/plain')
handler.send_response(200)
handler.send_header('Content-Type', f'{ct}; charset=utf-8')
handler.send_header('Cache-Control', 'no-store')
raw = static_file.read_bytes()
handler.send_header('Content-Length', str(len(raw)))
handler.end_headers()
handler.wfile.write(raw)
return True
def _handle_session_export(handler, parsed):
sid = parse_qs(parsed.query).get('session_id', [''])[0]
if not sid: return bad(handler, 'session_id is required')
try: s = get_session(sid)
except KeyError: return bad(handler, 'Session not found', 404)
payload = json.dumps(s.__dict__, ensure_ascii=False, indent=2)
handler.send_response(200)
handler.send_header('Content-Type', 'application/json; charset=utf-8')
handler.send_header('Content-Disposition', f'attachment; filename="hermes-{sid}.json"')
handler.send_header('Content-Length', str(len(payload.encode('utf-8'))))
handler.send_header('Cache-Control', 'no-store')
handler.end_headers()
handler.wfile.write(payload.encode('utf-8'))
return True
def _handle_sessions_search(handler, parsed):
qs = parse_qs(parsed.query)
q = qs.get('q', [''])[0].lower().strip()
content_search = qs.get('content', ['1'])[0] == '1'
depth = int(qs.get('depth', ['5'])[0])
if not q: return j(handler, {'sessions': all_sessions()})
results = []
for s in all_sessions():
title_match = q in (s.get('title') or '').lower()
if title_match:
results.append(dict(s, match_type='title'))
continue
if content_search:
try:
sess = get_session(s['session_id'])
msgs = sess.messages[:depth] if depth else sess.messages
for m in msgs:
c = m.get('content') or ''
if isinstance(c, list):
c = ' '.join(p.get('text', '') for p in c
if isinstance(p, dict) and p.get('type') == 'text')
if q in str(c).lower():
results.append(dict(s, match_type='content'))
break
except (KeyError, Exception):
pass
return j(handler, {'sessions': results, 'query': q, 'count': len(results)})
def _handle_list_dir(handler, parsed):
qs = parse_qs(parsed.query)
sid = qs.get('session_id', [''])[0]
if not sid: return bad(handler, 'session_id is required')
try: s = get_session(sid)
except KeyError: return bad(handler, 'Session not found', 404)
try:
return j(handler, {
'entries': list_dir(Path(s.workspace), qs.get('path', ['.'])[0]),
'path': qs.get('path', ['.'])[0],
})
except (FileNotFoundError, ValueError) as e:
return bad(handler, str(e), 404)
def _handle_sse_stream(handler, parsed):
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
q = STREAMS.get(stream_id)
if q is None: return j(handler, {'error': 'stream not found'}, status=404)
handler.send_response(200)
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
handler.send_header('Cache-Control', 'no-cache')
handler.send_header('X-Accel-Buffering', 'no')
handler.send_header('Connection', 'keep-alive')
handler.end_headers()
try:
while True:
try:
event, data = q.get(timeout=30)
except queue.Empty:
handler.wfile.write(b': heartbeat\n\n')
handler.wfile.flush()
continue
_sse(handler, event, data)
if event in ('done', 'error', 'cancel'):
break
except (BrokenPipeError, ConnectionResetError):
pass
return True
def _handle_file_raw(handler, parsed):
qs = parse_qs(parsed.query)
sid = qs.get('session_id', [''])[0]
if not sid: return bad(handler, 'session_id is required')
try: s = get_session(sid)
except KeyError: return bad(handler, 'Session not found', 404)
rel = qs.get('path', [''])[0]
force_download = qs.get('download', [''])[0] == '1'
target = safe_resolve(Path(s.workspace), rel)
if not target.exists() or not target.is_file():
return j(handler, {'error': 'not found'}, status=404)
ext = target.suffix.lower()
mime = MIME_MAP.get(ext, 'application/octet-stream')
raw_bytes = target.read_bytes()
import urllib.parse as _up
safe_name = _up.quote(target.name, safe='')
handler.send_response(200)
handler.send_header('Content-Type', mime)
handler.send_header('Content-Length', str(len(raw_bytes)))
handler.send_header('Cache-Control', 'no-store')
if force_download:
handler.send_header('Content-Disposition',
f'attachment; filename="{target.name}"; filename*=UTF-8\'\'{safe_name}')
handler.end_headers()
handler.wfile.write(raw_bytes)
return True
def _handle_file_read(handler, parsed):
qs = parse_qs(parsed.query)
sid = qs.get('session_id', [''])[0]
if not sid: return bad(handler, 'session_id is required')
try: s = get_session(sid)
except KeyError: return bad(handler, 'Session not found', 404)
rel = qs.get('path', [''])[0]
if not rel: return bad(handler, 'path is required')
try: return j(handler, read_file_content(Path(s.workspace), rel))
except (FileNotFoundError, ValueError) as e: return bad(handler, str(e), 404)
def _handle_approval_pending(handler, parsed):
sid = parse_qs(parsed.query).get('session_id', [''])[0]
if has_pending(sid):
with _lock:
p = dict(_pending.get(sid, {}))
return j(handler, {'pending': p})
return j(handler, {'pending': None})
def _handle_approval_inject(handler, parsed):
qs = parse_qs(parsed.query)
sid = qs.get('session_id', [''])[0]
key = qs.get('pattern_key', ['test_pattern'])[0]
cmd = qs.get('command', ['rm -rf /tmp/test'])[0]
if sid:
submit_pending(sid, {
'command': cmd, 'pattern_key': key,
'pattern_keys': [key], 'description': 'test pattern',
})
return j(handler, {'ok': True, 'session_id': sid})
return j(handler, {'error': 'session_id required'}, status=400)
def _handle_cron_output(handler, parsed):
from cron.jobs import OUTPUT_DIR as CRON_OUT
qs = parse_qs(parsed.query)
job_id = qs.get('job_id', [''])[0]
limit = int(qs.get('limit', ['5'])[0])
if not job_id: return j(handler, {'error': 'job_id required'}, status=400)
out_dir = CRON_OUT / job_id
outputs = []
if out_dir.exists():
files = sorted(out_dir.glob('*.md'), reverse=True)[:limit]
for f in files:
try:
txt = f.read_text(encoding='utf-8', errors='replace')
outputs.append({'filename': f.name, 'content': txt[:8000]})
except Exception:
pass
return j(handler, {'job_id': job_id, 'outputs': outputs})
def _handle_cron_recent(handler, parsed):
"""Return cron jobs that have completed since a given timestamp."""
import datetime
qs = parse_qs(parsed.query)
since = float(qs.get('since', ['0'])[0])
try:
sys.path.insert(0, str(Path(__file__).parent.parent))
from cron.jobs import list_jobs
jobs = list_jobs(include_disabled=True)
completions = []
for job in jobs:
last_run = job.get('last_run_at')
if not last_run:
continue
if isinstance(last_run, str):
try:
ts = datetime.datetime.fromisoformat(last_run.replace('Z', '+00:00')).timestamp()
except (ValueError, TypeError):
continue
else:
ts = float(last_run)
if ts > since:
completions.append({
'job_id': job.get('id', ''),
'name': job.get('name', 'Unknown'),
'status': job.get('last_status', 'unknown'),
'completed_at': ts,
})
return j(handler, {'completions': completions, 'since': since})
except ImportError:
return j(handler, {'completions': [], 'since': since})
def _handle_memory_read(handler):
mem_dir = Path.home() / '.hermes' / 'memories'
mem_file = mem_dir / 'MEMORY.md'
user_file = mem_dir / 'USER.md'
memory = mem_file.read_text(encoding='utf-8', errors='replace') if mem_file.exists() else ''
user = user_file.read_text(encoding='utf-8', errors='replace') if user_file.exists() else ''
return j(handler, {
'memory': memory, 'user': user,
'memory_path': str(mem_file), 'user_path': str(user_file),
'memory_mtime': mem_file.stat().st_mtime if mem_file.exists() else None,
'user_mtime': user_file.stat().st_mtime if user_file.exists() else None,
})
# ── POST route helpers ────────────────────────────────────────────────────────
def _handle_sessions_cleanup(handler, body, zero_only=False):
cleaned = 0
for p in SESSION_DIR.glob('*.json'):
if p.name.startswith('_'): continue
try:
s = Session.load(p.stem)
if zero_only:
should_delete = s and len(s.messages) == 0
else:
should_delete = s and s.title == 'Untitled' and len(s.messages) == 0
if should_delete:
with LOCK: SESSIONS.pop(p.stem, None)
p.unlink(missing_ok=True)
cleaned += 1
except Exception:
pass
if SESSION_INDEX_FILE.exists():
SESSION_INDEX_FILE.unlink(missing_ok=True)
return j(handler, {'ok': True, 'cleaned': cleaned})
def _handle_chat_start(handler, body):
try: require(body, 'session_id')
except ValueError as e: return bad(handler, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
msg = str(body.get('message', '')).strip()
if not msg: return bad(handler, 'message is required')
attachments = [str(a) for a in (body.get('attachments') or [])][:20]
workspace = str(Path(body.get('workspace') or s.workspace).expanduser().resolve())
model = body.get('model') or s.model
s.workspace = workspace; s.model = model; s.save()
set_last_workspace(workspace)
stream_id = uuid.uuid4().hex
q = queue.Queue()
with STREAMS_LOCK: STREAMS[stream_id] = q
thr = threading.Thread(
target=_run_agent_streaming,
args=(s.session_id, msg, model, workspace, stream_id, attachments),
daemon=True,
)
thr.start()
return j(handler, {'stream_id': stream_id, 'session_id': s.session_id})
def _handle_chat_sync(handler, body):
"""Fallback synchronous chat endpoint (POST /api/chat). Not used by frontend."""
from api.config import _get_session_agent_lock
s = get_session(body['session_id'])
msg = str(body.get('message', '')).strip()
if not msg: return j(handler, {'error': 'empty message'}, status=400)
workspace = Path(body.get('workspace') or s.workspace).expanduser().resolve()
s.workspace = str(workspace); s.model = body.get('model') or s.model
old_cwd = os.environ.get('TERMINAL_CWD')
os.environ['TERMINAL_CWD'] = str(workspace)
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
old_session_key = os.environ.get('HERMES_SESSION_KEY')
os.environ['HERMES_EXEC_ASK'] = '1'
os.environ['HERMES_SESSION_KEY'] = s.session_id
try:
from run_agent import AIAgent
with CHAT_LOCK:
from api.config import cfg as _hcfg
_model = s.model or ''
_prov = None
_mc = _hcfg.get('model', {})
if isinstance(_mc, dict):
_prov = _mc.get('provider')
if _prov and '/' in _model and _model.startswith(_prov + '/'):
_model = _model.split('/', 1)[1]
agent = AIAgent(model=_model, provider=_prov, platform='cli', quiet_mode=True,
enabled_toolsets=CLI_TOOLSETS, session_id=s.session_id)
workspace_ctx = f"[Workspace: {s.workspace}]\n"
workspace_system_msg = (
f"Active workspace at session start: {s.workspace}\n"
"Every user message is prefixed with [Workspace: /absolute/path] indicating the "
"workspace the user has selected in the web UI at the time they sent that message. "
"This tag is the single authoritative source of the active workspace and updates "
"with every message. It overrides any prior workspace mentioned in this system "
"prompt, memory, or conversation history. Always use the value from the most recent "
"[Workspace: ...] tag as your default working directory for ALL file operations: "
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present."
)
result = agent.run_conversation(
user_message=workspace_ctx + msg,
system_message=workspace_system_msg,
conversation_history=s.messages,
task_id=s.session_id,
persist_user_message=msg,
)
finally:
if old_cwd is None: os.environ.pop('TERMINAL_CWD', None)
else: os.environ['TERMINAL_CWD'] = old_cwd
if old_exec_ask is None: os.environ.pop('HERMES_EXEC_ASK', None)
else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask
if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None)
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
s.messages = result.get('messages') or s.messages
s.title = title_from(s.messages, s.title); s.save()
return j(handler, {
'answer': result.get('final_response') or '',
'status': 'done' if result.get('completed', True) else 'partial',
'session': s.compact() | {'messages': s.messages},
'result': {k: v for k, v in result.items() if k != 'messages'},
})
def _handle_cron_create(handler, body):
try: require(body, 'prompt', 'schedule')
except ValueError as e: return bad(handler, str(e))
try:
from cron.jobs import create_job
job = create_job(
prompt=body['prompt'], schedule=body['schedule'],
name=body.get('name') or None, deliver=body.get('deliver') or 'local',
skills=body.get('skills') or [], model=body.get('model') or None,
)
return j(handler, {'ok': True, 'job': job})
except Exception as e:
return j(handler, {'error': str(e)}, status=400)
def _handle_cron_update(handler, body):
try: require(body, 'job_id')
except ValueError as e: return bad(handler, str(e))
from cron.jobs import update_job
updates = {k: v for k, v in body.items() if k != 'job_id' and v is not None}
job = update_job(body['job_id'], updates)
if not job: return bad(handler, 'Job not found', 404)
return j(handler, {'ok': True, 'job': job})
def _handle_cron_delete(handler, body):
try: require(body, 'job_id')
except ValueError as e: return bad(handler, str(e))
from cron.jobs import remove_job
ok = remove_job(body['job_id'])
if not ok: return bad(handler, 'Job not found', 404)
return j(handler, {'ok': True, 'job_id': body['job_id']})
def _handle_cron_run(handler, body):
job_id = body.get('job_id', '')
if not job_id: return bad(handler, 'job_id required')
from cron.jobs import get_job
from cron.scheduler import run_job
job = get_job(job_id)
if not job: return bad(handler, 'Job not found', 404)
threading.Thread(target=run_job, args=(job,), daemon=True).start()
return j(handler, {'ok': True, 'job_id': job_id, 'status': 'triggered'})
def _handle_cron_pause(handler, body):
job_id = body.get('job_id', '')
if not job_id: return bad(handler, 'job_id required')
from cron.jobs import pause_job
result = pause_job(job_id, reason=body.get('reason'))
if result: return j(handler, {'ok': True, 'job': result})
return bad(handler, 'Job not found', 404)
def _handle_cron_resume(handler, body):
job_id = body.get('job_id', '')
if not job_id: return bad(handler, 'job_id required')
from cron.jobs import resume_job
result = resume_job(job_id)
if result: return j(handler, {'ok': True, 'job': result})
return bad(handler, 'Job not found', 404)
def _handle_file_delete(handler, body):
try: require(body, 'session_id', 'path')
except ValueError as e: return bad(handler, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
try:
target = safe_resolve(Path(s.workspace), body['path'])
if not target.exists(): return bad(handler, 'File not found', 404)
if target.is_dir(): return bad(handler, 'Cannot delete directories via this endpoint')
target.unlink()
return j(handler, {'ok': True, 'path': body['path']})
except (ValueError, PermissionError) as e: return bad(handler, str(e))
def _handle_file_save(handler, body):
try: require(body, 'session_id', 'path')
except ValueError as e: return bad(handler, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
try:
target = safe_resolve(Path(s.workspace), body['path'])
if not target.exists(): return bad(handler, 'File not found', 404)
if target.is_dir(): return bad(handler, 'Cannot save: path is a directory')
target.write_text(body.get('content', ''), encoding='utf-8')
return j(handler, {'ok': True, 'path': body['path'], 'size': target.stat().st_size})
except (ValueError, PermissionError) as e: return bad(handler, str(e))
def _handle_file_create(handler, body):
try: require(body, 'session_id', 'path')
except ValueError as e: return bad(handler, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
try:
target = safe_resolve(Path(s.workspace), body['path'])
if target.exists(): return bad(handler, 'File already exists')
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body.get('content', ''), encoding='utf-8')
return j(handler, {'ok': True, 'path': str(target.relative_to(Path(s.workspace)))})
except (ValueError, PermissionError) as e: return bad(handler, str(e))
def _handle_file_rename(handler, body):
try: require(body, 'session_id', 'path', 'new_name')
except ValueError as e: return bad(handler, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
try:
source = safe_resolve(Path(s.workspace), body['path'])
if not source.exists(): return bad(handler, 'File not found', 404)
new_name = body['new_name'].strip()
if not new_name or '/' in new_name or '..' in new_name:
return bad(handler, 'Invalid file name')
dest = source.parent / new_name
if dest.exists(): return bad(handler, f'A file named "{new_name}" already exists')
source.rename(dest)
new_rel = str(dest.relative_to(Path(s.workspace)))
return j(handler, {'ok': True, 'old_path': body['path'], 'new_path': new_rel})
except (ValueError, PermissionError, OSError) as e: return bad(handler, str(e))
def _handle_create_dir(handler, body):
try: require(body, 'session_id', 'path')
except ValueError as e: return bad(handler, str(e))
try: s = get_session(body['session_id'])
except KeyError: return bad(handler, 'Session not found', 404)
try:
target = safe_resolve(Path(s.workspace), body['path'])
if target.exists(): return bad(handler, 'Path already exists')
target.mkdir(parents=True)
return j(handler, {'ok': True, 'path': str(target.relative_to(Path(s.workspace)))})
except (ValueError, PermissionError, OSError) as e: return bad(handler, str(e))
def _handle_workspace_add(handler, body):
path_str = body.get('path', '').strip()
name = body.get('name', '').strip()
if not path_str: return bad(handler, 'path is required')
p = Path(path_str).expanduser().resolve()
if not p.exists(): return bad(handler, f'Path does not exist: {p}')
if not p.is_dir(): return bad(handler, f'Path is not a directory: {p}')
wss = load_workspaces()
if any(w['path'] == str(p) for w in wss):
return bad(handler, 'Workspace already in list')
wss.append({'path': str(p), 'name': name or p.name})
save_workspaces(wss)
return j(handler, {'ok': True, 'workspaces': wss})
def _handle_workspace_remove(handler, body):
path_str = body.get('path', '').strip()
if not path_str: return bad(handler, 'path is required')
wss = load_workspaces()
wss = [w for w in wss if w['path'] != path_str]
save_workspaces(wss)
return j(handler, {'ok': True, 'workspaces': wss})
def _handle_workspace_rename(handler, body):
path_str = body.get('path', '').strip()
name = body.get('name', '').strip()
if not path_str or not name: return bad(handler, 'path and name are required')
wss = load_workspaces()
for w in wss:
if w['path'] == path_str:
w['name'] = name; break
else:
return bad(handler, 'Workspace not found', 404)
save_workspaces(wss)
return j(handler, {'ok': True, 'workspaces': wss})
def _handle_approval_respond(handler, body):
sid = body.get('session_id', '')
if not sid: return bad(handler, 'session_id is required')
choice = body.get('choice', 'deny')
if choice not in ('once', 'session', 'always', 'deny'):
return bad(handler, f'Invalid choice: {choice}')
with _lock:
pending = _pending.pop(sid, None)
if pending:
keys = pending.get('pattern_keys') or [pending.get('pattern_key', '')]
if choice in ('once', 'session'):
for k in keys: approve_session(sid, k)
elif choice == 'always':
for k in keys:
approve_session(sid, k); approve_permanent(k)
save_permanent_allowlist(_permanent_approved)
return j(handler, {'ok': True, 'choice': choice})
def _handle_skill_save(handler, body):
try: require(body, 'name', 'content')
except ValueError as e: return bad(handler, str(e))
skill_name = body['name'].strip().lower().replace(' ', '-')
if not skill_name or '/' in skill_name or '..' in skill_name:
return bad(handler, 'Invalid skill name')
category = body.get('category', '').strip()
from tools.skills_tool import SKILLS_DIR
if category:
skill_dir = SKILLS_DIR / category / skill_name
else:
skill_dir = SKILLS_DIR / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
skill_file = skill_dir / 'SKILL.md'
skill_file.write_text(body['content'], encoding='utf-8')
return j(handler, {'ok': True, 'name': skill_name, 'path': str(skill_file)})
def _handle_skill_delete(handler, body):
try: require(body, 'name')
except ValueError as e: return bad(handler, str(e))
from tools.skills_tool import SKILLS_DIR
import shutil
matches = list(SKILLS_DIR.rglob(f'{body["name"]}/SKILL.md'))
if not matches: return bad(handler, 'Skill not found', 404)
skill_dir = matches[0].parent
shutil.rmtree(str(skill_dir))
return j(handler, {'ok': True, 'name': body['name']})
def _handle_memory_write(handler, body):
try: require(body, 'section', 'content')
except ValueError as e: return bad(handler, str(e))
mem_dir = Path.home() / '.hermes' / 'memories'
mem_dir.mkdir(parents=True, exist_ok=True)
section = body['section']
if section == 'memory':
target = mem_dir / 'MEMORY.md'
elif section == 'user':
target = mem_dir / 'USER.md'
else:
return bad(handler, 'section must be "memory" or "user"')
target.write_text(body['content'], encoding='utf-8')
return j(handler, {'ok': True, 'section': section, 'path': str(target)})
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):
return bad(handler, 'Request body must be a JSON object')
messages = body.get('messages')
if not isinstance(messages, list):
return bad(handler, 'JSON must contain a "messages" array')
title = body.get('title', 'Imported session')
workspace = body.get('workspace', str(DEFAULT_WORKSPACE))
model = body.get('model', DEFAULT_MODEL)
s = Session(
title=title, workspace=workspace, model=model,
messages=messages,
tool_calls=body.get('tool_calls', []),
)
s.pinned = body.get('pinned', False)
with LOCK:
SESSIONS[s.session_id] = s
SESSIONS.move_to_end(s.session_id)
while len(SESSIONS) > SESSIONS_MAX:
SESSIONS.popitem(last=False)
s.save()
return j(handler, {'ok': True, 'session': s.compact() | {'messages': s.messages}})