hermes-agent/tui_gateway/server.py
jonny cab6447d58 fix(tui): render tool trail consistently between live and resume
Resumed sessions showed raw JSON tool output in content boxes instead
of the compact trail lines seen during live use. The root cause was
two separate rendering paths with no shared code.

Extract buildToolTrailLine() into lib/text.ts as the single source
of truth for formatting tool trail lines. Both the live tool.complete
handler and toTranscriptMessages now call it.

Server-side, reconstruct tool name and args from the assistant
message's tool_calls field (tool_name column is unpopulated) and
pass them through _tool_ctx/build_tool_preview — the same path
the live tool.start callback uses.
2026-04-11 06:35:00 +00:00

1520 lines
56 KiB
Python

import atexit
import json
import os
import subprocess
import sys
import threading
import uuid
from datetime import datetime
from pathlib import Path
from hermes_constants import get_hermes_home
from hermes_cli.env_loader import load_hermes_dotenv
_hermes_home = get_hermes_home()
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env")
try:
from hermes_cli.banner import prefetch_update_check
prefetch_update_check()
except Exception:
pass
from tui_gateway.render import make_stream_renderer, render_diff, render_message
_sessions: dict[str, dict] = {}
_methods: dict[str, callable] = {}
_pending: dict[str, threading.Event] = {}
_answers: dict[str, str] = {}
_db = None
_stdout_lock = threading.Lock()
# Reserve real stdout for JSON-RPC only; redirect Python's stdout to stderr
# so stray print() from libraries/tools becomes harmless gateway.stderr instead
# of corrupting the JSON protocol.
_real_stdout = sys.stdout
sys.stdout = sys.stderr
class _SlashWorker:
"""Persistent HermesCLI subprocess for slash commands."""
def __init__(self, session_key: str, model: str):
self._lock = threading.Lock()
self._seq = 0
self.stderr_tail: list[str] = []
argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key]
if model:
argv += ["--model", model]
self.proc = subprocess.Popen(
argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, cwd=os.getcwd(), env=os.environ.copy(),
)
threading.Thread(target=self._drain_stderr, daemon=True).start()
def _drain_stderr(self):
for line in (self.proc.stderr or []):
if text := line.rstrip("\n"):
self.stderr_tail = (self.stderr_tail + [text])[-80:]
def run(self, command: str) -> str:
if self.proc.poll() is not None:
raise RuntimeError("slash worker exited")
with self._lock:
self._seq += 1
rid = self._seq
self.proc.stdin.write(json.dumps({"id": rid, "command": command}) + "\n")
self.proc.stdin.flush()
for line in self.proc.stdout:
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
if msg.get("id") != rid:
continue
if not msg.get("ok"):
raise RuntimeError(msg.get("error", "slash worker failed"))
return str(msg.get("output", "")).rstrip()
raise RuntimeError(f"slash worker closed pipe{': ' + chr(10).join(self.stderr_tail[-8:]) if self.stderr_tail else ''}")
def close(self):
try:
if self.proc.poll() is None:
self.proc.terminate()
self.proc.wait(timeout=1)
except Exception:
try: self.proc.kill()
except Exception: pass
atexit.register(lambda: [
s.get("slash_worker") and s["slash_worker"].close()
for s in _sessions.values()
])
# ── Plumbing ──────────────────────────────────────────────────────────
def _get_db():
global _db
if _db is None:
from hermes_state import SessionDB
_db = SessionDB()
return _db
def write_json(obj: dict) -> bool:
line = json.dumps(obj, ensure_ascii=False) + "\n"
try:
with _stdout_lock:
_real_stdout.write(line)
_real_stdout.flush()
return True
except BrokenPipeError:
return False
def _emit(event: str, sid: str, payload: dict | None = None):
params = {"type": event, "session_id": sid}
if payload is not None:
params["payload"] = payload
write_json({"jsonrpc": "2.0", "method": "event", "params": params})
def _status_update(sid: str, kind: str, text: str | None = None):
body = (text if text is not None else kind).strip()
if not body:
return
_emit("status.update", sid, {"kind": kind if text is not None else "status", "text": body})
def _ok(rid, result: dict) -> dict:
return {"jsonrpc": "2.0", "id": rid, "result": result}
def _err(rid, code: int, msg: str) -> dict:
return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": msg}}
def method(name: str):
def dec(fn):
_methods[name] = fn
return fn
return dec
def handle_request(req: dict) -> dict | None:
fn = _methods.get(req.get("method", ""))
if not fn:
return _err(req.get("id"), -32601, f"unknown method: {req.get('method')}")
return fn(req.get("id"), req.get("params", {}))
def _sess(params, rid):
s = _sessions.get(params.get("session_id", ""))
return (s, None) if s else (None, _err(rid, 4001, "session not found"))
# ── Config I/O ────────────────────────────────────────────────────────
def _load_cfg() -> dict:
try:
import yaml
p = _hermes_home / "config.yaml"
if p.exists():
with open(p) as f:
return yaml.safe_load(f) or {}
except Exception:
pass
return {}
def _save_cfg(cfg: dict):
import yaml
with open(_hermes_home / "config.yaml", "w") as f:
yaml.safe_dump(cfg, f)
# ── Blocking prompt factory ──────────────────────────────────────────
def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
rid = uuid.uuid4().hex[:8]
ev = threading.Event()
_pending[rid] = ev
payload["request_id"] = rid
_emit(event, sid, payload)
ev.wait(timeout=timeout)
_pending.pop(rid, None)
return _answers.pop(rid, "")
def _clear_pending():
for rid, ev in list(_pending.items()):
_answers[rid] = ""
ev.set()
# ── Agent factory ────────────────────────────────────────────────────
def resolve_skin() -> dict:
try:
from hermes_cli.skin_engine import init_skin_from_config, get_active_skin
init_skin_from_config(_load_cfg())
skin = get_active_skin()
return {
"name": skin.name,
"colors": skin.colors,
"branding": skin.branding,
"banner_logo": skin.banner_logo,
"banner_hero": skin.banner_hero,
}
except Exception:
return {}
def _resolve_model() -> str:
env = os.environ.get("HERMES_MODEL", "")
if env:
return env
m = _load_cfg().get("model", "")
if isinstance(m, dict):
return m.get("default", "")
if isinstance(m, str) and m:
return m
return "anthropic/claude-sonnet-4"
def _get_usage(agent) -> dict:
g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0)
usage = {
"model": getattr(agent, "model", "") or "",
"input": g("session_input_tokens", "session_prompt_tokens"),
"output": g("session_output_tokens", "session_completion_tokens"),
"cache_read": g("session_cache_read_tokens"),
"cache_write": g("session_cache_write_tokens"),
"prompt": g("session_prompt_tokens"),
"completion": g("session_completion_tokens"),
"total": g("session_total_tokens"),
"calls": g("session_api_calls"),
}
comp = getattr(agent, "context_compressor", None)
if comp:
ctx_used = getattr(comp, "last_prompt_tokens", 0) or usage["total"] or 0
ctx_max = getattr(comp, "context_length", 0) or 0
if ctx_max:
usage["context_used"] = ctx_used
usage["context_max"] = ctx_max
usage["context_percent"] = max(0, min(100, round(ctx_used / ctx_max * 100)))
usage["compressions"] = getattr(comp, "compression_count", 0) or 0
try:
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost
cost = estimate_usage_cost(
usage["model"],
CanonicalUsage(
input_tokens=usage["input"],
output_tokens=usage["output"],
cache_read_tokens=usage["cache_read"],
cache_write_tokens=usage["cache_write"],
),
provider=getattr(agent, "provider", None),
base_url=getattr(agent, "base_url", None),
)
usage["cost_status"] = cost.status
if cost.amount_usd is not None:
usage["cost_usd"] = float(cost.amount_usd)
except Exception:
pass
return usage
def _session_info(agent) -> dict:
info: dict = {
"model": getattr(agent, "model", ""),
"tools": {},
"skills": {},
"cwd": os.getcwd(),
"version": "",
"release_date": "",
"update_behind": None,
"update_command": "",
"usage": _get_usage(agent),
}
try:
from hermes_cli import __version__, __release_date__
info["version"] = __version__
info["release_date"] = __release_date__
except Exception:
pass
try:
from model_tools import get_toolset_for_tool
for t in getattr(agent, "tools", []) or []:
name = t["function"]["name"]
info["tools"].setdefault(get_toolset_for_tool(name) or "other", []).append(name)
except Exception:
pass
try:
from hermes_cli.banner import get_available_skills
info["skills"] = get_available_skills()
except Exception:
pass
try:
from hermes_cli.banner import get_update_result
from hermes_cli.config import recommended_update_command
info["update_behind"] = get_update_result(timeout=0.5)
info["update_command"] = recommended_update_command()
except Exception:
pass
return info
def _tool_ctx(name: str, args: dict) -> str:
try:
from agent.display import build_tool_preview
return build_tool_preview(name, args, max_len=80) or ""
except Exception:
return ""
def _agent_cbs(sid: str) -> dict:
return dict(
tool_start_callback=lambda tc_id, name, args: _emit("tool.start", sid, {"tool_id": tc_id, "name": name, "context": _tool_ctx(name, args)}),
tool_complete_callback=lambda tc_id, name, args, result: _emit("tool.complete", sid, {"tool_id": tc_id, "name": name}),
tool_progress_callback=lambda name, preview, args: _emit("tool.progress", sid, {"name": name, "preview": preview}),
tool_gen_callback=lambda name: _emit("tool.generating", sid, {"name": name}),
thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}),
reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}),
status_callback=lambda kind, text=None: _status_update(sid, str(kind), None if text is None else str(text)),
clarify_callback=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}),
)
def _wire_callbacks(sid: str):
from tools.terminal_tool import set_sudo_password_callback
from tools.skills_tool import set_secret_capture_callback
set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120))
def secret_cb(env_var, prompt, metadata=None):
pl = {"prompt": prompt, "env_var": env_var}
if metadata:
pl["metadata"] = metadata
val = _block("secret.request", sid, pl)
if not val:
return {"success": True, "stored_as": env_var, "validated": False, "skipped": True, "message": "skipped"}
from hermes_cli.config import save_env_value_secure
return {**save_env_value_secure(env_var, val), "skipped": False, "message": "ok"}
set_secret_capture_callback(secret_cb)
def _make_agent(sid: str, key: str, session_id: str | None = None):
from run_agent import AIAgent
cfg = _load_cfg()
system_prompt = cfg.get("agent", {}).get("system_prompt", "") or ""
return AIAgent(
model=_resolve_model(), quiet_mode=True, platform="tui",
session_id=session_id or key, session_db=_get_db(),
ephemeral_system_prompt=system_prompt or None,
**_agent_cbs(sid),
)
def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
_sessions[sid] = {
"agent": agent,
"session_key": key,
"history": history,
"attached_images": [],
"image_counter": 0,
"cols": cols,
"slash_worker": None,
}
try:
_sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model()))
except Exception:
# Defer hard-failure to slash.exec; chat still works without slash worker.
_sessions[sid]["slash_worker"] = None
try:
from tools.approval import register_gateway_notify, load_permanent_allowlist
register_gateway_notify(key, lambda data: _emit("approval.request", sid, data))
load_permanent_allowlist()
except Exception:
pass
_wire_callbacks(sid)
_emit("session.info", sid, _session_info(agent))
def _new_session_key() -> str:
return f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
def _with_checkpoints(session, fn):
return fn(session["agent"]._checkpoint_mgr, os.getenv("TERMINAL_CWD", os.getcwd()))
def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str:
"""Pre-analyze attached images via vision and prepend descriptions to user text."""
import asyncio, json as _json
from tools.vision_tools import vision_analyze_tool
prompt = (
"Describe everything visible in this image in thorough detail. "
"Include any text, code, data, objects, people, layout, colors, "
"and any other notable visual information."
)
parts: list[str] = []
for path in image_paths:
p = Path(path)
if not p.exists():
continue
hint = f"[You can examine it with vision_analyze using image_url: {p}]"
try:
r = _json.loads(asyncio.run(vision_analyze_tool(image_url=str(p), user_prompt=prompt)))
desc = r.get("analysis", "") if r.get("success") else None
parts.append(f"[The user attached an image:\n{desc}]\n{hint}" if desc
else f"[The user attached an image but analysis failed.]\n{hint}")
except Exception:
parts.append(f"[The user attached an image but analysis failed.]\n{hint}")
text = user_text or ""
prefix = "\n\n".join(parts)
if prefix:
return f"{prefix}\n\n{text}" if text else prefix
return text or "What do you see in this image?"
# ── Methods: session ─────────────────────────────────────────────────
@method("session.create")
def _(rid, params: dict) -> dict:
sid = uuid.uuid4().hex[:8]
key = _new_session_key()
os.environ["HERMES_SESSION_KEY"] = key
os.environ["HERMES_INTERACTIVE"] = "1"
try:
agent = _make_agent(sid, key)
_get_db().create_session(key, source="tui", model=_resolve_model())
_init_session(sid, key, agent, [], cols=int(params.get("cols", 80)))
except Exception as e:
return _err(rid, 5000, f"agent init failed: {e}")
return _ok(rid, {"session_id": sid, "info": _session_info(agent)})
@method("session.list")
def _(rid, params: dict) -> dict:
try:
db = _get_db()
# Show both TUI and CLI sessions — TUI is the successor to the CLI,
# so users expect to resume their old CLI sessions here too.
tui = db.list_sessions_rich(source="tui", limit=params.get("limit", 20))
cli = db.list_sessions_rich(source="cli", limit=params.get("limit", 20))
rows = sorted(tui + cli, key=lambda s: s.get("started_at") or 0, reverse=True)[:params.get("limit", 20)]
return _ok(rid, {"sessions": [
{"id": s["id"], "title": s.get("title") or "", "preview": s.get("preview") or "",
"started_at": s.get("started_at") or 0, "message_count": s.get("message_count") or 0,
"source": s.get("source") or ""}
for s in rows
]})
except Exception as e:
return _err(rid, 5006, str(e))
@method("session.resume")
def _(rid, params: dict) -> dict:
target = params.get("session_id", "")
if not target:
return _err(rid, 4006, "session_id required")
db = _get_db()
found = db.get_session(target)
if not found:
found = db.get_session_by_title(target)
if found:
target = found["id"]
else:
return _err(rid, 4007, "session not found")
sid = uuid.uuid4().hex[:8]
os.environ["HERMES_SESSION_KEY"] = target
os.environ["HERMES_INTERACTIVE"] = "1"
try:
db.reopen_session(target)
history = db.get_messages_as_conversation(target)
messages = []
tool_call_args = {}
for m in history:
role = m.get("role")
if role not in ("user", "assistant", "tool", "system"):
continue
if role == "assistant" and m.get("tool_calls"):
for tc in m["tool_calls"]:
fn = tc.get("function", {})
tc_id = tc.get("id", "")
if tc_id and fn.get("name"):
try:
args = json.loads(fn.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
args = {}
tool_call_args[tc_id] = (fn["name"], args)
if not (m.get("content") or "").strip():
continue
if role == "tool":
tc_id = m.get("tool_call_id", "")
tc_info = tool_call_args.get(tc_id) if tc_id else None
name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool"
args = (tc_info[1] if tc_info else None) or {}
ctx = _tool_ctx(name, args)
messages.append({"role": "tool", "name": name, "context": ctx})
continue
if not (m.get("content") or "").strip():
continue
messages.append({"role": role, "text": m.get("content") or ""})
agent = _make_agent(sid, target, session_id=target)
_init_session(sid, target, agent, history, cols=int(params.get("cols", 80)))
except Exception as e:
return _err(rid, 5000, f"resume failed: {e}")
return _ok(
rid,
{
"session_id": sid,
"resumed": target,
"message_count": len(messages),
"messages": messages,
"info": _session_info(agent),
},
)
@method("session.title")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
title, key = params.get("title", ""), session["session_key"]
if not title:
return _ok(rid, {"title": _get_db().get_session_title(key) or "", "session_key": key})
try:
_get_db().set_session_title(key, title)
return _ok(rid, {"title": title})
except Exception as e:
return _err(rid, 5007, str(e))
@method("session.usage")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
return err or _ok(rid, _get_usage(session["agent"]))
@method("session.history")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
return err or _ok(rid, {"count": len(session.get("history", []))})
@method("session.undo")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
history, removed = session.get("history", []), 0
while history and history[-1].get("role") in ("assistant", "tool"):
history.pop(); removed += 1
if history and history[-1].get("role") == "user":
history.pop(); removed += 1
return _ok(rid, {"removed": removed})
@method("session.compress")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
agent = session["agent"]
try:
if hasattr(agent, "compress_context"):
agent.compress_context()
return _ok(rid, {"status": "compressed", "usage": _get_usage(agent)})
except Exception as e:
return _err(rid, 5005, str(e))
@method("session.save")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
import time as _time
filename = f"hermes_conversation_{_time.strftime('%Y%m%d_%H%M%S')}.json"
try:
with open(filename, "w") as f:
json.dump({"model": getattr(session["agent"], "model", ""), "messages": session.get("history", [])},
f, indent=2, ensure_ascii=False)
return _ok(rid, {"file": filename})
except Exception as e:
return _err(rid, 5011, str(e))
@method("session.branch")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
db = _get_db()
old_key = session["session_key"]
history = session.get("history", [])
if not history:
return _err(rid, 4008, "nothing to branch — send a message first")
new_key = _new_session_key()
branch_name = params.get("name", "")
try:
if branch_name:
title = branch_name
else:
current = db.get_session_title(old_key) or "branch"
title = db.get_next_title_in_lineage(current) if hasattr(db, "get_next_title_in_lineage") else f"{current} (branch)"
db.create_session(new_key, source="tui", model=_resolve_model(), parent_session_id=old_key)
for msg in history:
db.append_message(session_id=new_key, role=msg.get("role", "user"), content=msg.get("content"))
db.set_session_title(new_key, title)
except Exception as e:
return _err(rid, 5008, f"branch failed: {e}")
new_sid = uuid.uuid4().hex[:8]
os.environ["HERMES_SESSION_KEY"] = new_key
try:
agent = _make_agent(new_sid, new_key, session_id=new_key)
_init_session(new_sid, new_key, agent, list(history), cols=session.get("cols", 80))
except Exception as e:
return _err(rid, 5000, f"agent init failed on branch: {e}")
return _ok(rid, {"session_id": new_sid, "title": title, "parent": old_key})
@method("session.interrupt")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
if hasattr(session["agent"], "interrupt"):
session["agent"].interrupt()
_clear_pending()
try:
from tools.approval import resolve_gateway_approval
resolve_gateway_approval(session["session_key"], "deny", resolve_all=True)
except Exception:
pass
return _ok(rid, {"status": "interrupted"})
@method("terminal.resize")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
session["cols"] = int(params.get("cols", 80))
return _ok(rid, {"cols": session["cols"]})
# ── Methods: prompt ──────────────────────────────────────────────────
@method("prompt.submit")
def _(rid, params: dict) -> dict:
sid, text = params.get("session_id", ""), params.get("text", "")
session = _sessions.get(sid)
if not session:
return _err(rid, 4001, "session not found")
agent, history = session["agent"], session["history"]
_emit("message.start", sid)
def run():
try:
cols = session.get("cols", 80)
streamer = make_stream_renderer(cols)
images = session.pop("attached_images", [])
prompt = _enrich_with_attached_images(text, images) if images else text
def _stream(delta):
payload = {"text": delta}
if streamer and (r := streamer.feed(delta)) is not None:
payload["rendered"] = r
_emit("message.delta", sid, payload)
result = agent.run_conversation(
prompt, conversation_history=list(history),
stream_callback=_stream,
)
if isinstance(result, dict):
if isinstance(result.get("messages"), list):
session["history"] = result["messages"]
raw = result.get("final_response", "")
status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete"
else:
raw = str(result)
status = "complete"
payload = {"text": raw, "usage": _get_usage(agent), "status": status}
rendered = render_message(raw, cols)
if rendered:
payload["rendered"] = rendered
_emit("message.complete", sid, payload)
except Exception as e:
_emit("error", sid, {"message": str(e)})
threading.Thread(target=run, daemon=True).start()
return _ok(rid, {"status": "streaming"})
@method("clipboard.paste")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
try:
from datetime import datetime
from hermes_cli.clipboard import has_clipboard_image, save_clipboard_image
except Exception as e:
return _err(rid, 5027, f"clipboard unavailable: {e}")
session["image_counter"] = session.get("image_counter", 0) + 1
img_dir = _hermes_home / "images"
img_dir.mkdir(parents=True, exist_ok=True)
img_path = img_dir / f"clip_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{session['image_counter']}.png"
# Save-first: mirrors CLI keybinding path; more robust than has_image() precheck
if not save_clipboard_image(img_path):
msg = "Clipboard has image but extraction failed" if has_clipboard_image() else "No image found in clipboard"
return _ok(rid, {"attached": False, "message": msg})
session.setdefault("attached_images", []).append(str(img_path))
return _ok(rid, {"attached": True, "path": str(img_path), "count": len(session["attached_images"])})
@method("prompt.background")
def _(rid, params: dict) -> dict:
text, parent = params.get("text", ""), params.get("session_id", "")
if not text:
return _err(rid, 4012, "text required")
task_id = f"bg_{uuid.uuid4().hex[:6]}"
def run():
try:
from run_agent import AIAgent
result = AIAgent(model=_resolve_model(), quiet_mode=True, platform="tui",
session_id=task_id, max_iterations=30).run_conversation(text)
_emit("background.complete", parent, {"task_id": task_id,
"text": result.get("final_response", str(result)) if isinstance(result, dict) else str(result)})
except Exception as e:
_emit("background.complete", parent, {"task_id": task_id, "text": f"error: {e}"})
threading.Thread(target=run, daemon=True).start()
return _ok(rid, {"task_id": task_id})
@method("prompt.btw")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
text, sid = params.get("text", ""), params.get("session_id", "")
if not text:
return _err(rid, 4012, "text required")
snapshot = list(session.get("history", []))
def run():
try:
from run_agent import AIAgent
result = AIAgent(model=_resolve_model(), quiet_mode=True, platform="tui",
max_iterations=8, enabled_toolsets=[]).run_conversation(text, conversation_history=snapshot)
_emit("btw.complete", sid, {"text": result.get("final_response", str(result)) if isinstance(result, dict) else str(result)})
except Exception as e:
_emit("btw.complete", sid, {"text": f"error: {e}"})
threading.Thread(target=run, daemon=True).start()
return _ok(rid, {"status": "running"})
# ── Methods: respond ─────────────────────────────────────────────────
def _respond(rid, params, key):
r = params.get("request_id", "")
ev = _pending.get(r)
if not ev:
return _err(rid, 4009, f"no pending {key} request")
_answers[r] = params.get(key, "")
ev.set()
return _ok(rid, {"status": "ok"})
@method("clarify.respond")
def _(rid, params: dict) -> dict:
return _respond(rid, params, "answer")
@method("sudo.respond")
def _(rid, params: dict) -> dict:
return _respond(rid, params, "password")
@method("secret.respond")
def _(rid, params: dict) -> dict:
return _respond(rid, params, "value")
@method("approval.respond")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
try:
from tools.approval import resolve_gateway_approval
return _ok(rid, {"resolved": resolve_gateway_approval(
session["session_key"], params.get("choice", "deny"), resolve_all=params.get("all", False))})
except Exception as e:
return _err(rid, 5004, str(e))
# ── Methods: config ──────────────────────────────────────────────────
@method("config.set")
def _(rid, params: dict) -> dict:
key, value = params.get("key", ""), params.get("value", "")
if key == "model":
os.environ["HERMES_MODEL"] = value
return _ok(rid, {"key": key, "value": value})
if key == "verbose":
cycle = ["off", "new", "all", "verbose"]
if value and value != "cycle":
os.environ["HERMES_VERBOSE"] = value
return _ok(rid, {"key": key, "value": value})
cur = os.environ.get("HERMES_VERBOSE", "all")
try:
idx = cycle.index(cur)
except ValueError:
idx = 2
nv = cycle[(idx + 1) % len(cycle)]
os.environ["HERMES_VERBOSE"] = nv
return _ok(rid, {"key": key, "value": nv})
if key == "yolo":
nv = "0" if os.environ.get("HERMES_YOLO", "0") == "1" else "1"
os.environ["HERMES_YOLO"] = nv
return _ok(rid, {"key": key, "value": nv})
if key == "reasoning":
if value in ("show", "on"):
os.environ["HERMES_SHOW_REASONING"] = "1"
return _ok(rid, {"key": key, "value": "show"})
if value in ("hide", "off"):
os.environ.pop("HERMES_SHOW_REASONING", None)
return _ok(rid, {"key": key, "value": "hide"})
os.environ["HERMES_REASONING"] = value
return _ok(rid, {"key": key, "value": value})
if key in ("prompt", "personality", "skin"):
try:
cfg = _load_cfg()
if key == "prompt":
if value == "clear":
cfg.pop("custom_prompt", None)
nv = ""
else:
cfg["custom_prompt"] = value
nv = value
elif key == "personality":
cfg.setdefault("display", {})["personality"] = value if value not in ("none", "default", "neutral") else ""
nv = value
else:
cfg.setdefault("display", {})[key] = value
nv = value
_save_cfg(cfg)
if key == "skin":
_emit("skin.changed", "", resolve_skin())
return _ok(rid, {"key": key, "value": nv})
except Exception as e:
return _err(rid, 5001, str(e))
return _err(rid, 4002, f"unknown config key: {key}")
@method("config.get")
def _(rid, params: dict) -> dict:
key = params.get("key", "")
if key == "provider":
try:
from hermes_cli.models import list_available_providers, normalize_provider
model = _resolve_model()
parts = model.split("/", 1)
return _ok(rid, {"model": model, "provider": normalize_provider(parts[0]) if len(parts) > 1 else "unknown",
"providers": list_available_providers()})
except Exception as e:
return _err(rid, 5013, str(e))
if key == "profile":
from hermes_constants import display_hermes_home
return _ok(rid, {"home": str(_hermes_home), "display": display_hermes_home()})
if key == "full":
return _ok(rid, {"config": _load_cfg()})
if key == "prompt":
return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")})
if key == "skin":
return _ok(rid, {"value": _load_cfg().get("display", {}).get("skin", "default")})
return _err(rid, 4002, f"unknown config key: {key}")
# ── Methods: tools & system ──────────────────────────────────────────
@method("process.stop")
def _(rid, params: dict) -> dict:
try:
from tools.process_registry import ProcessRegistry
return _ok(rid, {"killed": ProcessRegistry().kill_all()})
except Exception as e:
return _err(rid, 5010, str(e))
@method("reload.mcp")
def _(rid, params: dict) -> dict:
session = _sessions.get(params.get("session_id", ""))
try:
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools
shutdown_mcp_servers()
discover_mcp_tools()
if session:
agent = session["agent"]
if hasattr(agent, "refresh_tools"):
agent.refresh_tools()
_emit("session.info", params.get("session_id", ""), _session_info(agent))
return _ok(rid, {"status": "reloaded"})
except Exception as e:
return _err(rid, 5015, str(e))
_TUI_HIDDEN: frozenset[str] = frozenset({
"sethome", "set-home", "update", "commands", "status", "approve", "deny",
})
_TUI_EXTRA: list[tuple[str, str, str]] = [
("/compact", "Toggle compact display mode", "TUI"),
("/logs", "Show recent gateway log lines", "TUI"),
]
@method("commands.catalog")
def _(rid, params: dict) -> dict:
"""Registry-backed slash metadata for the TUI — categorized, no aliases."""
try:
from hermes_cli.commands import COMMAND_REGISTRY, SUBCOMMANDS, _build_description
all_pairs: list[list[str]] = []
canon: dict[str, str] = {}
categories: list[dict] = []
cat_map: dict[str, list[list[str]]] = {}
cat_order: list[str] = []
for cmd in COMMAND_REGISTRY:
c = f"/{cmd.name}"
canon[c.lower()] = c
for a in cmd.aliases:
canon[f"/{a}".lower()] = c
if cmd.name in _TUI_HIDDEN:
continue
desc = _build_description(cmd)
all_pairs.append([c, desc])
cat = cmd.category
if cat not in cat_map:
cat_map[cat] = []
cat_order.append(cat)
cat_map[cat].append([c, desc])
for name, desc, cat in _TUI_EXTRA:
all_pairs.append([name, desc])
if cat not in cat_map:
cat_map[cat] = []
cat_order.append(cat)
cat_map[cat].append([name, desc])
skill_count = 0
try:
from agent.skill_commands import scan_skill_commands
for k, info in sorted(scan_skill_commands().items()):
d = str(info.get("description", "Skill"))
all_pairs.append([k, d[:120] + ("" if len(d) > 120 else "")])
skill_count += 1
except Exception:
pass
for cat in cat_order:
categories.append({"name": cat, "pairs": cat_map[cat]})
sub = {k: v[:] for k, v in SUBCOMMANDS.items()}
return _ok(rid, {
"pairs": all_pairs,
"sub": sub,
"canon": canon,
"categories": categories,
"skill_count": skill_count,
})
except Exception as e:
return _err(rid, 5020, str(e))
def _cli_exec_blocked(argv: list[str]) -> str | None:
"""Return user hint if this argv must not run headless in the gateway process."""
if not argv:
return "bare `hermes` is interactive — use `/hermes chat -q …` or run `hermes` in another terminal"
a0 = argv[0].lower()
if a0 == "setup":
return "`hermes setup` needs a full terminal — run it outside the Ink UI"
if a0 == "gateway":
return "`hermes gateway` is long-running — run it in another terminal"
if a0 == "sessions" and len(argv) > 1 and argv[1].lower() == "browse":
return "`hermes sessions browse` is interactive — use /resume here, or run browse in another terminal"
if a0 == "config" and len(argv) > 1 and argv[1].lower() == "edit":
return "`hermes config edit` needs $EDITOR in a real terminal"
return None
@method("cli.exec")
def _(rid, params: dict) -> dict:
"""Run `python -m hermes_cli.main` with argv; capture stdout/stderr (non-interactive only)."""
argv = params.get("argv", [])
if not isinstance(argv, list) or not all(isinstance(x, str) for x in argv):
return _err(rid, 4003, "argv must be list[str]")
hint = _cli_exec_blocked(argv)
if hint:
return _ok(rid, {"blocked": True, "hint": hint, "code": -1, "output": ""})
try:
r = subprocess.run(
[sys.executable, "-m", "hermes_cli.main", *argv],
capture_output=True,
text=True,
timeout=min(int(params.get("timeout", 240)), 600),
cwd=os.getcwd(),
env=os.environ.copy(),
)
parts = [r.stdout or "", r.stderr or ""]
out = "\n".join(p for p in parts if p).strip() or "(no output)"
return _ok(rid, {"blocked": False, "code": r.returncode, "output": out[:48_000]})
except subprocess.TimeoutExpired:
return _err(rid, 5016, "cli.exec: timeout")
except Exception as e:
return _err(rid, 5017, str(e))
@method("command.resolve")
def _(rid, params: dict) -> dict:
try:
from hermes_cli.commands import resolve_command
r = resolve_command(params.get("name", ""))
if r:
return _ok(rid, {"canonical": r.name, "description": r.description, "category": r.category})
return _err(rid, 4011, f"unknown command: {params.get('name')}")
except Exception as e:
return _err(rid, 5012, str(e))
def _resolve_name(name: str) -> str:
try:
from hermes_cli.commands import resolve_command
r = resolve_command(name)
return r.name if r else name
except Exception:
return name
@method("command.dispatch")
def _(rid, params: dict) -> dict:
name, arg = params.get("name", "").lstrip("/"), params.get("arg", "")
resolved = _resolve_name(name)
if resolved != name:
name = resolved
session = _sessions.get(params.get("session_id", ""))
qcmds = _load_cfg().get("quick_commands", {})
if name in qcmds:
qc = qcmds[name]
if qc.get("type") == "exec":
r = subprocess.run(qc.get("command", ""), shell=True, capture_output=True, text=True, timeout=30)
return _ok(rid, {"type": "exec", "output": (r.stdout or r.stderr)[:4000]})
if qc.get("type") == "alias":
return _ok(rid, {"type": "alias", "target": qc.get("target", "")})
try:
from hermes_cli.plugins import get_plugin_command_handler
handler = get_plugin_command_handler(name)
if handler:
return _ok(rid, {"type": "plugin", "output": str(handler(arg) or "")})
except Exception:
pass
try:
from agent.skill_commands import scan_skill_commands, build_skill_invocation_message
cmds = scan_skill_commands()
key = f"/{name}"
if key in cmds:
msg = build_skill_invocation_message(key, arg, task_id=session.get("session_key", "") if session else "")
if msg:
return _ok(rid, {"type": "skill", "message": msg, "name": cmds[key].get("name", name)})
except Exception:
pass
return _err(rid, 4018, f"not a quick/plugin/skill command: {name}")
# ── Methods: paste ────────────────────────────────────────────────────
_paste_counter = 0
@method("paste.collapse")
def _(rid, params: dict) -> dict:
global _paste_counter
text = params.get("text", "")
if not text:
return _err(rid, 4004, "empty paste")
_paste_counter += 1
line_count = text.count('\n') + 1
paste_dir = _hermes_home / "pastes"
paste_dir.mkdir(parents=True, exist_ok=True)
from datetime import datetime
paste_file = paste_dir / f"paste_{_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt"
paste_file.write_text(text, encoding="utf-8")
placeholder = f"[Pasted text #{_paste_counter}: {line_count} lines \u2192 {paste_file}]"
return _ok(rid, {"placeholder": placeholder, "path": str(paste_file), "lines": line_count})
# ── Methods: complete ─────────────────────────────────────────────────
@method("complete.path")
def _(rid, params: dict) -> dict:
word = params.get("word", "")
if not word:
return _ok(rid, {"items": []})
items: list[dict] = []
try:
is_context = word.startswith("@")
query = word[1:] if is_context else word
if is_context and not query:
items = [
{"text": "@diff", "display": "@diff", "meta": "git diff"},
{"text": "@staged", "display": "@staged", "meta": "staged diff"},
{"text": "@file:", "display": "@file:", "meta": "attach file"},
{"text": "@folder:", "display": "@folder:", "meta": "attach folder"},
{"text": "@url:", "display": "@url:", "meta": "fetch url"},
{"text": "@git:", "display": "@git:", "meta": "git log"},
]
return _ok(rid, {"items": items})
if is_context and query.startswith(("file:", "folder:")):
prefix_tag = query.split(":", 1)[0]
path_part = query.split(":", 1)[1] or "."
else:
prefix_tag = ""
path_part = query if not is_context else query
expanded = os.path.expanduser(path_part)
if expanded.endswith("/"):
search_dir, match = expanded, ""
else:
search_dir = os.path.dirname(expanded) or "."
match = os.path.basename(expanded)
match_lower = match.lower()
for entry in sorted(os.listdir(search_dir))[:200]:
if match and not entry.lower().startswith(match_lower):
continue
if is_context and not prefix_tag and entry.startswith("."):
continue
full = os.path.join(search_dir, entry)
is_dir = os.path.isdir(full)
rel = os.path.relpath(full)
suffix = "/" if is_dir else ""
if is_context and prefix_tag:
text = f"@{prefix_tag}:{rel}{suffix}"
elif is_context:
kind = "folder" if is_dir else "file"
text = f"@{kind}:{rel}{suffix}"
elif word.startswith("~"):
text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix
elif word.startswith("./"):
text = "./" + rel + suffix
else:
text = rel + suffix
items.append({"text": text, "display": entry + suffix, "meta": "dir" if is_dir else ""})
if len(items) >= 30:
break
except Exception:
pass
return _ok(rid, {"items": items})
@method("complete.slash")
def _(rid, params: dict) -> dict:
text = params.get("text", "")
if not text.startswith("/"):
return _ok(rid, {"items": []})
try:
from hermes_cli.commands import SlashCommandCompleter
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import to_plain_text
completer = SlashCommandCompleter()
doc = Document(text, len(text))
items = [
{"text": c.text, "display": c.display or c.text,
"meta": to_plain_text(c.display_meta) if c.display_meta else ""}
for c in completer.get_completions(doc, None)
][:30]
return _ok(rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1})
except Exception:
return _ok(rid, {"items": []})
# ── Methods: slash.exec ──────────────────────────────────────────────
def _mirror_slash_side_effects(sid: str, session: dict, command: str):
"""Apply side effects that must also hit the gateway's live agent."""
parts = command.lstrip("/").split(None, 1)
if not parts:
return
name, arg, agent = parts[0], (parts[1].strip() if len(parts) > 1 else ""), session.get("agent")
try:
if name == "model" and arg and agent:
from hermes_cli.model_switch import switch_model
result = switch_model(
raw_input=arg,
current_provider=getattr(agent, "provider", "") or "",
current_model=getattr(agent, "model", "") or "",
current_base_url=getattr(agent, "base_url", "") or "",
current_api_key=getattr(agent, "api_key", "") or "",
)
if result.success:
agent.switch_model(
new_model=result.new_model,
new_provider=result.target_provider,
api_key=result.api_key,
base_url=result.base_url,
api_mode=result.api_mode,
)
_emit("session.info", sid, _session_info(agent))
elif name in ("personality", "prompt") and agent:
cfg = _load_cfg()
new_prompt = cfg.get("agent", {}).get("system_prompt", "") or ""
agent.ephemeral_system_prompt = new_prompt or None
agent._cached_system_prompt = None
elif name == "compress" and agent:
(getattr(agent, "compress_context", None) or getattr(agent, "context_compressor", agent).compress)()
elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"):
agent.reload_mcp_tools()
elif name == "stop":
from tools.process_registry import ProcessRegistry
ProcessRegistry().kill_all()
except Exception:
pass
@method("slash.exec")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
cmd = params.get("command", "").strip()
if not cmd:
return _err(rid, 4004, "empty command")
worker = session.get("slash_worker")
if not worker:
try:
worker = _SlashWorker(session["session_key"], getattr(session.get("agent"), "model", _resolve_model()))
session["slash_worker"] = worker
except Exception as e:
return _err(rid, 5030, f"slash worker start failed: {e}")
try:
output = worker.run(cmd)
_mirror_slash_side_effects(params.get("session_id", ""), session, cmd)
return _ok(rid, {"output": output or "(no output)"})
except Exception as e:
try:
worker.close()
except Exception:
pass
session["slash_worker"] = None
return _err(rid, 5030, str(e))
# ── Methods: voice ───────────────────────────────────────────────────
@method("voice.toggle")
def _(rid, params: dict) -> dict:
action = params.get("action", "status")
if action == "status":
return _ok(rid, {"enabled": os.environ.get("HERMES_VOICE", "0") == "1"})
if action in ("on", "off"):
os.environ["HERMES_VOICE"] = "1" if action == "on" else "0"
return _ok(rid, {"enabled": action == "on"})
return _err(rid, 4013, f"unknown voice action: {action}")
@method("voice.record")
def _(rid, params: dict) -> dict:
action = params.get("action", "start")
try:
if action == "start":
from hermes_cli.voice import start_recording
start_recording()
return _ok(rid, {"status": "recording"})
if action == "stop":
from hermes_cli.voice import stop_and_transcribe
return _ok(rid, {"text": stop_and_transcribe() or ""})
return _err(rid, 4019, f"unknown voice action: {action}")
except ImportError:
return _err(rid, 5025, "voice module not available — install audio dependencies")
except Exception as e:
return _err(rid, 5025, str(e))
@method("voice.tts")
def _(rid, params: dict) -> dict:
text = params.get("text", "")
if not text:
return _err(rid, 4020, "text required")
try:
from hermes_cli.voice import speak_text
threading.Thread(target=speak_text, args=(text,), daemon=True).start()
return _ok(rid, {"status": "speaking"})
except ImportError:
return _err(rid, 5026, "voice module not available")
except Exception as e:
return _err(rid, 5026, str(e))
# ── Methods: insights ────────────────────────────────────────────────
@method("insights.get")
def _(rid, params: dict) -> dict:
days = params.get("days", 30)
try:
import time
cutoff = time.time() - days * 86400
rows = [s for s in _get_db().list_sessions_rich(limit=500) if (s.get("started_at") or 0) >= cutoff]
return _ok(rid, {"days": days, "sessions": len(rows), "messages": sum(s.get("message_count", 0) for s in rows)})
except Exception as e:
return _err(rid, 5017, str(e))
# ── Methods: rollback ────────────────────────────────────────────────
@method("rollback.list")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
try:
def go(mgr, cwd):
if not mgr.enabled:
return _ok(rid, {"enabled": False, "checkpoints": []})
return _ok(rid, {"enabled": True, "checkpoints": [
{"hash": c.get("hash", ""), "timestamp": c.get("timestamp", ""), "message": c.get("message", "")}
for c in mgr.list_checkpoints(cwd)]})
return _with_checkpoints(session, go)
except Exception as e:
return _err(rid, 5020, str(e))
@method("rollback.restore")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
target = params.get("hash", "")
if not target:
return _err(rid, 4014, "hash required")
try:
return _ok(rid, _with_checkpoints(session, lambda mgr, cwd: mgr.restore(cwd, target)))
except Exception as e:
return _err(rid, 5021, str(e))
@method("rollback.diff")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
target = params.get("hash", "")
if not target:
return _err(rid, 4014, "hash required")
try:
r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, target))
raw = r.get("diff", "")[:4000]
payload = {"stat": r.get("stat", ""), "diff": raw}
rendered = render_diff(raw, session.get("cols", 80))
if rendered:
payload["rendered"] = rendered
return _ok(rid, payload)
except Exception as e:
return _err(rid, 5022, str(e))
# ── Methods: browser / plugins / cron / skills ───────────────────────
@method("browser.manage")
def _(rid, params: dict) -> dict:
action = params.get("action", "status")
if action == "status":
url = os.environ.get("BROWSER_CDP_URL", "")
return _ok(rid, {"connected": bool(url), "url": url})
if action == "connect":
url = params.get("url", "http://localhost:9222")
os.environ["BROWSER_CDP_URL"] = url
try:
from tools.browser_tool import cleanup_all_browsers
cleanup_all_browsers()
except Exception:
pass
return _ok(rid, {"connected": True, "url": url})
if action == "disconnect":
os.environ.pop("BROWSER_CDP_URL", None)
try:
from tools.browser_tool import cleanup_all_browsers
cleanup_all_browsers()
except Exception:
pass
return _ok(rid, {"connected": False})
return _err(rid, 4015, f"unknown action: {action}")
@method("plugins.list")
def _(rid, params: dict) -> dict:
try:
from hermes_cli.plugins import get_plugin_manager
return _ok(rid, {"plugins": [
{"name": n, "version": getattr(i, "version", "?"), "enabled": getattr(i, "enabled", True)}
for n, i in get_plugin_manager()._plugins.items()]})
except Exception:
return _ok(rid, {"plugins": []})
@method("cron.manage")
def _(rid, params: dict) -> dict:
action, jid = params.get("action", "list"), params.get("name", "")
try:
from tools.cronjob_tools import cronjob
if action == "list":
return _ok(rid, json.loads(cronjob(action="list")))
if action == "add":
return _ok(rid, json.loads(cronjob(action="create", name=jid,
schedule=params.get("schedule", ""), prompt=params.get("prompt", ""))))
if action in ("remove", "pause", "resume"):
return _ok(rid, json.loads(cronjob(action=action, job_id=jid)))
return _err(rid, 4016, f"unknown cron action: {action}")
except Exception as e:
return _err(rid, 5023, str(e))
@method("skills.manage")
def _(rid, params: dict) -> dict:
action, query = params.get("action", "list"), params.get("query", "")
try:
if action == "list":
from hermes_cli.banner import get_available_skills
return _ok(rid, {"skills": get_available_skills()})
if action == "search":
from hermes_cli.skills_hub import unified_search, GitHubAuth, create_source_router
raw = unified_search(query, create_source_router(GitHubAuth()), source_filter="all", limit=20) or []
return _ok(rid, {"results": [{"name": r.name, "description": r.description} for r in raw]})
if action == "install":
from hermes_cli.skills_hub import do_install
class _Q:
def print(self, *a, **k): pass
do_install(query, skip_confirm=True, console=_Q())
return _ok(rid, {"installed": True, "name": query})
if action == "browse":
from hermes_cli.skills_hub import browse_skills
pg = int(params.get("page", 0) or 0) or (int(query) if query.isdigit() else 1)
return _ok(rid, browse_skills(page=pg, page_size=int(params.get("page_size", 20))))
if action == "inspect":
from hermes_cli.skills_hub import inspect_skill
return _ok(rid, {"info": inspect_skill(query) or {}})
return _err(rid, 4017, f"unknown skills action: {action}")
except Exception as e:
return _err(rid, 5024, str(e))
# ── Methods: shell ───────────────────────────────────────────────────
@method("shell.exec")
def _(rid, params: dict) -> dict:
cmd = params.get("command", "")
if not cmd:
return _err(rid, 4004, "empty command")
try:
from tools.approval import detect_dangerous_command
is_dangerous, _, desc = detect_dangerous_command(cmd)
if is_dangerous:
return _err(rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands.")
except ImportError:
pass
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd())
return _ok(rid, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode})
except subprocess.TimeoutExpired:
return _err(rid, 5002, "command timed out (30s)")
except Exception as e:
return _err(rid, 5003, str(e))