mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
refactor(tui): split @method handlers into methods_* modules (mechanical move, registry set-equality verified)
This commit is contained in:
parent
c3ffe27383
commit
f67ca220ab
7 changed files with 6674 additions and 6513 deletions
53
tui_gateway/method_ctx.py
Normal file
53
tui_gateway/method_ctx.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Seam for the server.py @method handler split (mechanical move).
|
||||
|
||||
server.py's ~130 JSON-RPC handlers close over its module globals
|
||||
(``_sessions``, ``_ok``, ``_err``, config helpers, ...). To move them
|
||||
out of the 19K-line module without rewriting a single handler body,
|
||||
each ``methods_*`` module defines its handlers under a local
|
||||
:class:`HandlerRegistry` and server.py calls :meth:`HandlerRegistry.install`
|
||||
at the end of its own import, once every global the handlers close over
|
||||
exists. ``install()`` rebinds each handler's ``__globals__`` to
|
||||
server.py's namespace with ``types.FunctionType``, so handler bodies
|
||||
stay byte-identical and ``global X`` statements inside handlers keep
|
||||
mutating server.py state exactly as before the split.
|
||||
|
||||
No import cycle: ``methods_*`` modules never import server at module
|
||||
level — server imports them and passes itself to ``register()``.
|
||||
"""
|
||||
|
||||
import types
|
||||
|
||||
|
||||
class HandlerRegistry:
|
||||
"""Deferred @method registrar used by the methods_* split modules."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: list[tuple[str, types.FunctionType]] = []
|
||||
|
||||
def method(self, name: str):
|
||||
"""Drop-in for server.py's ``@method`` decorator (defers registration)."""
|
||||
|
||||
def dec(fn):
|
||||
self._pending.append((name, fn))
|
||||
return fn
|
||||
|
||||
return dec
|
||||
|
||||
def profile_scoped(self, fn):
|
||||
"""Drop-in for server.py's ``@_profile_scoped`` (applied at install)."""
|
||||
fn._hermes_profile_scoped = True
|
||||
return fn
|
||||
|
||||
def install(self, server) -> None:
|
||||
"""Rebind pending handlers onto ``server``'s globals and register them."""
|
||||
g = vars(server)
|
||||
for name, fn in self._pending:
|
||||
real = types.FunctionType(
|
||||
fn.__code__, g, fn.__name__, fn.__defaults__, fn.__closure__
|
||||
)
|
||||
real.__kwdefaults__ = fn.__kwdefaults__
|
||||
real.__doc__ = fn.__doc__
|
||||
real.__dict__.update(fn.__dict__)
|
||||
if getattr(fn, "_hermes_profile_scoped", False):
|
||||
real = server._profile_scoped(real)
|
||||
server._methods[name] = real
|
||||
471
tui_gateway/methods_complete.py
Normal file
471
tui_gateway/methods_complete.py
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
"""Completion / model-key / paste JSON-RPC handlers (moved verbatim from server.py).
|
||||
|
||||
Handler bodies are byte-identical to their pre-split server.py form; they
|
||||
are rebound onto server.py's globals at install time — see method_ctx.py.
|
||||
"""
|
||||
|
||||
from .method_ctx import HandlerRegistry
|
||||
|
||||
_registry = HandlerRegistry()
|
||||
method = _registry.method
|
||||
_profile_scoped = _registry.profile_scoped
|
||||
|
||||
|
||||
@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}
|
||||
)
|
||||
|
||||
|
||||
@method("complete.path")
|
||||
def _(rid, params: dict) -> dict:
|
||||
word = params.get("word", "")
|
||||
if not word:
|
||||
return _ok(rid, {"items": []})
|
||||
|
||||
items: list[dict] = []
|
||||
try:
|
||||
root = _completion_cwd(params)
|
||||
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})
|
||||
|
||||
# Accept both `@folder:path` and the bare `@folder` form so the user
|
||||
# sees directory listings as soon as they finish typing the keyword,
|
||||
# without first accepting the static `@folder:` hint.
|
||||
if is_context and query in {"file", "folder"}:
|
||||
prefix_tag, path_part = query, ""
|
||||
elif is_context and query.startswith(("file:", "folder:")):
|
||||
prefix_tag, _, tail = query.partition(":")
|
||||
path_part = tail
|
||||
else:
|
||||
prefix_tag = ""
|
||||
path_part = query if is_context else query
|
||||
|
||||
# `@/foo` almost always means "foo, from here" rather than the absolute
|
||||
# `/foo`: the `@` already says "this is a path", so the slash reads as a
|
||||
# separator people type out of habit. Take the absolute reading only
|
||||
# when something is actually there, else drop the slash and resolve
|
||||
# relative to the cwd — otherwise `@/Desktop` dead-ends on a directory
|
||||
# that exists one level down. Real absolute paths (`@/usr/local`,
|
||||
# `@/etc/hosts`) still resolve, since those prefixes do exist.
|
||||
if (
|
||||
is_context
|
||||
and path_part.startswith("/")
|
||||
and not path_part.startswith("//")
|
||||
and not _abs_completion_prefix_exists(path_part)
|
||||
):
|
||||
path_part = path_part.lstrip("/")
|
||||
|
||||
# Fuzzy basename search across the repo when the user types a bare
|
||||
# name with no path separator — `@appChrome` surfaces every file
|
||||
# whose basename matches, regardless of directory depth. Matches what
|
||||
# editors like Cursor / VS Code do for Cmd-P. Path-ish queries (with
|
||||
# `/`, `./`, `~/`, `/abs`) fall through to the directory-listing
|
||||
# path so explicit navigation intent is preserved.
|
||||
if (
|
||||
is_context
|
||||
and path_part
|
||||
and len(path_part.strip()) >= 2
|
||||
and "/" not in path_part
|
||||
and prefix_tag != "folder"
|
||||
):
|
||||
ranked: list[tuple[tuple[int, int], str, str, bool]] = []
|
||||
walked_dirs: set[str] = set()
|
||||
seen: set[str] = set()
|
||||
want_hidden = path_part.startswith(".")
|
||||
|
||||
def _consider(rel: str, name: str, is_dir: bool) -> None:
|
||||
if rel in seen or (name.startswith(".") and not want_hidden):
|
||||
return
|
||||
rank = _fuzzy_basename_rank(name, path_part)
|
||||
if rank is not None:
|
||||
seen.add(rel)
|
||||
ranked.append((rank, rel, name, is_dir))
|
||||
|
||||
# Seed with root's immediate children. `_list_repo_files` is capped
|
||||
# at _FUZZY_CACHE_MAX_FILES, and outside a git repo the fallback
|
||||
# walk can burn that whole budget on one deep subtree before ever
|
||||
# reaching a sibling — which is why `@Desk` in a non-repo $HOME
|
||||
# found nothing. One listdir keeps the top level always reachable.
|
||||
try:
|
||||
for entry in os.listdir(root):
|
||||
if entry not in _FUZZY_FALLBACK_EXCLUDES:
|
||||
_consider(entry, entry, os.path.isdir(os.path.join(root, entry)))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for rel in _list_repo_files(root):
|
||||
_consider(rel, os.path.basename(rel), False)
|
||||
|
||||
# Directories are only implied by the file listing, so rank each
|
||||
# ancestor too. Without this a bare `@Desktop` finds nothing —
|
||||
# a folder with no name-matching file inside it is invisible to
|
||||
# a file-only scan, which is the "can't @ a folder by name" bug.
|
||||
parent = os.path.dirname(rel)
|
||||
while parent and parent not in walked_dirs:
|
||||
walked_dirs.add(parent)
|
||||
_consider(parent, os.path.basename(parent), True)
|
||||
parent = os.path.dirname(parent)
|
||||
|
||||
# Same rank tier: folders first, so `@Desktop` leads with the folder
|
||||
# rather than a file that merely fuzzy-matches the same letters.
|
||||
ranked.sort(key=lambda r: (r[0], not r[3], len(r[1]), r[1]))
|
||||
tag = prefix_tag or "file"
|
||||
for _, rel, basename, is_dir in ranked[:30]:
|
||||
items.append(
|
||||
{
|
||||
"text": f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}",
|
||||
"display": basename + ("/" if is_dir else ""),
|
||||
"meta": "dir" if is_dir else os.path.dirname(rel),
|
||||
}
|
||||
)
|
||||
|
||||
return _ok(rid, {"items": items})
|
||||
|
||||
expanded = _normalize_completion_path(path_part) if path_part else "."
|
||||
if expanded == "." or not expanded:
|
||||
search_dir, match = ".", ""
|
||||
elif expanded.endswith("/"):
|
||||
search_dir, match = expanded, ""
|
||||
else:
|
||||
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": []})
|
||||
|
||||
want_dir = prefix_tag == "folder"
|
||||
match_lower = match.lower()
|
||||
for entry in sorted(os.listdir(search_dir)):
|
||||
if match and not entry.lower().startswith(match_lower):
|
||||
continue
|
||||
if is_context and entry in _FUZZY_FALLBACK_EXCLUDES:
|
||||
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)
|
||||
# Explicit `@folder:` / `@file:` — honour the user's filter. Skip
|
||||
# the opposite kind instead of auto-rewriting the completion tag,
|
||||
# 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, root).replace(os.sep, "/")
|
||||
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 as e:
|
||||
return _err(rid, 5021, str(e))
|
||||
|
||||
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
|
||||
|
||||
from agent.skill_commands import get_skill_commands
|
||||
from agent.skill_bundles import get_skill_bundles
|
||||
|
||||
completer = SlashCommandCompleter(
|
||||
skill_commands_provider=lambda: get_skill_commands(),
|
||||
skill_bundles_provider=lambda: get_skill_bundles(),
|
||||
)
|
||||
doc = Document(text, len(text))
|
||||
# Skill commands and bundles are the only completions offered for an
|
||||
# inline `/skill` reference typed mid-message, so the class has to
|
||||
# reach the TUI as data. Derived from the same providers the completer
|
||||
# uses — no sniffing the ⚡/▣ meta glyphs, which are display text.
|
||||
skill_names = {
|
||||
key.lstrip("/").lower()
|
||||
for key in (*get_skill_commands(), *get_skill_bundles())
|
||||
}
|
||||
items = [
|
||||
{
|
||||
"text": c.text,
|
||||
# prompt_toolkit gives us FormattedText (a list of (style,
|
||||
# text) tuples) for display/display_meta. Serialize both as
|
||||
# plain strings — the TUI's CompletionItem.display contract
|
||||
# is a string, and sending the raw list trips Ink's row
|
||||
# layout into 1-char truncation of the next column.
|
||||
"display": to_plain_text(c.display) if c.display else c.text,
|
||||
"meta": to_plain_text(c.display_meta) if c.display_meta else "",
|
||||
"kind": (
|
||||
"skill"
|
||||
if c.text.strip().lstrip("/").lower() in skill_names
|
||||
else "command"
|
||||
),
|
||||
}
|
||||
for c in completer.get_completions(doc, None)
|
||||
][:30]
|
||||
text_lower = text.lower()
|
||||
extras = [
|
||||
{
|
||||
"text": "/density",
|
||||
"display": "/density",
|
||||
"meta": "Toggle compact display mode",
|
||||
"kind": "command",
|
||||
},
|
||||
{
|
||||
"text": "/details",
|
||||
"display": "/details",
|
||||
"meta": "Control agent detail visibility",
|
||||
"kind": "command",
|
||||
},
|
||||
{
|
||||
"text": "/logs",
|
||||
"display": "/logs",
|
||||
"meta": "Show recent gateway log lines",
|
||||
"kind": "command",
|
||||
},
|
||||
{
|
||||
"text": "/mouse",
|
||||
"display": "/mouse",
|
||||
"meta": "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]",
|
||||
"kind": "command",
|
||||
},
|
||||
]
|
||||
for extra in extras:
|
||||
if extra["text"].startswith(text_lower) and not any(
|
||||
item["text"] == extra["text"] for item in items
|
||||
):
|
||||
items.append(extra)
|
||||
|
||||
details_items = _details_completions(text)
|
||||
if details_items is not None:
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"items": details_items,
|
||||
"replace_from": text.rfind(" ") + 1 if " " in text else len(text),
|
||||
},
|
||||
)
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5020, str(e))
|
||||
|
||||
|
||||
@method("model.options")
|
||||
def _(rid, params: dict) -> dict:
|
||||
try:
|
||||
from hermes_cli.inventory import build_model_options_payload
|
||||
|
||||
session = _sessions.get(params.get("session_id", ""))
|
||||
agent = session.get("agent") if session else None
|
||||
# Layer agent-session state on top of disk config — once an agent
|
||||
# is spawned, IT owns the live provider/model/base_url. Empty
|
||||
# agent attributes must NOT clobber disk config (with_overrides
|
||||
# is truthy-only).
|
||||
ctx = _model_picker_context(agent)
|
||||
payload = build_model_options_payload(
|
||||
ctx,
|
||||
explicit_only=bool(params.get("explicit_only")),
|
||||
include_unconfigured=bool(params.get("include_unconfigured")),
|
||||
refresh=bool(params.get("refresh")),
|
||||
)
|
||||
return _ok(rid, payload)
|
||||
except Exception as e:
|
||||
return _err(rid, 5033, str(e))
|
||||
|
||||
|
||||
@method("model.save_key")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Save an API key for a provider, then return its refreshed model list.
|
||||
|
||||
Params:
|
||||
slug: provider slug (e.g. "deepseek", "xai")
|
||||
api_key: the key value to save
|
||||
|
||||
Returns the provider dict with models populated (same shape as
|
||||
model.options entries) on success.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_cli.config import is_managed
|
||||
from hermes_cli.inventory import build_models_payload
|
||||
|
||||
slug = (params.get("slug") or "").strip()
|
||||
api_key = (params.get("api_key") or "").strip()
|
||||
if not slug or not api_key:
|
||||
return _err(rid, 4001, "slug and api_key are required")
|
||||
|
||||
if is_managed():
|
||||
return _err(rid, 4006, "managed install — credentials are read-only")
|
||||
|
||||
pconfig = PROVIDER_REGISTRY.get(slug)
|
||||
if not pconfig:
|
||||
return _err(rid, 4002, f"unknown provider: {slug}")
|
||||
if pconfig.auth_type != "api_key":
|
||||
return _err(
|
||||
rid,
|
||||
4003,
|
||||
f"{pconfig.name} uses {pconfig.auth_type} auth — "
|
||||
f"run `hermes model` to configure",
|
||||
)
|
||||
if not pconfig.api_key_env_vars:
|
||||
return _err(rid, 4004, f"no env var defined for {pconfig.name}")
|
||||
|
||||
# Save the key to ~/.hermes/.env via the unified credential lifecycle
|
||||
# so any stale config.yaml mirror of the previous key (model.api_key,
|
||||
# custom_providers[*].api_key) is rotated in the same action (#62269).
|
||||
env_var = pconfig.api_key_env_vars[0]
|
||||
from hermes_cli.credential_lifecycle import save_provider_env_credential
|
||||
|
||||
save_provider_env_credential(env_var, api_key)
|
||||
# Also set in current process so the refreshed inventory sees it.
|
||||
import os
|
||||
|
||||
os.environ[env_var] = api_key
|
||||
|
||||
# Refresh provider data via the shared inventory builder so this
|
||||
# surface stays in lock-step with model.options + dashboard
|
||||
# /api/model/options. picker_hints=True ensures the returned row
|
||||
# carries `authenticated` for the TUI frontend.
|
||||
session = _sessions.get(params.get("session_id", ""))
|
||||
agent = session.get("agent") if session else None
|
||||
ctx = _model_picker_context(agent)
|
||||
payload = build_models_payload(
|
||||
ctx, picker_hints=True, max_models=50,
|
||||
)
|
||||
provider_data = next(
|
||||
(p for p in payload["providers"] if p["slug"] == slug), None
|
||||
)
|
||||
if provider_data is None:
|
||||
# Key was saved but provider didn't appear — still return success.
|
||||
provider_data = {
|
||||
"slug": slug,
|
||||
"name": pconfig.name,
|
||||
"is_current": False,
|
||||
"models": [],
|
||||
"total_models": 0,
|
||||
"authenticated": True,
|
||||
}
|
||||
# picker_hints sets `authenticated` from the row state, but the
|
||||
# synthetic fallback above doesn't go through that path.
|
||||
provider_data["authenticated"] = True
|
||||
return _ok(rid, {"provider": provider_data})
|
||||
except Exception as e:
|
||||
return _err(rid, 5034, str(e))
|
||||
|
||||
|
||||
@method("model.disconnect")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Remove credentials for a provider.
|
||||
|
||||
Params:
|
||||
slug: provider slug (e.g. "deepseek", "xai")
|
||||
|
||||
Returns success status and the provider's slug.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth
|
||||
from hermes_cli.credential_lifecycle import remove_provider_env_credential
|
||||
|
||||
slug = (params.get("slug") or "").strip()
|
||||
if not slug:
|
||||
return _err(rid, 4001, "slug is required")
|
||||
|
||||
pconfig = PROVIDER_REGISTRY.get(slug)
|
||||
cleared_env = False
|
||||
cleared_auth = False
|
||||
|
||||
# Remove API key env vars from .env and process, plus every mirror
|
||||
# (env-seeded credential_pool entries, provider model cache rows,
|
||||
# value-matched config.yaml api_key copies) via the unified helper —
|
||||
# otherwise the provider resurrects in the picker after restart
|
||||
# (#51071 / #59761).
|
||||
if pconfig and pconfig.api_key_env_vars:
|
||||
for ev in pconfig.api_key_env_vars:
|
||||
if remove_provider_env_credential(ev).get("found"):
|
||||
cleared_env = True
|
||||
|
||||
# Clear OAuth / credential pool state. This is a full provider
|
||||
# disconnect (TUI "disconnect" action), so removing OAuth grants
|
||||
# here is the documented intent — unlike the key-only delete paths.
|
||||
cleared_auth = clear_provider_auth(slug)
|
||||
|
||||
if not cleared_env and not cleared_auth:
|
||||
return _err(rid, 4005, f"no credentials found for {slug}")
|
||||
|
||||
provider_name = pconfig.name if pconfig else slug
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"slug": slug,
|
||||
"name": provider_name,
|
||||
"disconnected": True,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5035, str(e))
|
||||
|
||||
|
||||
def register(server) -> None:
|
||||
"""Bind this module's handlers onto ``server``'s globals and registry."""
|
||||
_registry.install(server)
|
||||
420
tui_gateway/methods_config.py
Normal file
420
tui_gateway/methods_config.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
"""Config / projects / setup JSON-RPC handlers (moved verbatim from server.py).
|
||||
|
||||
NOTE: ``config.set`` stays in server.py for now — the in-flight
|
||||
opt/model-resolution-core PR touches it; move it in a follow-up once merged.
|
||||
|
||||
Handler bodies are byte-identical to their pre-split server.py form; they
|
||||
are rebound onto server.py's globals at install time — see method_ctx.py.
|
||||
"""
|
||||
|
||||
from .method_ctx import HandlerRegistry
|
||||
|
||||
_registry = HandlerRegistry()
|
||||
method = _registry.method
|
||||
_profile_scoped = _registry.profile_scoped
|
||||
|
||||
|
||||
@method("projects.discover_repos")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Repos for the desktop overview: scanned-from-disk (cached) ∪ session-derived."""
|
||||
try:
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _ok(rid, {"repos": []})
|
||||
from hermes_cli import projects_db as pdb
|
||||
|
||||
policy = _repo_discovery_policy()
|
||||
policy_key = _repo_discovery_policy_key(policy)
|
||||
with pdb.connect_closing() as conn:
|
||||
pdb.reconcile_discovered_repos_policy(
|
||||
conn,
|
||||
policy_key,
|
||||
preserve_unversioned=_repo_discovery_policy_is_default(policy),
|
||||
)
|
||||
repos = _discover_repos_payload(
|
||||
db, conn=conn, include_cached=policy["enabled"]
|
||||
)
|
||||
return _ok(rid, {"repos": repos, "discovery_policy": policy})
|
||||
except Exception as e:
|
||||
return _err(rid, 5061, str(e))
|
||||
|
||||
|
||||
@method("projects.record_repos")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Persist git repo roots found by the client's filesystem scan, then return
|
||||
the merged repo list. The native crawl runs on the desktop (local fs); this
|
||||
caches the result so later reads are instant instead of re-walking disk."""
|
||||
try:
|
||||
from hermes_cli import projects_db as pdb
|
||||
|
||||
policy = _repo_discovery_policy()
|
||||
policy_key = _repo_discovery_policy_key(policy)
|
||||
incoming_raw = params.get("discovery_policy")
|
||||
incoming_policy = (
|
||||
_repo_discovery_policy(incoming_raw)
|
||||
if isinstance(incoming_raw, dict)
|
||||
else None
|
||||
)
|
||||
incoming_matches = (
|
||||
incoming_policy is not None
|
||||
and _repo_discovery_policy_key(incoming_policy) == policy_key
|
||||
)
|
||||
accept_legacy_default = (
|
||||
incoming_policy is None and _repo_discovery_policy_is_default(policy)
|
||||
)
|
||||
|
||||
pairs: list[tuple[str, str | None]] = []
|
||||
for item in params.get("repos") or []:
|
||||
if isinstance(item, str):
|
||||
pairs.append((item, None))
|
||||
elif isinstance(item, dict) and item.get("root"):
|
||||
pairs.append((str(item["root"]), item.get("label")))
|
||||
|
||||
with pdb.connect_closing() as conn:
|
||||
pdb.reconcile_discovered_repos_policy(
|
||||
conn,
|
||||
policy_key,
|
||||
preserve_unversioned=_repo_discovery_policy_is_default(policy),
|
||||
)
|
||||
accepted = bool(
|
||||
policy["enabled"] and (incoming_matches or accept_legacy_default)
|
||||
)
|
||||
if accepted:
|
||||
pdb.record_discovered_repos(
|
||||
conn, pairs, replace=True, policy_key=policy_key
|
||||
)
|
||||
elif not policy["enabled"]:
|
||||
pdb.clear_discovered_repos(conn, policy_key=policy_key)
|
||||
|
||||
db = _get_db()
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"repos": _discover_repos_payload(
|
||||
db, include_cached=policy["enabled"]
|
||||
)
|
||||
if db is not None
|
||||
else [],
|
||||
"accepted": accepted,
|
||||
"discovery_policy": policy,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5061, str(e))
|
||||
|
||||
|
||||
@method("projects.tree")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Authoritative project overview: project -> repo -> lane structure with
|
||||
counts + a few preview sessions per project, plus the flat set of session
|
||||
ids claimed by any project (so the desktop excludes them from flat Recents).
|
||||
Lanes carry no session rows here; drill-in uses ``projects.project_sessions``.
|
||||
"""
|
||||
try:
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _ok(rid, {"projects": [], "active_id": None, "scoped_session_ids": []})
|
||||
|
||||
tree, active_id = _build_project_tree(
|
||||
db,
|
||||
preview_limit=int(params.get("preview_limit") or 3),
|
||||
hydrate=False,
|
||||
session_limit=int(params.get("session_limit") or 2000),
|
||||
include_discovered=True,
|
||||
)
|
||||
return _ok(
|
||||
rid,
|
||||
{"projects": tree["projects"], "active_id": active_id, "scoped_session_ids": tree["scoped_session_ids"]},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5061, str(e))
|
||||
|
||||
|
||||
@method("projects.project_sessions")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Fully hydrated lanes (repo -> lane -> session rows) for one project,
|
||||
built from the same authoritative grouping as ``projects.tree`` so ids and
|
||||
membership match exactly. Used when the user enters a project."""
|
||||
try:
|
||||
project_id = str(params.get("project_id") or "")
|
||||
if not project_id:
|
||||
return _err(rid, 5063, "project_id required")
|
||||
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _ok(rid, {"project": None})
|
||||
|
||||
# Drill-in only needs the entered project (which has sessions), so skip
|
||||
# the zero-session discovery tier entirely.
|
||||
tree, _active = _build_project_tree(
|
||||
db, preview_limit=0, hydrate=True, session_limit=int(params.get("session_limit") or 5000),
|
||||
include_discovered=False,
|
||||
)
|
||||
proj = next((p for p in tree["projects"] if p["id"] == project_id), None)
|
||||
return _ok(rid, {"project": proj})
|
||||
except Exception as e:
|
||||
return _err(rid, 5061, str(e))
|
||||
|
||||
|
||||
@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 == "project":
|
||||
cfg_terminal = _load_cfg().get("terminal") or {}
|
||||
raw = str(params.get("cwd", "") or cfg_terminal.get("cwd", "") or "").strip()
|
||||
cwd = _completion_cwd({"cwd": raw} if raw else {})
|
||||
return _ok(rid, {"cwd": cwd, "branch": _git_branch_for_cwd(cwd)})
|
||||
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") or {}).get("skin", "default")}
|
||||
)
|
||||
if key == "indicator":
|
||||
# Normalize so a hand-edited config.yaml with stray casing or
|
||||
# an unknown value reads back the SAME value the TUI actually
|
||||
# rendered (frontend's `normalizeIndicatorStyle` falls back to
|
||||
# `_INDICATOR_DEFAULT` for the same inputs). Otherwise
|
||||
# `/indicator` would print one thing while the UI shows another.
|
||||
raw = (_load_cfg().get("display") or {}).get("tui_status_indicator", "")
|
||||
norm = str(raw).strip().lower()
|
||||
return _ok(
|
||||
rid,
|
||||
{"value": norm if norm in _INDICATOR_STYLES else _INDICATOR_DEFAULT},
|
||||
)
|
||||
if key == "personality":
|
||||
return _ok(
|
||||
rid,
|
||||
{"value": (_load_cfg().get("display") or {}).get("personality") or "none"},
|
||||
)
|
||||
if key == "reasoning":
|
||||
cfg = _load_cfg()
|
||||
session = _sessions.get(params.get("session_id", ""))
|
||||
reasoning_config = None
|
||||
if session is not None:
|
||||
if isinstance(session.get("create_reasoning_override"), dict):
|
||||
reasoning_config = session.get("create_reasoning_override")
|
||||
else:
|
||||
agent = session.get("agent")
|
||||
agent_reasoning = getattr(agent, "reasoning_config", None)
|
||||
if isinstance(agent_reasoning, dict):
|
||||
reasoning_config = agent_reasoning
|
||||
|
||||
if isinstance(reasoning_config, dict):
|
||||
if reasoning_config.get("enabled") is False:
|
||||
effort = "none"
|
||||
else:
|
||||
effort = str(reasoning_config.get("effort") or "medium")
|
||||
else:
|
||||
raw_effort = (cfg.get("agent") or {}).get("reasoning_effort", "")
|
||||
if raw_effort is False:
|
||||
# YAML `reasoning_effort: false`/`off`/`no` — thinking
|
||||
# disabled, not "unset, show the medium default".
|
||||
effort = "none"
|
||||
else:
|
||||
effort = str(raw_effort or "medium")
|
||||
display = (
|
||||
"show"
|
||||
if bool((cfg.get("display") or {}).get("show_reasoning", True))
|
||||
else "hide"
|
||||
)
|
||||
return _ok(rid, {"value": effort, "display": display})
|
||||
if key == "fast":
|
||||
# Prefer the session's live/pinned value — `config.set fast` is
|
||||
# session-scoped, so the global key may not reflect this chat. A
|
||||
# pre-build session keeps its pin in create_service_tier_override.
|
||||
session = _sessions.get(params.get("session_id", ""))
|
||||
tier = None
|
||||
if session is not None:
|
||||
agent = session.get("agent")
|
||||
if agent is not None:
|
||||
tier = getattr(agent, "service_tier", None)
|
||||
elif session.get("create_service_tier_override") is not None:
|
||||
tier = session["create_service_tier_override"]
|
||||
if tier is None:
|
||||
tier = _load_service_tier()
|
||||
return _ok(rid, {"value": "fast" if tier == "priority" else "normal"})
|
||||
if key == "busy":
|
||||
return _ok(rid, {"value": _load_busy_input_mode()})
|
||||
if key in {"approval_mode", "approvals.mode"}:
|
||||
try:
|
||||
return _ok(rid, {"value": _load_approval_mode()})
|
||||
except Exception as e:
|
||||
return _err(rid, 5001, str(e))
|
||||
if key == "details_mode":
|
||||
allowed_dm = frozenset({"hidden", "collapsed", "expanded"})
|
||||
raw = (
|
||||
str(
|
||||
(_load_cfg().get("display") or {}).get("details_mode", "collapsed")
|
||||
or "collapsed"
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
nv = raw if raw in allowed_dm else "collapsed"
|
||||
return _ok(rid, {"value": nv})
|
||||
if key == "thinking_mode":
|
||||
allowed_tm = frozenset({"collapsed", "truncated", "full"})
|
||||
cfg = _load_cfg()
|
||||
raw = (
|
||||
str((cfg.get("display") or {}).get("thinking_mode", "") or "")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if raw in allowed_tm:
|
||||
nv = raw
|
||||
else:
|
||||
dm = (
|
||||
str(
|
||||
(cfg.get("display") or {}).get("details_mode", "collapsed")
|
||||
or "collapsed"
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
nv = "full" if dm == "expanded" else "collapsed"
|
||||
return _ok(rid, {"value": nv})
|
||||
if key == "density":
|
||||
on = bool((_load_cfg().get("display") or {}).get("tui_compact", False))
|
||||
return _ok(rid, {"value": "on" if on else "off"})
|
||||
if key == "theme":
|
||||
display = _load_cfg().get("display")
|
||||
raw = str(display.get("tui_theme", "auto") if isinstance(display, dict) else "auto").strip().lower()
|
||||
return _ok(rid, {"value": raw if raw in {"auto", "light", "dark"} else "auto"})
|
||||
if key == "statusbar":
|
||||
display = _load_cfg().get("display")
|
||||
raw = (
|
||||
display.get("tui_statusbar", "top") if isinstance(display, dict) else "top"
|
||||
)
|
||||
return _ok(rid, {"value": _coerce_statusbar(raw)})
|
||||
if key == "focus":
|
||||
display = _load_cfg().get("display")
|
||||
on = bool(display.get("focus_view", False)) if isinstance(display, dict) else False
|
||||
return _ok(
|
||||
rid,
|
||||
{"value": "on" if on else "off", "tool_progress": _load_tool_progress_mode()},
|
||||
)
|
||||
if key == "mouse":
|
||||
display = _load_cfg().get("display")
|
||||
return _ok(rid, {"value": _display_mouse_tracking(display)})
|
||||
if key == "mtime":
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
try:
|
||||
mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0
|
||||
except Exception:
|
||||
return _ok(rid, {"mtime": 0})
|
||||
# Revision hash of the MCP-relevant config sections. The TUI's
|
||||
# config-change poller uses it to reload MCP servers only when their
|
||||
# config actually changed — a /skin or /statusbar write bumps mtime
|
||||
# but must not cost a multi-second MCP reconnect.
|
||||
return _ok(rid, {"mtime": mtime, "mcp_rev": _compute_mcp_rev()})
|
||||
return _err(rid, 4002, f"unknown config key: {key}")
|
||||
|
||||
|
||||
@method("setup.status")
|
||||
def _(rid, params: dict) -> dict:
|
||||
try:
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
|
||||
return _ok(rid, {"provider_configured": bool(_has_any_provider_configured())})
|
||||
except Exception as e:
|
||||
return _err(rid, 5016, str(e))
|
||||
|
||||
|
||||
@method("setup.runtime_check")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Strict provider check: does the configured/default model actually resolve to a usable runtime?
|
||||
|
||||
Unlike setup.status (which returns True if ANY provider auth state is
|
||||
discoverable, including indirect fallbacks like ``gh auth token`` for
|
||||
Copilot), this runs the same resolve_runtime_provider() call the agent
|
||||
uses on session creation. It returns ok=False with the auth error message
|
||||
when the user's configured model cannot actually be served, so UIs can
|
||||
surface onboarding before the user submits a doomed prompt.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
from hermes_cli.auth import has_usable_secret
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
|
||||
requested = str(params.get("provider") or "").strip() or None
|
||||
runtime = resolve_runtime_provider(requested=requested)
|
||||
provider_configured = bool(_has_any_provider_configured())
|
||||
provider = runtime.get("provider") or "provider"
|
||||
source = str(runtime.get("source") or "")
|
||||
if not provider_configured and provider == "bedrock" and source in {
|
||||
"iam-role",
|
||||
"aws-sdk-default-chain",
|
||||
}:
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"ok": False,
|
||||
"provider": provider,
|
||||
"model": runtime.get("model"),
|
||||
"source": source,
|
||||
"error": "No Hermes provider is configured.",
|
||||
},
|
||||
)
|
||||
|
||||
api_key = runtime.get("api_key")
|
||||
api_key_text = "" if callable(api_key) else str(api_key or "").strip()
|
||||
credential_ok = (
|
||||
callable(api_key)
|
||||
or api_key_text in {"aws-sdk", "no-key-required"}
|
||||
or has_usable_secret(api_key_text)
|
||||
or bool(runtime.get("command"))
|
||||
)
|
||||
|
||||
if not credential_ok:
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"ok": False,
|
||||
"provider": provider,
|
||||
"model": runtime.get("model"),
|
||||
"source": runtime.get("source"),
|
||||
"error": f"No usable credentials found for {provider}.",
|
||||
},
|
||||
)
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"ok": True,
|
||||
"provider": runtime.get("provider"),
|
||||
"model": runtime.get("model"),
|
||||
"source": runtime.get("source"),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _ok(rid, {"ok": False, "error": str(e)})
|
||||
|
||||
|
||||
def register(server) -> None:
|
||||
"""Bind this module's handlers onto ``server``'s globals and registry."""
|
||||
_registry.install(server)
|
||||
835
tui_gateway/methods_prompt.py
Normal file
835
tui_gateway/methods_prompt.py
Normal file
|
|
@ -0,0 +1,835 @@
|
|||
"""Prompt / attachment / respond JSON-RPC handlers (moved verbatim from server.py).
|
||||
|
||||
Handler bodies are byte-identical to their pre-split server.py form; they
|
||||
are rebound onto server.py's globals at install time — see method_ctx.py.
|
||||
"""
|
||||
|
||||
from .method_ctx import HandlerRegistry
|
||||
|
||||
_registry = HandlerRegistry()
|
||||
method = _registry.method
|
||||
_profile_scoped = _registry.profile_scoped
|
||||
|
||||
|
||||
@method("prompt.submit")
|
||||
def _(rid, params: dict) -> dict:
|
||||
from hermes_cli.input_sanitize import sanitize_user_prompt_text
|
||||
|
||||
sid = params.get("session_id", "")
|
||||
raw_text = params.get("text", "")
|
||||
text = sanitize_user_prompt_text(raw_text) if isinstance(raw_text, str) else raw_text
|
||||
# Typed bare stop phrase while backend voice mode is active ends the
|
||||
# voice chat instead of sending "stop" to the agent — the typed twin of
|
||||
# the spoken stop phrase (PR #73106), applied at the ONE server-side
|
||||
# choke point every TUI submit passes through. Guarded on voice mode
|
||||
# being ON: typed "stop" outside a voice chat is a normal message.
|
||||
# (The desktop's voice conversation is renderer-owned and never flips
|
||||
# the backend flag, so it handles its own typed stop client-side.)
|
||||
if isinstance(text, str) and _voice_mode_enabled():
|
||||
try:
|
||||
from tools.voice_mode import is_voice_stop_phrase
|
||||
|
||||
typed_stop = is_voice_stop_phrase(text)
|
||||
except Exception:
|
||||
typed_stop = False
|
||||
if typed_stop:
|
||||
os.environ["HERMES_VOICE"] = "0"
|
||||
os.environ["HERMES_VOICE_TTS"] = "0"
|
||||
try:
|
||||
from hermes_cli.voice import stop_continuous
|
||||
|
||||
stop_continuous()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_tts_stream_stop(user_barge=False)
|
||||
except Exception:
|
||||
pass
|
||||
_voice_emit("voice.transcript", {"stop_phrase": True, "typed": True})
|
||||
logger.info("prompt.submit: typed stop phrase — voice chat ended")
|
||||
return _ok(rid, {"voice_stopped": True})
|
||||
truncate_user_ordinal = params.get("truncate_before_user_ordinal")
|
||||
if params.get("interrupted"):
|
||||
# Client-side barge-in (desktop VAD / typing over playback) — latch it
|
||||
# so this turn's model message carries the interruption note.
|
||||
from tools.tts_streaming import mark_speech_interrupted
|
||||
|
||||
mark_speech_interrupted()
|
||||
session, err = _sess_nowait(params, rid)
|
||||
if err:
|
||||
return err
|
||||
if (limit_message := _ensure_active_session_slot(sid, session)) is not None:
|
||||
return _err(rid, 4090, limit_message)
|
||||
if truncate_user_ordinal is not None and isinstance(text, str):
|
||||
# A rewind/regenerate replays a turn from what the transcript shows. A
|
||||
# skill turn shows its invocation, so re-expand it here — otherwise
|
||||
# re-running `/work fix it` sends the agent nine literal characters
|
||||
# instead of the skill it originally loaded.
|
||||
text = _expand_skill_invocation_for_replay(
|
||||
text, str(session.get("session_key") or "")
|
||||
)
|
||||
isolation_cfg = _load_dashboard_process_isolation_config()
|
||||
turn_isolation = _session_uses_compute_host(session, isolation_cfg)
|
||||
# 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
|
||||
while True:
|
||||
busy_transport = None
|
||||
with session["history_lock"]:
|
||||
if session.get("running"):
|
||||
# Don't reject a mid-turn prompt — queue it (and, by default,
|
||||
# interrupt the live turn) so it runs as the next turn. The
|
||||
# provider interrupt itself must happen after this lock is
|
||||
# released: a non-interruptible tool may keep it waiting.
|
||||
busy_transport = t or session.get("transport")
|
||||
else:
|
||||
break
|
||||
busy_response = _handle_busy_submit(
|
||||
rid, sid, session, text, busy_transport,
|
||||
queued=bool(params.get("queued")),
|
||||
)
|
||||
if busy_response is not None:
|
||||
return busy_response
|
||||
# The old turn finished between the two lock acquisitions. Retry the
|
||||
# claim so this prompt starts normally instead of being stranded in a
|
||||
# queue whose drain already ran.
|
||||
|
||||
with session["history_lock"]:
|
||||
# A watch session's run lives in the PARENT turn, so its own running
|
||||
# flag is False — without this, typing mid-run builds a second agent
|
||||
# racing the in-flight child on the same stored session (interleaved
|
||||
# transcript, stale fork). After the run completes, submitting is fine:
|
||||
# the upgrade resumes the child's transcript as a normal conversation.
|
||||
if session.get("lazy") and _child_run_active(str(session.get("session_key") or "")):
|
||||
return _err(rid, 4009, "subagent still running — wait for it to finish")
|
||||
if truncate_user_ordinal is not None:
|
||||
try:
|
||||
ordinal = int(truncate_user_ordinal)
|
||||
except (TypeError, ValueError):
|
||||
return _err(rid, 4004, "truncate_before_user_ordinal must be an integer")
|
||||
history = session.get("history", [])
|
||||
user_indices = [
|
||||
i for i, m in enumerate(history)
|
||||
if m.get("role") == "user" and not m.get("display_kind")
|
||||
]
|
||||
# Reject out-of-range ordinals on BOTH ends. A negative value would
|
||||
# otherwise sail past the upper-bound check and hit Python's negative
|
||||
# indexing below (user_indices[-1] -> the LAST user turn), silently
|
||||
# truncating history to everything before it and persisting that loss
|
||||
# via replace_messages — an unrecoverable overwrite of the session DB.
|
||||
if ordinal < 0 or ordinal >= len(user_indices):
|
||||
return _err(rid, 4018, "target user message is no longer in session history")
|
||||
truncated = history[: user_indices[ordinal]]
|
||||
# Stale clients can attach truncate_before_user_ordinal=0 to an
|
||||
# ordinary submit. That resolves to history[:0] == [] and
|
||||
# replace_messages() DELETEs every durable row — silent total
|
||||
# transcript loss. Refuse the empty-truncation edge unless the
|
||||
# client explicitly opts in (legitimate restore/regenerate of the
|
||||
# first user turn).
|
||||
if (
|
||||
not truncated
|
||||
and history
|
||||
and not is_truthy_value(params.get("confirm_empty_truncate"))
|
||||
):
|
||||
logger.warning(
|
||||
"prompt.submit: REFUSED empty truncation of session %s "
|
||||
"(%d messages would be wiped; ordinal=%d).",
|
||||
sid,
|
||||
len(history),
|
||||
ordinal,
|
||||
)
|
||||
return _err(
|
||||
rid,
|
||||
4028,
|
||||
"truncation would erase the entire session transcript; "
|
||||
"resubmit with confirm_empty_truncate=true if this is intended",
|
||||
)
|
||||
# Info for routine rewind/edit cuts; warning only when the client
|
||||
# explicitly opts into wiping the whole transcript.
|
||||
log_fn = logger.warning if not truncated else logger.info
|
||||
log_fn(
|
||||
"prompt.submit: truncating session %s history %d -> %d messages "
|
||||
"(ordinal=%d)",
|
||||
sid,
|
||||
len(history),
|
||||
len(truncated),
|
||||
ordinal,
|
||||
)
|
||||
session["history"] = truncated
|
||||
session["history_version"] = int(session.get("history_version", 0)) + 1
|
||||
if (db := _get_db()) is not None:
|
||||
try:
|
||||
db.replace_messages(session["session_key"], truncated)
|
||||
except Exception as exc:
|
||||
print(f"[tui_gateway] prompt.submit: replace_messages failed: {exc}", file=sys.stderr)
|
||||
session["running"] = True
|
||||
session["_turn_cancel_requested"] = False
|
||||
session["last_active"] = time.time()
|
||||
_start_inflight_turn(session, text)
|
||||
|
||||
if turn_isolation:
|
||||
isolated_response = _submit_prompt_to_compute_host(rid, sid, session, text)
|
||||
if not isolated_response.get("error"):
|
||||
return isolated_response
|
||||
logger.warning(
|
||||
"compute-host dispatch failed for session %s; falling back inline: %s",
|
||||
sid,
|
||||
isolated_response["error"].get("message", "unknown error"),
|
||||
)
|
||||
|
||||
# Persist the DB row lazily, now that the user has actually sent a message.
|
||||
_ensure_session_db_row(session)
|
||||
# A branch becomes real here: copy its parent's transcript into the row so it
|
||||
# resumes with full context (the agent won't persist the seed itself).
|
||||
_persist_branch_seed(session)
|
||||
_start_agent_build(sid, session)
|
||||
|
||||
def run_after_agent_ready() -> None:
|
||||
# Patient wait (#63078): the user's message is already the accepted
|
||||
# in-flight turn, so a slow deferred build must not eat it. The wait
|
||||
# delivers the prompt when the still-running build completes, honors a
|
||||
# cancel promptly, notices the user once past the slow threshold, and
|
||||
# only errors when the build itself fails or the bounded cap expires.
|
||||
err = _wait_agent_for_prompt(session, rid, sid)
|
||||
if err:
|
||||
# Terminal frame + retained snapshot (not a bare "error" event +
|
||||
# cleared inflight): if the client is disconnected right now, the
|
||||
# retained snapshot is the only way resume can show this failure.
|
||||
_emit_terminal_turn_error(
|
||||
sid,
|
||||
session,
|
||||
(err.get("error") or {}).get("message", "agent initialization failed"),
|
||||
)
|
||||
with session["history_lock"]:
|
||||
session["running"] = False
|
||||
session["last_active"] = time.time()
|
||||
_emit("session.info", sid, _session_info(session.get("agent"), session))
|
||||
return
|
||||
with session["history_lock"]:
|
||||
if session.get("_turn_cancel_requested") or not session.get("running"):
|
||||
session["running"] = False
|
||||
_clear_inflight_turn(session)
|
||||
# Surface the cancellation to the client. Without this emit the
|
||||
# turn vanishes silently — the Desktop sees `prompt.submit`
|
||||
# return `{"status": "streaming"}` but never receives a
|
||||
# `message.start` or `error` event, so the composer shows no
|
||||
# feedback (issue #63078 server-side half). Match the
|
||||
# `_wait_agent` error branch above: emit, then bail.
|
||||
_emit(
|
||||
"error",
|
||||
sid,
|
||||
{
|
||||
"message": "Turn cancelled before the agent was ready"
|
||||
if session.get("_turn_cancel_requested")
|
||||
else "Session no longer running before the agent was ready"
|
||||
},
|
||||
)
|
||||
return
|
||||
_run_prompt_submit(rid, sid, session, text)
|
||||
|
||||
run_thread = threading.Thread(target=run_after_agent_ready, daemon=True)
|
||||
# Keep a handle so session.interrupt can tell a live turn from a stuck
|
||||
# `running` flag (a turn that died without clearing it) and recover the latter.
|
||||
session["_run_thread"] = run_thread
|
||||
run_thread.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 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):
|
||||
session["image_counter"] = max(0, session["image_counter"] - 1)
|
||||
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"]),
|
||||
**_image_meta(img_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@method("image.attach")
|
||||
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")
|
||||
try:
|
||||
from cli import (
|
||||
_IMAGE_EXTENSIONS,
|
||||
_detect_file_drop,
|
||||
_resolve_attachment_path,
|
||||
_split_path_input,
|
||||
)
|
||||
|
||||
dropped = _detect_file_drop(raw)
|
||||
if dropped:
|
||||
image_path = dropped["path"]
|
||||
remainder = dropped["remainder"]
|
||||
else:
|
||||
path_token, remainder = _split_path_input(raw)
|
||||
image_path = _resolve_attachment_path(path_token)
|
||||
if image_path is None:
|
||||
return _err(rid, 4016, f"image not found: {path_token}")
|
||||
if image_path.suffix.lower() not in _IMAGE_EXTENSIONS:
|
||||
return _err(rid, 4016, f"unsupported image: {image_path.name}")
|
||||
session.setdefault("attached_images", []).append(str(image_path))
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"attached": True,
|
||||
"path": str(image_path),
|
||||
"count": len(session["attached_images"]),
|
||||
"remainder": remainder,
|
||||
"text": remainder or f"[User attached image: {image_path.name}]",
|
||||
**_image_meta(image_path),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5027, str(e))
|
||||
|
||||
|
||||
@method("image.attach_bytes")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Attach an image to the session from base64 bytes (remote-client path).
|
||||
|
||||
A desktop app or web dashboard running on a DIFFERENT machine than the
|
||||
gateway can't hand us a local path — that file only exists on the client's
|
||||
disk. So it uploads the raw image bytes (base64) and we write them into the
|
||||
gateway's own images dir. The response shape mirrors ``image.attach`` so the
|
||||
client treats both identically.
|
||||
|
||||
Params:
|
||||
content_base64 / data (str, required): base64 image bytes. Accepts a
|
||||
``data:image/...;base64,`` prefix and embedded whitespace. ``data`` is
|
||||
an accepted alias for older desktop builds.
|
||||
filename / ext (str, optional): extension hint. Without it, magic bytes
|
||||
identify PNG/JPEG/GIF/WebP/BMP, falling back to ``.png``.
|
||||
"""
|
||||
session, err = _sess(params, rid)
|
||||
if err:
|
||||
return err
|
||||
|
||||
raw_b64 = str(params.get("content_base64") or params.get("data") or "").strip()
|
||||
if not raw_b64:
|
||||
return _err(rid, 4015, "content_base64 required")
|
||||
|
||||
img_bytes = _decode_attach_base64(raw_b64, mime_prefix="image/")
|
||||
if img_bytes is None:
|
||||
return _err(rid, 4017, "data is not valid base64")
|
||||
if not img_bytes:
|
||||
return _err(rid, 4017, "image is empty")
|
||||
if len(img_bytes) > _ATTACH_BYTES_MAX_BYTES:
|
||||
mb = _ATTACH_BYTES_MAX_BYTES // (1024 * 1024)
|
||||
return _err(rid, 4018, f"image too large ({len(img_bytes)} bytes; cap is {mb} MB)")
|
||||
|
||||
filename = str(params.get("filename", "") or "")
|
||||
ext_hint = str(params.get("ext", "") or "").strip().lower()
|
||||
if ext_hint and not ext_hint.startswith("."):
|
||||
ext_hint = "." + ext_hint
|
||||
ext = _sniff_image_ext(img_bytes, filename or (f"x{ext_hint}" if ext_hint else ""))
|
||||
if ext not in _allowed_image_extensions():
|
||||
return _err(rid, 4016, f"unsupported image extension: {ext}")
|
||||
|
||||
try:
|
||||
img_path = _queue_attached_image(session, img_bytes, ext, prefix="upload")
|
||||
except Exception as e:
|
||||
return _err(rid, 5027, f"write failed: {e}")
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"attached": True,
|
||||
"path": str(img_path),
|
||||
"count": len(session["attached_images"]),
|
||||
"remainder": "",
|
||||
"text": f"[User attached image: {img_path.name}]",
|
||||
"bytes": len(img_bytes),
|
||||
**_image_meta(img_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@method("pdf.attach")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Attach a PDF by rendering each page to PNG and queuing the pages.
|
||||
|
||||
Anthropic's vision pipeline accepts images, not PDFs, so this runs
|
||||
``pdftoppm`` (poppler-utils) at 150 DPI per page and queues each rendered
|
||||
page as an attached image. Accepts either a host ``path`` (local mode) or
|
||||
base64 ``content_base64`` (remote upload). Caps at 50 MB / 25 pages per call.
|
||||
|
||||
Requires ``pdftoppm`` on $PATH (``apt install poppler-utils``); returns 5028
|
||||
if missing.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
session, err = _sess(params, rid)
|
||||
if err:
|
||||
return err
|
||||
|
||||
if shutil.which("pdftoppm") is None:
|
||||
return _err(rid, 5028, "pdftoppm not installed (poppler-utils package required)")
|
||||
|
||||
raw_path = str(params.get("path", "") or "").strip()
|
||||
raw_b64 = str(params.get("content_base64") or params.get("data") or "").strip()
|
||||
if not raw_path and not raw_b64:
|
||||
return _err(rid, 4015, "path or content_base64 required")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="pdf_attach_") as td:
|
||||
td_path = Path(td)
|
||||
if raw_b64:
|
||||
pdf_bytes = _decode_attach_base64(raw_b64, mime_prefix="application/pdf")
|
||||
if pdf_bytes is None:
|
||||
return _err(rid, 4017, "data is not valid base64")
|
||||
if not pdf_bytes:
|
||||
return _err(rid, 4017, "decoded PDF is empty")
|
||||
if len(pdf_bytes) > _PDF_ATTACH_MAX_BYTES:
|
||||
mb = _PDF_ATTACH_MAX_BYTES // (1024 * 1024)
|
||||
return _err(rid, 4018, f"PDF too large ({len(pdf_bytes)} bytes; cap is {mb} MB)")
|
||||
if pdf_bytes[:5] != b"%PDF-":
|
||||
return _err(rid, 4017, "payload is not a PDF (missing %PDF- magic bytes)")
|
||||
pdf_path = td_path / "input.pdf"
|
||||
pdf_path.write_bytes(pdf_bytes)
|
||||
display_name = str(params.get("filename", "") or "uploaded.pdf")
|
||||
else:
|
||||
try:
|
||||
from cli import _resolve_attachment_path
|
||||
|
||||
resolved = _resolve_attachment_path(raw_path)
|
||||
except Exception:
|
||||
resolved = None
|
||||
if resolved is None or not Path(resolved).is_file():
|
||||
return _err(rid, 4016, f"PDF not found: {raw_path}")
|
||||
if Path(resolved).suffix.lower() != ".pdf":
|
||||
return _err(rid, 4016, f"not a PDF: {Path(resolved).name}")
|
||||
if Path(resolved).stat().st_size > _PDF_ATTACH_MAX_BYTES:
|
||||
mb = _PDF_ATTACH_MAX_BYTES // (1024 * 1024)
|
||||
return _err(rid, 4018, f"PDF too large; cap is {mb} MB")
|
||||
pdf_path = Path(resolved)
|
||||
display_name = pdf_path.name
|
||||
|
||||
try:
|
||||
first_page = int(params.get("first_page") or 1)
|
||||
last_page_param = params.get("last_page")
|
||||
last_page = int(last_page_param) if last_page_param is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return _err(rid, 4015, "first_page/last_page must be integers")
|
||||
|
||||
if first_page < 1:
|
||||
return _err(rid, 4015, "first_page must be >= 1")
|
||||
if last_page is None:
|
||||
last_page = first_page + _PDF_ATTACH_MAX_PAGES - 1
|
||||
if last_page < first_page:
|
||||
return _err(rid, 4015, "last_page must be >= first_page")
|
||||
if last_page - first_page + 1 > _PDF_ATTACH_MAX_PAGES:
|
||||
return _err(rid, 4019, f"page range exceeds cap of {_PDF_ATTACH_MAX_PAGES} pages per attach call")
|
||||
|
||||
out_prefix = td_path / "page"
|
||||
argv = [
|
||||
"pdftoppm", "-png", "-r", "150",
|
||||
"-f", str(first_page), "-l", str(last_page),
|
||||
str(pdf_path), str(out_prefix),
|
||||
]
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
|
||||
try:
|
||||
res = subprocess.run(
|
||||
argv, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL,
|
||||
# Force UTF-8 + lossy decode so non-UTF-8 child output can't
|
||||
# crash the gateway thread on locale-mismatched Windows (#53137).
|
||||
encoding="utf-8", errors="replace",
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return _err(rid, 5028, "pdftoppm timed out (>120s)")
|
||||
if res.returncode != 0:
|
||||
tail = (res.stderr or res.stdout or "").strip().splitlines()[-3:]
|
||||
return _err(rid, 5028, "pdftoppm failed: " + " | ".join(tail))
|
||||
|
||||
rendered = sorted(td_path.glob("page-*.png"))
|
||||
if not rendered:
|
||||
return _err(rid, 5028, "pdftoppm produced no pages (corrupt PDF?)")
|
||||
|
||||
attached_pages = []
|
||||
for src in rendered:
|
||||
page_num = src.stem.split("-", 1)[-1]
|
||||
try:
|
||||
page_int = int(page_num)
|
||||
except ValueError:
|
||||
page_int = first_page + len(attached_pages)
|
||||
dst = _queue_attached_image(session, src.read_bytes(), ".png", prefix=f"pdf_p{page_num}")
|
||||
attached_pages.append({"path": str(dst), "page": page_int, **_image_meta(dst)})
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"attached": True,
|
||||
"filename": display_name,
|
||||
"pages_attached": len(attached_pages),
|
||||
"pages": attached_pages,
|
||||
"count": len(session["attached_images"]),
|
||||
"text": f"[User attached PDF: {display_name} ({len(attached_pages)} page(s))]",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@method("file.attach")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Stage a non-image file attachment into the session workspace.
|
||||
|
||||
The image/PDF path renders to vision tiles; this one keeps the file as a
|
||||
readable artifact and returns a workspace-relative ``@file:`` ref so the
|
||||
agent's file tools (and ``agent.context_references``) can read it. Solves the
|
||||
remote-gateway case where the desktop passes a path that only exists on the
|
||||
CLIENT's disk: the client uploads ``data_url`` bytes and we materialize the
|
||||
file on the gateway.
|
||||
|
||||
Params:
|
||||
session_id (str, required)
|
||||
path (str): client/host path of the file (used for naming + local-mode
|
||||
gateway-visible resolution).
|
||||
data_url (str): ``data:<mime>;base64,<b64>`` upload of the file bytes,
|
||||
required when the path isn't visible to the gateway.
|
||||
name (str, optional): preferred filename.
|
||||
"""
|
||||
session, err = _sess(params, rid)
|
||||
if err:
|
||||
return err
|
||||
raw = str(params.get("path", "") or "").strip()
|
||||
data_url = str(params.get("data_url", "") or "").strip()
|
||||
name = str(params.get("name", "") or "").strip()
|
||||
if not raw and not data_url:
|
||||
return _err(rid, 4015, "path or data_url required")
|
||||
try:
|
||||
stored_path, uploaded = _stage_session_file_attachment(
|
||||
session, raw_path=raw, data_url=data_url, name=name
|
||||
)
|
||||
ref_path = _attachment_ref_path(session, stored_path)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"attached": True,
|
||||
"name": stored_path.name,
|
||||
"path": str(stored_path),
|
||||
"ref_path": ref_path,
|
||||
"ref_text": f"@file:{_format_ref_value(ref_path)}",
|
||||
"uploaded": uploaded,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5028, 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)
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
from cli import _detect_file_drop
|
||||
|
||||
raw = str(params.get("text", "") or "")
|
||||
dropped = _detect_file_drop(raw)
|
||||
if not dropped:
|
||||
return _ok(rid, {"matched": False})
|
||||
|
||||
drop_path = dropped["path"]
|
||||
remainder = dropped["remainder"]
|
||||
if dropped["is_image"]:
|
||||
session.setdefault("attached_images", []).append(str(drop_path))
|
||||
text = remainder or f"[User attached image: {drop_path.name}]"
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"matched": True,
|
||||
"is_image": True,
|
||||
"path": str(drop_path),
|
||||
"count": len(session["attached_images"]),
|
||||
"text": text,
|
||||
**_image_meta(drop_path),
|
||||
},
|
||||
)
|
||||
|
||||
text = f"[User attached file: {drop_path}]" + (
|
||||
f"\n{remainder}" if remainder else ""
|
||||
)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"matched": True,
|
||||
"is_image": False,
|
||||
"path": str(drop_path),
|
||||
"name": drop_path.name,
|
||||
"text": text,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5027, str(e))
|
||||
|
||||
|
||||
@method("prompt.background")
|
||||
def _(rid, params: dict) -> dict:
|
||||
session, err = _sess(params, rid)
|
||||
if err:
|
||||
return err
|
||||
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():
|
||||
session_tokens = _set_session_context(task_id, cwd=_session_cwd(session))
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
|
||||
result = AIAgent(
|
||||
**_background_agent_kwargs(session["agent"], task_id)
|
||||
).run_conversation(
|
||||
user_message=text,
|
||||
task_id=task_id,
|
||||
)
|
||||
_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}"},
|
||||
)
|
||||
finally:
|
||||
_clear_session_context(session_tokens)
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
return _ok(rid, {"task_id": task_id})
|
||||
|
||||
|
||||
@method("preview.restart")
|
||||
def _(rid, params: dict) -> dict:
|
||||
session, err = _sess(params, rid)
|
||||
if err:
|
||||
return err
|
||||
|
||||
url = str(params.get("url") or "").strip()
|
||||
cwd = str(params.get("cwd") or "").strip()
|
||||
context = str(params.get("context") or "").strip()
|
||||
|
||||
if not url:
|
||||
return _err(rid, 4012, "url required")
|
||||
|
||||
task_id = f"preview_{uuid.uuid4().hex[:6]}"
|
||||
parent = params.get("session_id", "")
|
||||
parent_history = _preview_restart_history(session)
|
||||
has_history = bool(parent_history)
|
||||
prompt = "\n".join(
|
||||
line
|
||||
for line in [
|
||||
"The desktop preview pane cannot load a local server URL.",
|
||||
"",
|
||||
f"Preview URL: {url}",
|
||||
f"Current working directory: {cwd or '(unknown)'}",
|
||||
"",
|
||||
f"Preview console:\n{context}" if context else "",
|
||||
"" if context else "",
|
||||
(
|
||||
"The conversation history above is from the user's main session — including the commands you (the assistant) previously ran to start servers, edit files, or check ports. Use it to figure out exactly which server should be running at this Preview URL. The user did not start a brand new task; recover what they had working."
|
||||
if has_history
|
||||
else None
|
||||
),
|
||||
"Restart exactly the app intended for the Preview URL, not Hermes Desktop itself.",
|
||||
"The Preview URL and port are the target. Preserve that target unless you conclude it is impossible.",
|
||||
"If the prior conversation shows a specific command that bound this URL/port, prefer re-running THAT exact command (in the same cwd) over guessing a new one.",
|
||||
"First inspect what process, if any, owns the Preview URL port. If a stale server exists, inspect its cwd and prefer that cwd over the Hermes/Desktop process cwd.",
|
||||
"The Current working directory is only a hint. Do not assume it is the preview app root when the port owner or files indicate another root.",
|
||||
"If the console shows a module-script MIME error for src/main.tsx or similar, a static server is serving source files. Do not restart python -m http.server or any dumb static server for that app.",
|
||||
"For module-script MIME failures, inspect package.json/vite config in the candidate app root and start the real dev server/bundler (for example npm/pnpm/yarn dev) so module transforms happen.",
|
||||
"Before declaring success, verify the Preview URL responds with the intended app, not Hermes Desktop. If it serves Hermes/Desktop UI or another unrelated app, stop that process and report failure.",
|
||||
"Do not modify files. Do not ask the user unless blocked.",
|
||||
"Prefer existing project scripts or commands when they are clear.",
|
||||
"If a stale process owns the needed port, handle it safely.",
|
||||
"Start long-running servers detached/in the background, then return immediately.",
|
||||
"Do not run a foreground dev server command that blocks this background task.",
|
||||
"Keep the final response short: what command/server was started, or why it could not be restarted.",
|
||||
]
|
||||
if line
|
||||
)
|
||||
|
||||
# Normalize defensively: a malformed client path (embedded NUL, etc.) must
|
||||
# not blow up the whole restart — treat it as "no validated cwd".
|
||||
try:
|
||||
preview_cwd = os.path.abspath(os.path.expanduser(cwd)) if cwd else ""
|
||||
if preview_cwd and not os.path.isdir(preview_cwd):
|
||||
preview_cwd = ""
|
||||
except Exception:
|
||||
preview_cwd = ""
|
||||
|
||||
def run():
|
||||
# Pin the validated preview cwd, else the parent workspace — never an
|
||||
# invalid client path, which would silently fall back to the launch dir.
|
||||
session_tokens = _set_session_context(task_id, cwd=(preview_cwd or _session_cwd(session)))
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
from tools.terminal_tool import register_task_env_overrides
|
||||
|
||||
if preview_cwd:
|
||||
register_task_env_overrides(task_id, {"cwd": preview_cwd})
|
||||
|
||||
history_note = (
|
||||
f" (with {len(parent_history)} parent-session messages of context)"
|
||||
if parent_history
|
||||
else ""
|
||||
)
|
||||
_emit(
|
||||
"preview.restart.progress",
|
||||
parent,
|
||||
{"task_id": task_id, "text": f"Starting hidden restart agent{history_note}"},
|
||||
)
|
||||
result = AIAgent(
|
||||
**_ephemeral_preview_agent_kwargs(session["agent"], task_id),
|
||||
**_preview_restart_callbacks(parent, task_id),
|
||||
).run_conversation(
|
||||
user_message=prompt,
|
||||
task_id=task_id,
|
||||
conversation_history=parent_history or None,
|
||||
)
|
||||
text = (
|
||||
result.get("final_response", str(result))
|
||||
if isinstance(result, dict)
|
||||
else str(result)
|
||||
)
|
||||
_emit("preview.restart.complete", parent, {"task_id": task_id, "text": text})
|
||||
except Exception as e:
|
||||
_emit(
|
||||
"preview.restart.complete",
|
||||
parent,
|
||||
{"task_id": task_id, "text": f"error: {e}"},
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
from tools.terminal_tool import clear_task_env_overrides
|
||||
|
||||
clear_task_env_overrides(task_id)
|
||||
except Exception:
|
||||
pass
|
||||
_clear_session_context(session_tokens)
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
return _ok(rid, {"task_id": task_id})
|
||||
|
||||
|
||||
@method("clarify.respond")
|
||||
def _(rid, params: dict) -> dict:
|
||||
# allow_expired=True: a clarify can time out server-side (its entry is popped
|
||||
# from _pending) while the card is still visible — common when a WebSocket
|
||||
# reconnect during the wait drops tool.complete. A late answer must resolve
|
||||
# gracefully instead of hitting the raw 4009 "no pending answer request".
|
||||
return _respond(rid, params, "answer", allow_expired=True)
|
||||
|
||||
|
||||
@method("terminal.read.respond")
|
||||
def _(rid, params: dict) -> dict:
|
||||
# `text` is a JSON string of the serialized terminal buffer + line metadata.
|
||||
# allow_expired=True: the read_terminal tool's _block() uses a short 30s
|
||||
# timeout, so a slow renderer losing the race is the common case — a late
|
||||
# response must not error after the tool already returned empty.
|
||||
return _respond(rid, params, "text", allow_expired=True)
|
||||
|
||||
|
||||
@method("sudo.respond")
|
||||
def _(rid, params: dict) -> dict:
|
||||
return _respond(rid, params, "password", allow_expired=True)
|
||||
|
||||
|
||||
@method("secret.respond")
|
||||
def _(rid, params: dict) -> dict:
|
||||
return _respond(rid, params, "value", allow_expired=True)
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
def register(server) -> None:
|
||||
"""Bind this module's handlers onto ``server``'s globals and registry."""
|
||||
_registry.install(server)
|
||||
2960
tui_gateway/methods_session.py
Normal file
2960
tui_gateway/methods_session.py
Normal file
File diff suppressed because it is too large
Load diff
1912
tui_gateway/methods_tools.py
Normal file
1912
tui_gateway/methods_tools.py
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue