mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
feat(desktop): wire gateway support
Add the backend session, cwd, and attachment plumbing needed by the desktop shell while keeping generated build state out of git.
This commit is contained in:
parent
7b61f86529
commit
6c624f197c
3 changed files with 211 additions and 29 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -54,6 +54,7 @@ environments/benchmarks/evals/
|
|||
|
||||
# Web UI build output
|
||||
hermes_cli/web_dist/
|
||||
apps/desktop/dist/
|
||||
|
||||
# Web UI assets — synced from @nous-research/ui at build time via
|
||||
# `npm run sync-assets` (see web/package.json).
|
||||
|
|
@ -70,3 +71,12 @@ mini-swe-agent/
|
|||
result
|
||||
website/static/api/skills-index.json
|
||||
models-dev-upstream/
|
||||
|
||||
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
|
||||
.codex/
|
||||
.cursor/
|
||||
.gemini/
|
||||
.zed/
|
||||
.mcp.json
|
||||
opencode.json
|
||||
config/mcporter.json
|
||||
|
|
|
|||
|
|
@ -70,8 +70,12 @@ app = FastAPI(title="Hermes Agent", version=__version__)
|
|||
# Session token for protecting sensitive endpoints (reveal).
|
||||
# Generated fresh on every server start — dies when the process exits.
|
||||
# Injected into the SPA HTML so only the legitimate web UI can use it.
|
||||
# Native desktop shells can pre-seed the token because they own the local
|
||||
# child process and do not need to scrape index.html before opening /api/ws.
|
||||
# ---------------------------------------------------------------------------
|
||||
_SESSION_TOKEN = secrets.token_urlsafe(32)
|
||||
_SESSION_TOKEN = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(
|
||||
32
|
||||
)
|
||||
_SESSION_HEADER_NAME = "X-Hermes-Session-Token"
|
||||
|
||||
# In-browser Chat tab (/chat, /api/pty, …). Off unless ``hermes dashboard --tui``
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import atexit
|
|||
import concurrent.futures
|
||||
import contextvars
|
||||
import copy
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -551,7 +552,7 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
|||
_wire_callbacks(sid)
|
||||
_notify_session_boundary("on_session_reset", key)
|
||||
|
||||
info = _session_info(agent)
|
||||
info = _session_info(agent, current)
|
||||
warn = _probe_credentials(agent)
|
||||
if warn:
|
||||
info["credential_warning"] = warn
|
||||
|
|
@ -608,6 +609,81 @@ def _normalize_completion_path(path_part: str) -> str:
|
|||
return expanded
|
||||
|
||||
|
||||
def _completion_cwd(params: dict | None = None) -> str:
|
||||
raw = (
|
||||
(params or {}).get("cwd")
|
||||
or _sessions.get((params or {}).get("session_id") or "", {}).get("cwd")
|
||||
or os.environ.get("TERMINAL_CWD")
|
||||
or os.getcwd()
|
||||
)
|
||||
try:
|
||||
resolved = os.path.abspath(os.path.expanduser(str(raw)))
|
||||
if os.path.isdir(resolved):
|
||||
return resolved
|
||||
except Exception:
|
||||
pass
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
def _git_branch_for_cwd(cwd: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", cwd, "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1.5,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
branch = result.stdout.strip()
|
||||
if branch:
|
||||
return branch
|
||||
head = subprocess.run(
|
||||
["git", "-C", cwd, "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1.5,
|
||||
check=False,
|
||||
)
|
||||
return head.stdout.strip() if head.returncode == 0 else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _session_cwd(session: dict | None) -> str:
|
||||
if session and session.get("cwd"):
|
||||
return str(session["cwd"])
|
||||
return _completion_cwd()
|
||||
|
||||
|
||||
def _register_session_cwd(session: dict | None) -> None:
|
||||
if not session:
|
||||
return
|
||||
try:
|
||||
from tools.terminal_tool import register_task_env_overrides
|
||||
|
||||
register_task_env_overrides(
|
||||
session["session_key"], {"cwd": _session_cwd(session)}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _set_session_cwd(session: dict, cwd: str) -> str:
|
||||
resolved = os.path.abspath(os.path.expanduser(str(cwd)))
|
||||
if not os.path.isdir(resolved):
|
||||
raise ValueError(f"working directory does not exist: {cwd}")
|
||||
session["cwd"] = resolved
|
||||
_register_session_cwd(session)
|
||||
try:
|
||||
from tools.terminal_tool import cleanup_vm
|
||||
|
||||
cleanup_vm(session["session_key"])
|
||||
except Exception:
|
||||
pass
|
||||
return resolved
|
||||
|
||||
|
||||
# ── Config I/O ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -1079,7 +1155,7 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
|
|||
api_mode=result.api_mode,
|
||||
)
|
||||
_restart_slash_worker(session)
|
||||
_emit("session.info", sid, _session_info(agent))
|
||||
_emit("session.info", sid, _session_info(agent, session))
|
||||
|
||||
os.environ["HERMES_MODEL"] = result.new_model
|
||||
os.environ["HERMES_INFERENCE_MODEL"] = result.new_model
|
||||
|
|
@ -1298,7 +1374,15 @@ def _probe_config_health(cfg: dict) -> str:
|
|||
return " ".join(warnings).strip()
|
||||
|
||||
|
||||
def _session_info(agent) -> dict:
|
||||
def _session_info(agent, session: dict | None = None) -> dict:
|
||||
if session is None:
|
||||
for candidate in _sessions.values():
|
||||
if candidate.get("agent") is agent:
|
||||
session = candidate
|
||||
break
|
||||
cwd = _session_cwd(session)
|
||||
cfg_personality = ((_load_cfg().get("display") or {}).get("personality") or "")
|
||||
personality = (session or {}).get("personality", cfg_personality)
|
||||
reasoning_config = getattr(agent, "reasoning_config", None)
|
||||
reasoning_effort = ""
|
||||
if (
|
||||
|
|
@ -1314,7 +1398,9 @@ def _session_info(agent) -> dict:
|
|||
"fast": service_tier == "priority",
|
||||
"tools": {},
|
||||
"skills": {},
|
||||
"cwd": os.getcwd(),
|
||||
"cwd": cwd,
|
||||
"branch": _git_branch_for_cwd(cwd),
|
||||
"personality": str(personality or ""),
|
||||
"version": "",
|
||||
"release_date": "",
|
||||
"update_behind": None,
|
||||
|
|
@ -1651,10 +1737,11 @@ def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str
|
|||
|
||||
|
||||
def _apply_personality_to_session(
|
||||
sid: str, session: dict, new_prompt: str
|
||||
sid: str, session: dict, new_prompt: str, personality: str = ""
|
||||
) -> tuple[bool, dict | None]:
|
||||
if not session:
|
||||
return False, None
|
||||
session["personality"] = personality
|
||||
|
||||
try:
|
||||
info = _reset_session_agent(sid, session)
|
||||
|
|
@ -1664,7 +1751,7 @@ def _apply_personality_to_session(
|
|||
agent = session["agent"]
|
||||
agent.ephemeral_system_prompt = new_prompt or None
|
||||
agent._cached_system_prompt = None
|
||||
info = _session_info(agent)
|
||||
info = _session_info(agent, session)
|
||||
_emit("session.info", sid, info)
|
||||
return False, info
|
||||
return False, None
|
||||
|
|
@ -1731,7 +1818,7 @@ def _reset_session_agent(sid: str, session: dict) -> dict:
|
|||
with session["history_lock"]:
|
||||
session["history"] = []
|
||||
session["history_version"] = int(session.get("history_version", 0)) + 1
|
||||
info = _session_info(new_agent)
|
||||
info = _session_info(new_agent, session)
|
||||
_emit("session.info", sid, info)
|
||||
_restart_slash_worker(session)
|
||||
return info
|
||||
|
|
@ -1782,6 +1869,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
|
|||
"running": False,
|
||||
"attached_images": [],
|
||||
"image_counter": 0,
|
||||
"cwd": _completion_cwd(),
|
||||
"cols": cols,
|
||||
"slash_worker": None,
|
||||
"show_reasoning": _load_show_reasoning(),
|
||||
|
|
@ -1792,6 +1880,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
|
|||
# session (stdio for Ink, JSON-RPC WS for the dashboard sidebar).
|
||||
"transport": current_transport() or _stdio_transport,
|
||||
}
|
||||
_register_session_cwd(_sessions[sid])
|
||||
try:
|
||||
_sessions[sid]["slash_worker"] = _SlashWorker(
|
||||
key, getattr(agent, "model", _resolve_model())
|
||||
|
|
@ -1823,7 +1912,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
|
|||
pass
|
||||
_wire_callbacks(sid)
|
||||
_notify_session_boundary("on_session_reset", key)
|
||||
_emit("session.info", sid, _session_info(agent))
|
||||
_emit("session.info", sid, _session_info(agent, _sessions[sid]))
|
||||
|
||||
|
||||
def _new_session_key() -> str:
|
||||
|
|
@ -1831,7 +1920,7 @@ def _new_session_key() -> str:
|
|||
|
||||
|
||||
def _with_checkpoints(session, fn):
|
||||
return fn(session["agent"]._checkpoint_mgr, os.getenv("TERMINAL_CWD", os.getcwd()))
|
||||
return fn(session["agent"]._checkpoint_mgr, _session_cwd(session))
|
||||
|
||||
|
||||
def _resolve_checkpoint_hash(mgr, cwd: str, ref: str) -> str:
|
||||
|
|
@ -1943,6 +2032,7 @@ def _(rid, params: dict) -> dict:
|
|||
"history_lock": threading.Lock(),
|
||||
"history_version": 0,
|
||||
"image_counter": 0,
|
||||
"cwd": _completion_cwd(params),
|
||||
"pending_title": None,
|
||||
"running": False,
|
||||
"session_key": key,
|
||||
|
|
@ -1952,6 +2042,7 @@ def _(rid, params: dict) -> dict:
|
|||
"tool_started_at": {},
|
||||
"transport": current_transport() or _stdio_transport,
|
||||
}
|
||||
_register_session_cwd(_sessions[sid])
|
||||
|
||||
# Return the lightweight session immediately so Ink can paint the composer
|
||||
# + skeleton panel, then build the real AIAgent just after this response is
|
||||
|
|
@ -1970,11 +2061,13 @@ def _(rid, params: dict) -> dict:
|
|||
rid,
|
||||
{
|
||||
"session_id": sid,
|
||||
"stored_session_id": key,
|
||||
"info": {
|
||||
"model": _resolve_model(),
|
||||
"tools": {},
|
||||
"skills": {},
|
||||
"cwd": os.getenv("TERMINAL_CWD", os.getcwd()),
|
||||
"cwd": _sessions[sid]["cwd"],
|
||||
"branch": _git_branch_for_cwd(_sessions[sid]["cwd"]),
|
||||
"lazy": True,
|
||||
},
|
||||
},
|
||||
|
|
@ -2110,11 +2203,35 @@ def _(rid, params: dict) -> dict:
|
|||
"resumed": target,
|
||||
"message_count": len(messages),
|
||||
"messages": messages,
|
||||
"info": _session_info(agent),
|
||||
"info": _session_info(agent, _sessions.get(sid)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@method("session.cwd.set")
|
||||
def _(rid, params: dict) -> dict:
|
||||
session, err = _sess_nowait(params, rid)
|
||||
if err:
|
||||
return err
|
||||
if session.get("running"):
|
||||
return _err(rid, 4009, "session busy")
|
||||
raw = str(params.get("cwd", "") or "").strip()
|
||||
if not raw:
|
||||
return _err(rid, 4016, "cwd required")
|
||||
try:
|
||||
cwd = _set_session_cwd(session, raw)
|
||||
except ValueError as e:
|
||||
return _err(rid, 4017, str(e))
|
||||
agent = session.get("agent")
|
||||
info = _session_info(agent, session) if agent is not None else {
|
||||
"cwd": cwd,
|
||||
"branch": _git_branch_for_cwd(cwd),
|
||||
"lazy": True,
|
||||
}
|
||||
_emit("session.info", params.get("session_id", ""), info)
|
||||
return _ok(rid, info)
|
||||
|
||||
|
||||
@method("session.delete")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Delete a stored session and its on-disk transcript files.
|
||||
|
|
@ -2330,7 +2447,7 @@ def _(rid, params: dict) -> dict:
|
|||
summary = summarize_manual_compression(
|
||||
before_messages, messages, before_tokens, after_tokens
|
||||
)
|
||||
info = _session_info(agent)
|
||||
info = _session_info(agent, session)
|
||||
_emit("session.info", sid, info)
|
||||
return _ok(
|
||||
rid,
|
||||
|
|
@ -2746,6 +2863,11 @@ def _(rid, params: dict) -> dict:
|
|||
session, err = _sess_nowait(params, rid)
|
||||
if err:
|
||||
return err
|
||||
# Re-bind to the current client transport for this request. This keeps
|
||||
# streaming events on the active websocket even if an earlier disconnect
|
||||
# or fallback moved the session transport to stdio.
|
||||
if (t := current_transport()) is not None:
|
||||
session["transport"] = t
|
||||
with session["history_lock"]:
|
||||
if session.get("running"):
|
||||
return _err(rid, 4009, "session busy")
|
||||
|
|
@ -2786,6 +2908,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|||
|
||||
approval_token = set_current_session_key(session["session_key"])
|
||||
session_tokens = _set_session_context(session["session_key"])
|
||||
cwd = _session_cwd(session)
|
||||
_register_session_cwd(session)
|
||||
cols = session.get("cols", 80)
|
||||
streamer = make_stream_renderer(cols)
|
||||
prompt = text
|
||||
|
|
@ -2803,8 +2927,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|||
)
|
||||
ctx = preprocess_context_references(
|
||||
prompt,
|
||||
cwd=os.environ.get("TERMINAL_CWD", os.getcwd()),
|
||||
allowed_root=os.environ.get("TERMINAL_CWD", os.getcwd()),
|
||||
cwd=cwd,
|
||||
allowed_root=cwd,
|
||||
context_length=ctx_len,
|
||||
)
|
||||
if ctx.blocked:
|
||||
|
|
@ -2880,11 +3004,16 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|||
payload["rendered"] = r
|
||||
_emit("message.delta", sid, payload)
|
||||
|
||||
result = agent.run_conversation(
|
||||
run_message,
|
||||
conversation_history=list(history),
|
||||
stream_callback=_stream,
|
||||
)
|
||||
run_kwargs = {
|
||||
"conversation_history": list(history),
|
||||
"stream_callback": _stream,
|
||||
}
|
||||
try:
|
||||
if "task_id" in inspect.signature(agent.run_conversation).parameters:
|
||||
run_kwargs["task_id"] = session["session_key"]
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
result = agent.run_conversation(run_message, **run_kwargs)
|
||||
|
||||
last_reasoning = None
|
||||
status_note = None
|
||||
|
|
@ -3005,6 +3134,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|||
_clear_session_context(session_tokens)
|
||||
with session["history_lock"]:
|
||||
session["running"] = False
|
||||
_emit("session.info", sid, _session_info(agent, session))
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
|
@ -3092,6 +3222,26 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 5027, str(e))
|
||||
|
||||
|
||||
@method("image.detach")
|
||||
def _(rid, params: dict) -> dict:
|
||||
session, err = _sess(params, rid)
|
||||
if err:
|
||||
return err
|
||||
raw = str(params.get("path", "") or "").strip()
|
||||
if not raw:
|
||||
return _err(rid, 4015, "path required")
|
||||
images = session.setdefault("attached_images", [])
|
||||
before = len(images)
|
||||
session["attached_images"] = [path for path in images if path != raw]
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"detached": len(session["attached_images"]) != before,
|
||||
"count": len(session["attached_images"]),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@method("input.detect_drop")
|
||||
def _(rid, params: dict) -> dict:
|
||||
session, err = _sess_nowait(params, rid)
|
||||
|
|
@ -3331,7 +3481,7 @@ def _(rid, params: dict) -> dict:
|
|||
_emit(
|
||||
"session.info",
|
||||
params.get("session_id", ""),
|
||||
_session_info(agent),
|
||||
_session_info(agent, session),
|
||||
)
|
||||
return _ok(rid, {"key": key, "value": nv})
|
||||
|
||||
|
|
@ -3570,6 +3720,17 @@ def _(rid, params: dict) -> dict:
|
|||
_write_config_key("display.tui_status_indicator", raw)
|
||||
return _ok(rid, {"key": key, "value": raw})
|
||||
|
||||
if key in ("cwd", "terminal.cwd", "workdir"):
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return _err(rid, 4002, "cwd required")
|
||||
cwd = os.path.abspath(os.path.expanduser(raw))
|
||||
if not os.path.isdir(cwd):
|
||||
return _err(rid, 4002, f"working directory does not exist: {raw}")
|
||||
_write_config_key("terminal.cwd", cwd)
|
||||
os.environ["TERMINAL_CWD"] = cwd
|
||||
return _ok(rid, {"key": "terminal.cwd", "value": cwd})
|
||||
|
||||
if key in ("prompt", "personality", "skin"):
|
||||
try:
|
||||
cfg = _load_cfg()
|
||||
|
|
@ -3588,7 +3749,7 @@ def _(rid, params: dict) -> dict:
|
|||
_write_config_key("agent.system_prompt", new_prompt)
|
||||
nv = str(value or "default")
|
||||
history_reset, info = _apply_personality_to_session(
|
||||
sid_key, session, new_prompt
|
||||
sid_key, session, new_prompt, pname
|
||||
)
|
||||
else:
|
||||
_write_config_key(f"display.{key}", value)
|
||||
|
|
@ -3809,7 +3970,11 @@ def _(rid, params: dict) -> dict:
|
|||
agent = session["agent"]
|
||||
if hasattr(agent, "refresh_tools"):
|
||||
agent.refresh_tools()
|
||||
_emit("session.info", params.get("session_id", ""), _session_info(agent))
|
||||
_emit(
|
||||
"session.info",
|
||||
params.get("session_id", ""),
|
||||
_session_info(agent, session),
|
||||
)
|
||||
|
||||
# Honor `always=true` by persisting the opt-out to config.
|
||||
if bool(params.get("always", False)):
|
||||
|
|
@ -4394,6 +4559,7 @@ def _(rid, params: dict) -> dict:
|
|||
|
||||
items: list[dict] = []
|
||||
try:
|
||||
root = _completion_cwd(params)
|
||||
is_context = word.startswith("@")
|
||||
query = word[1:] if is_context else word
|
||||
|
||||
|
|
@ -4427,7 +4593,6 @@ def _(rid, params: dict) -> dict:
|
|||
# `/`, `./`, `~/`, `/abs`) fall through to the directory-listing
|
||||
# path so explicit navigation intent is preserved.
|
||||
if is_context and path_part and "/" not in path_part and prefix_tag != "folder":
|
||||
root = os.getcwd()
|
||||
ranked: list[tuple[tuple[int, int], str, str]] = []
|
||||
for rel in _list_repo_files(root):
|
||||
basename = os.path.basename(rel)
|
||||
|
|
@ -4460,6 +4625,9 @@ def _(rid, params: dict) -> dict:
|
|||
search_dir = os.path.dirname(expanded) or "."
|
||||
match = os.path.basename(expanded)
|
||||
|
||||
search_dir = (
|
||||
search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir)
|
||||
)
|
||||
if not os.path.isdir(search_dir):
|
||||
return _ok(rid, {"items": []})
|
||||
|
||||
|
|
@ -4477,7 +4645,7 @@ def _(rid, params: dict) -> dict:
|
|||
# which used to defeat the prefix and let `@folder:` list files.
|
||||
if prefix_tag and want_dir != is_dir:
|
||||
continue
|
||||
rel = os.path.relpath(full)
|
||||
rel = os.path.relpath(full, root).replace(os.sep, "/")
|
||||
suffix = "/" if is_dir else ""
|
||||
|
||||
if is_context and prefix_tag:
|
||||
|
|
@ -4741,8 +4909,8 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str:
|
|||
result = _apply_model_switch(sid, session, arg)
|
||||
return result.get("warning", "")
|
||||
elif name == "personality" and arg and agent:
|
||||
_, new_prompt = _validate_personality(arg, _load_cfg())
|
||||
_apply_personality_to_session(sid, session, new_prompt)
|
||||
pname, new_prompt = _validate_personality(arg, _load_cfg())
|
||||
_apply_personality_to_session(sid, session, new_prompt, pname)
|
||||
elif name == "prompt" and agent:
|
||||
cfg = _load_cfg()
|
||||
new_prompt = (cfg.get("agent") or {}).get("system_prompt", "") or ""
|
||||
|
|
@ -4751,14 +4919,14 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str:
|
|||
elif name == "compress" and agent:
|
||||
_compress_session_history(session, arg)
|
||||
_sync_session_key_after_compress(sid, session)
|
||||
_emit("session.info", sid, _session_info(agent))
|
||||
_emit("session.info", sid, _session_info(agent, session))
|
||||
elif name == "fast" and agent:
|
||||
mode = arg.lower()
|
||||
if mode in {"fast", "on"}:
|
||||
agent.service_tier = "priority"
|
||||
elif mode in {"normal", "off"}:
|
||||
agent.service_tier = None
|
||||
_emit("session.info", sid, _session_info(agent))
|
||||
_emit("session.info", sid, _session_info(agent, session))
|
||||
elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"):
|
||||
agent.reload_mcp_tools()
|
||||
elif name == "stop":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue