Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 299b9dba67 | |||
| 769ee86cd2 | |||
| 1b1e30510a | |||
| f3acdd94fe | |||
| 78a54d2c00 | |||
| e7c99651fb | |||
| a5c1f925b5 | |||
| 0acb7f4583 | |||
| a3cd974ee7 | |||
| 102eb4adc0 | |||
| c661fefa08 | |||
| c9e5a9bb08 |
+134
-14
@@ -37,6 +37,8 @@ from __future__ import annotations
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -46,6 +48,102 @@ logger = logging.getLogger(__name__)
|
||||
_VALID_MODES = frozenset({"auto", "native", "text"})
|
||||
|
||||
|
||||
# Image extensions used by extract_image_refs(). Kept tight on purpose — we
|
||||
# only auto-attach things the model can actually see. Documents/archives are
|
||||
# excluded because the gateway's broader extract_local_files() also routes
|
||||
# them differently (send_document), and we don't want to attach a PDF as a
|
||||
# vision part.
|
||||
_IMAGE_EXTS = (
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".heic",
|
||||
)
|
||||
_IMAGE_EXT_PATTERN = "|".join(e.lstrip(".") for e in _IMAGE_EXTS)
|
||||
|
||||
# Absolute / home-relative local image path. Matches the same shape gateway's
|
||||
# extract_local_files() uses: anchors to ``~/`` or ``/``, ignores matches inside
|
||||
# URLs (the ``(?<![/:\w.])`` lookbehind), and case-insensitive on the extension.
|
||||
_LOCAL_IMAGE_PATH_RE = re.compile(
|
||||
r"(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:" + _IMAGE_EXT_PATTERN + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# http(s) URL ending in an image extension (optionally followed by a
|
||||
# query string). Case-insensitive on the extension. Strict ``http(s)://``
|
||||
# scheme so we don't accidentally grab ``file://`` URLs or other shapes.
|
||||
_IMAGE_URL_RE = re.compile(
|
||||
r"https?://[^\s<>\"']+?\.(?:" + _IMAGE_EXT_PATTERN + r")(?:\?[^\s<>\"']*)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_image_refs(text: str) -> Tuple[List[str], List[str]]:
|
||||
"""Scan free-form text for image references the model should see.
|
||||
|
||||
Returns ``(local_paths, urls)``:
|
||||
|
||||
* ``local_paths`` — absolute (``/``) or home-relative (``~/``) paths
|
||||
whose suffix is an image extension AND whose expanded form exists
|
||||
on disk as a file. Order-preserving, deduplicated.
|
||||
* ``urls`` — ``http(s)://…`` URLs whose path ends in an image
|
||||
extension (a ``?query`` is allowed after the extension).
|
||||
Order-preserving, deduplicated.
|
||||
|
||||
Matches inside fenced code blocks (``` ``` ```) and inline backticks
|
||||
(`` `…` ``) are skipped so that snippets pasted into a task body for
|
||||
reference aren't mistaken for live attachments. This mirrors the
|
||||
behaviour of ``gateway.platforms.base.BaseAdapter.extract_local_files``.
|
||||
|
||||
Local paths are validated against the filesystem; URLs are not
|
||||
(the provider fetches them at request time).
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return [], []
|
||||
|
||||
# Build spans covered by fenced code blocks and inline code so we can
|
||||
# ignore references the author embedded purely as example text.
|
||||
code_spans: list[tuple[int, int]] = []
|
||||
for m in re.finditer(r"```[^\n]*\n.*?```", text, re.DOTALL):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
for m in re.finditer(r"`[^`\n]+`", text):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
|
||||
def _in_code(pos: int) -> bool:
|
||||
return any(s <= pos < e for s, e in code_spans)
|
||||
|
||||
local_paths: list[str] = []
|
||||
seen_paths: set[str] = set()
|
||||
for match in _LOCAL_IMAGE_PATH_RE.finditer(text):
|
||||
if _in_code(match.start()):
|
||||
continue
|
||||
raw = match.group(0)
|
||||
expanded = os.path.expanduser(raw)
|
||||
try:
|
||||
if not os.path.isfile(expanded):
|
||||
continue
|
||||
except OSError:
|
||||
# ENAMETOOLONG / EINVAL on pathological inputs — skip rather than crash.
|
||||
continue
|
||||
if expanded in seen_paths:
|
||||
continue
|
||||
seen_paths.add(expanded)
|
||||
local_paths.append(expanded)
|
||||
|
||||
urls: list[str] = []
|
||||
seen_urls: set[str] = set()
|
||||
for match in _IMAGE_URL_RE.finditer(text):
|
||||
if _in_code(match.start()):
|
||||
continue
|
||||
url = match.group(0)
|
||||
# Strip trailing punctuation that's almost certainly prose, not part
|
||||
# of the URL (e.g. "see https://x.com/a.png." or "/a.png)").
|
||||
url = url.rstrip(".,;:!?)]>")
|
||||
if url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(url)
|
||||
urls.append(url)
|
||||
|
||||
return local_paths, urls
|
||||
|
||||
|
||||
# Strict YAML/JSON boolean coercion for capability overrides.
|
||||
#
|
||||
# ``bool("false")`` is True in Python because non-empty strings are truthy, so
|
||||
@@ -320,20 +418,29 @@ def _file_to_data_url(path: Path) -> Optional[str]:
|
||||
def build_native_content_parts(
|
||||
user_text: str,
|
||||
image_paths: List[str],
|
||||
image_urls: Optional[List[str]] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
"""Build an OpenAI-style ``content`` list for a user turn.
|
||||
|
||||
Shape:
|
||||
[{"type": "text", "text": "...\\n\\n[Image attached at: /local/path]"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
|
||||
...]
|
||||
|
||||
The local path of each successfully attached image is appended to the
|
||||
text part as ``[Image attached at: <path>]``. The model still sees the
|
||||
pixels via the ``image_url`` part (full native vision); the path note
|
||||
just gives it a string handle so MCP/skill tools that take an image
|
||||
path or URL argument can be invoked on the same image without an
|
||||
extra round-trip. This parallels the text-mode hint produced by
|
||||
Local paths are read from disk and embedded as base64 ``data:`` URLs.
|
||||
Remote URLs (``http(s)://``) are passed through verbatim — the provider
|
||||
fetches them server-side. The model still sees the pixels either way.
|
||||
|
||||
For each successfully attached image, a hint is appended to the text
|
||||
part:
|
||||
|
||||
* local path → ``[Image attached at: <path>]``
|
||||
* URL → ``[Image attached: <url>]``
|
||||
|
||||
The hint gives the model a string handle so MCP/skill tools that take
|
||||
an image path or URL argument can be invoked on the same image without
|
||||
an extra round-trip. This parallels the text-mode hint produced by
|
||||
``Runner._enrich_message_with_vision`` (``vision_analyze using image_url:
|
||||
<path>``) so behaviour is consistent across both image input modes.
|
||||
|
||||
@@ -342,12 +449,14 @@ def build_native_content_parts(
|
||||
ceiling), the agent's retry loop transparently shrinks and retries
|
||||
once — see ``run_agent._try_shrink_image_parts_in_messages``.
|
||||
|
||||
Returns (content_parts, skipped_paths). Skipped paths are files that
|
||||
couldn't be read from disk and are NOT advertised in the path hints.
|
||||
Returns (content_parts, skipped). Skipped entries are local paths
|
||||
that couldn't be read from disk; URLs are never skipped (they're
|
||||
not validated here).
|
||||
"""
|
||||
skipped: List[str] = []
|
||||
image_parts: List[Dict[str, Any]] = []
|
||||
attached_paths: List[str] = []
|
||||
attached_urls: List[str] = []
|
||||
|
||||
for raw_path in image_paths:
|
||||
p = Path(raw_path)
|
||||
@@ -364,16 +473,26 @@ def build_native_content_parts(
|
||||
})
|
||||
attached_paths.append(str(raw_path))
|
||||
|
||||
for url in image_urls or []:
|
||||
url = (url or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
image_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": url},
|
||||
})
|
||||
attached_urls.append(url)
|
||||
|
||||
text = (user_text or "").strip()
|
||||
|
||||
# If at least one image attached, build a single text part that combines
|
||||
# the user's caption (or a neutral default) with one path hint per image.
|
||||
if attached_paths:
|
||||
# the user's caption (or a neutral default) with one hint per image.
|
||||
if attached_paths or attached_urls:
|
||||
base_text = text or "What do you see in this image?"
|
||||
path_hints = "\n".join(
|
||||
f"[Image attached at: {p}]" for p in attached_paths
|
||||
)
|
||||
combined_text = f"{base_text}\n\n{path_hints}"
|
||||
hint_lines: List[str] = []
|
||||
hint_lines.extend(f"[Image attached at: {p}]" for p in attached_paths)
|
||||
hint_lines.extend(f"[Image attached: {u}]" for u in attached_urls)
|
||||
combined_text = f"{base_text}\n\n" + "\n".join(hint_lines)
|
||||
parts: List[Dict[str, Any]] = [{"type": "text", "text": combined_text}]
|
||||
parts.extend(image_parts)
|
||||
return parts, skipped
|
||||
@@ -388,4 +507,5 @@ def build_native_content_parts(
|
||||
__all__ = [
|
||||
"decide_image_input_mode",
|
||||
"build_native_content_parts",
|
||||
"extract_image_refs",
|
||||
]
|
||||
|
||||
@@ -15125,13 +15125,50 @@ def main(
|
||||
# Handle single query mode
|
||||
if query or image:
|
||||
query, single_query_images = _collect_query_images(query, image)
|
||||
# Kanban workers spawn with ``hermes chat -q "work kanban task <id>"``;
|
||||
# the actual task description lives in the task body. Mirror the
|
||||
# gateway/CLI behaviour for inbound images by scanning the body for
|
||||
# local image paths and http(s) image URLs and attaching them to the
|
||||
# worker's first turn. Without this, users who paste a screenshot
|
||||
# path or URL into a kanban task body never get it routed to the
|
||||
# model's vision input.
|
||||
single_query_image_urls: list[str] = []
|
||||
_kanban_task_id = os.environ.get("HERMES_KANBAN_TASK", "").strip()
|
||||
if _kanban_task_id:
|
||||
try:
|
||||
from hermes_cli import kanban_db as _kb
|
||||
from agent.image_routing import extract_image_refs as _extract_refs
|
||||
|
||||
_conn = _kb.connect()
|
||||
try:
|
||||
_task = _kb.get_task(_conn, _kanban_task_id)
|
||||
finally:
|
||||
try:
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_body = getattr(_task, "body", "") if _task is not None else ""
|
||||
if _body:
|
||||
_kb_paths, _kb_urls = _extract_refs(_body)
|
||||
if _kb_paths:
|
||||
# Dedupe against any --image the user already passed.
|
||||
_seen = {str(p) for p in single_query_images}
|
||||
for _p in _kb_paths:
|
||||
if _p not in _seen:
|
||||
_seen.add(_p)
|
||||
single_query_images.append(Path(_p))
|
||||
if _kb_urls:
|
||||
single_query_image_urls.extend(_kb_urls)
|
||||
except Exception as _exc:
|
||||
# Best-effort enrichment; never block worker startup on it.
|
||||
logger.debug("kanban image-ref extraction failed: %s", _exc)
|
||||
if quiet:
|
||||
# Quiet mode: suppress banner, spinner, tool previews.
|
||||
# Only print the final response and parseable session info.
|
||||
cli.tool_progress_mode = "off"
|
||||
if cli._ensure_runtime_credentials():
|
||||
effective_query: Any = query
|
||||
if single_query_images:
|
||||
if single_query_images or single_query_image_urls:
|
||||
# Honour the same image-routing decision used by the
|
||||
# interactive path. With a vision-capable model (incl.
|
||||
# custom-provider models declared via
|
||||
@@ -15160,19 +15197,26 @@ def main(
|
||||
_parts, _skipped = _build_parts(
|
||||
query if isinstance(query, str) else "",
|
||||
[str(p) for p in single_query_images],
|
||||
image_urls=list(single_query_image_urls) or None,
|
||||
)
|
||||
if any(p.get("type") == "image_url" for p in _parts):
|
||||
effective_query = _parts
|
||||
else:
|
||||
# All images unreadable — text fallback.
|
||||
# ``_preprocess_images_with_vision`` only knows
|
||||
# about local files; URLs would be lost there,
|
||||
# so keep the original query text intact when
|
||||
# only URLs were supplied.
|
||||
if single_query_images:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query, single_query_images, announce=False,
|
||||
)
|
||||
except Exception:
|
||||
if single_query_images:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query, single_query_images, announce=False,
|
||||
)
|
||||
except Exception:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query, single_query_images, announce=False,
|
||||
)
|
||||
else:
|
||||
elif single_query_images:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query,
|
||||
single_query_images,
|
||||
|
||||
+236
-12
@@ -2,19 +2,40 @@
|
||||
Event Hook System
|
||||
|
||||
A lightweight event-driven system that fires handlers at key lifecycle points.
|
||||
Hooks are discovered from ~/.hermes/hooks/ directories, each containing:
|
||||
- HOOK.yaml (metadata: name, description, events list)
|
||||
- handler.py (Python handler with async def handle(event_type, context))
|
||||
|
||||
Events:
|
||||
- gateway:startup -- Gateway process starts
|
||||
- session:start -- New session created (first message of a new session)
|
||||
- session:end -- Session ends (user ran /new or /reset)
|
||||
- session:reset -- Session reset completed (new session entry created)
|
||||
- agent:start -- Agent begins processing a message
|
||||
- agent:step -- Each turn in the tool-calling loop
|
||||
- agent:end -- Agent finishes processing
|
||||
- command:* -- Any slash command executed (wildcard match)
|
||||
There are two ways to register a handler:
|
||||
|
||||
1. **File-system discovery** — drop a directory into ``~/.hermes/hooks/``
|
||||
containing ``HOOK.yaml`` (metadata: name, description, events list) and
|
||||
``handler.py`` (with ``def handle(event_type, context)``, sync or async).
|
||||
These are loaded by :meth:`HookRegistry.discover_and_load` at gateway
|
||||
startup.
|
||||
|
||||
2. **Programmatic registration** — call :meth:`HookRegistry.register` from
|
||||
inside the process. Useful for plugins that ship their own bundled hooks
|
||||
without expecting the user to maintain a ``~/.hermes/hooks/`` entry. Pairs
|
||||
with :func:`get_default_registry` so plugins don't have to hold a registry
|
||||
reference threaded through every call site.
|
||||
|
||||
Events fired today:
|
||||
|
||||
- ``gateway:startup`` — Gateway process starts
|
||||
- ``session:start`` — New session created (first message of a new session)
|
||||
- ``session:end`` — Session ends (user ran /new or /reset)
|
||||
- ``session:reset`` — Session reset completed (new session entry created)
|
||||
- ``agent:start`` — Agent begins processing a message
|
||||
- ``agent:step`` — Each turn in the tool-calling loop
|
||||
- ``agent:end`` — Agent finishes processing
|
||||
- ``command:*`` — Any slash command executed (wildcard match)
|
||||
- ``tui:<sub-event>`` — Any TUI gateway dispatch event mirrored to the
|
||||
bus (``tui:tool.start``, ``tui:message.delta``,
|
||||
``tui:reasoning.available``, etc.). Subscribe
|
||||
with the full name for one event, or
|
||||
``tui:*`` for all of them.
|
||||
|
||||
Wildcards match one colon-separated namespace level: a handler registered for
|
||||
``foo:*`` fires for every ``foo:<anything>`` event, but not for
|
||||
``bar:something``.
|
||||
|
||||
Errors in hooks are caught and logged but never block the main pipeline.
|
||||
"""
|
||||
@@ -32,6 +53,12 @@ from hermes_cli.config import get_hermes_home
|
||||
HOOKS_DIR = get_hermes_home() / "hooks"
|
||||
|
||||
|
||||
# Tracks handler functions we've already warned about for emit_sync's
|
||||
# async-without-loop case, so each bad combination only logs once per
|
||||
# process instead of flooding stderr on every event.
|
||||
_ASYNC_NO_LOOP_WARNED: "set[int]" = set()
|
||||
|
||||
|
||||
class HookRegistry:
|
||||
"""
|
||||
Discovers, loads, and fires event hooks.
|
||||
@@ -52,6 +79,60 @@ class HookRegistry:
|
||||
"""Return metadata about all loaded hooks."""
|
||||
return list(self._loaded_hooks)
|
||||
|
||||
def register(
|
||||
self,
|
||||
event_type: str,
|
||||
handler: Callable,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
) -> Callable[[], None]:
|
||||
"""Programmatically register a handler for ``event_type``.
|
||||
|
||||
Intended for in-process plugins, tests, and built-in hooks. Pairs with
|
||||
the file-system discovery path (HOOK.yaml + handler.py) — both share
|
||||
the same dispatch and wildcard rules.
|
||||
|
||||
The handler signature matches discovered hooks: ``handle(event_type,
|
||||
context)`` where ``handler`` may be sync or async.
|
||||
|
||||
Returns a callable that, when invoked, removes this specific handler
|
||||
registration from the registry. Other handlers for the same event are
|
||||
unaffected.
|
||||
|
||||
Args:
|
||||
event_type: Event identifier such as ``agent:start`` or
|
||||
``tui:tool.start``. May also be a wildcard like ``command:*``.
|
||||
handler: Function or coroutine function to invoke when the
|
||||
event fires.
|
||||
name: Optional friendly name recorded alongside the
|
||||
registration metadata for listing/debugging. Defaults to the
|
||||
handler's ``__name__``.
|
||||
|
||||
Returns:
|
||||
A no-arg callable that unregisters this handler when called.
|
||||
"""
|
||||
self._handlers.setdefault(event_type, []).append(handler)
|
||||
|
||||
meta = {
|
||||
"name": name or getattr(handler, "__name__", "<anonymous>"),
|
||||
"description": "(registered programmatically)",
|
||||
"events": [event_type],
|
||||
"path": "<programmatic>",
|
||||
}
|
||||
self._loaded_hooks.append(meta)
|
||||
|
||||
def _unregister() -> None:
|
||||
try:
|
||||
self._handlers.get(event_type, []).remove(handler)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
self._loaded_hooks.remove(meta)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return _unregister
|
||||
|
||||
def _register_builtin_hooks(self) -> None:
|
||||
"""Register built-in hooks that are always active.
|
||||
|
||||
@@ -208,3 +289,146 @@ class HookRegistry:
|
||||
except Exception as e:
|
||||
print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
|
||||
return results
|
||||
|
||||
def emit_sync(
|
||||
self,
|
||||
event_type: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Fire handlers from a synchronous caller.
|
||||
|
||||
Companion to :meth:`emit` for hot-path callers that cannot await — most
|
||||
notably ``tui_gateway/server.py:_emit``, which serves both async dispatch
|
||||
paths and sync callback paths and must remain ``def`` (not ``async
|
||||
def``).
|
||||
|
||||
Behavior:
|
||||
|
||||
- Sync handlers run immediately, in registration order. Exceptions are
|
||||
caught and logged so a buggy handler can't break the host pipeline.
|
||||
- Async handlers (coroutine functions) are scheduled via
|
||||
``asyncio.ensure_future`` if a running event loop is available in the
|
||||
current thread. If no loop is running, the handler is **skipped** and
|
||||
a one-time warning is logged per handler — async handlers in a
|
||||
purely sync process don't have a way to make forward progress.
|
||||
|
||||
Like :meth:`emit`, never raises and never blocks waiting on async
|
||||
handlers — fire-and-forget for the async case.
|
||||
|
||||
Args:
|
||||
event_type: The event identifier (e.g. ``tui:tool.start``).
|
||||
context: Optional dict with event-specific data.
|
||||
"""
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
for fn in self._resolve_handlers(event_type):
|
||||
try:
|
||||
result = fn(event_type, context)
|
||||
except Exception as e:
|
||||
print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
|
||||
continue
|
||||
|
||||
if not asyncio.iscoroutine(result):
|
||||
continue
|
||||
|
||||
# Coroutine returned — needs a loop to make progress.
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop in this thread.
|
||||
handler_id = id(fn)
|
||||
if handler_id not in _ASYNC_NO_LOOP_WARNED:
|
||||
_ASYNC_NO_LOOP_WARNED.add(handler_id)
|
||||
handler_name = getattr(fn, "__name__", "<anonymous>")
|
||||
print(
|
||||
f"[hooks] Skipping async handler {handler_name!r} for "
|
||||
f"'{event_type}' — emit_sync called with no running "
|
||||
f"event loop. Subsequent skips for this handler are "
|
||||
f"silent.",
|
||||
flush=True,
|
||||
)
|
||||
# Close the coroutine to suppress "coroutine was never
|
||||
# awaited" RuntimeWarning noise.
|
||||
try:
|
||||
result.close()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
try:
|
||||
# ensure_future schedules the coroutine on the loop and
|
||||
# returns immediately. Exceptions inside the coroutine
|
||||
# surface via the task's done callback (or asyncio's
|
||||
# default exception handler) — we don't await here.
|
||||
task = asyncio.ensure_future(result, loop=loop)
|
||||
task.add_done_callback(_log_task_exception)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[hooks] Failed to schedule async handler for "
|
||||
f"'{event_type}': {e}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _log_task_exception(task: "asyncio.Task[Any]") -> None:
|
||||
"""Surface exceptions from scheduled async hook handlers.
|
||||
|
||||
Without this callback, an exception inside a fire-and-forget handler
|
||||
coroutine becomes "Task exception was never retrieved" noise from
|
||||
asyncio's default exception handler at GC time. Logging it explicitly
|
||||
keeps the failure mode visible and consistent with the sync path.
|
||||
"""
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
print(f"[hooks] Async handler raised: {exc}", flush=True)
|
||||
|
||||
|
||||
# ── Module-level default registry ──────────────────────────────────
|
||||
#
|
||||
# Plugins and in-process callers (TUI gateway's ``_emit`` etc.) need a
|
||||
# stable place to find "the" registry without threading a reference
|
||||
# through every API. The gateway process installs its own
|
||||
# ``self.hooks`` instance as the default during startup so file-system
|
||||
# discovery and built-in hooks share state with programmatic
|
||||
# registrations. Other processes (TUI) lazily get their own default on
|
||||
# first access and run ``discover_and_load()`` themselves.
|
||||
|
||||
_default_registry: Optional["HookRegistry"] = None
|
||||
|
||||
|
||||
def get_default_registry() -> "HookRegistry":
|
||||
"""Return the process-wide default :class:`HookRegistry`.
|
||||
|
||||
Lazily creates one (without auto-running discovery) on first call. Callers
|
||||
that need file-system hook discovery should invoke
|
||||
:meth:`HookRegistry.discover_and_load` themselves after first access — the
|
||||
gateway already does this for the registry it installs as the default.
|
||||
"""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
_default_registry = HookRegistry()
|
||||
return _default_registry
|
||||
|
||||
|
||||
def install_as_default(registry: "HookRegistry") -> None:
|
||||
"""Install ``registry`` as the process-wide default.
|
||||
|
||||
Intended for the gateway and other long-lived hosts that want their own
|
||||
:class:`HookRegistry` instance to be visible to in-process plugins through
|
||||
:func:`get_default_registry`. Idempotent — installing the same registry
|
||||
twice is a no-op; installing a different registry replaces the previous
|
||||
default.
|
||||
"""
|
||||
global _default_registry
|
||||
_default_registry = registry
|
||||
|
||||
|
||||
def _reset_default_registry_for_tests() -> None:
|
||||
"""Test helper — clears the cached default so each test starts fresh."""
|
||||
global _default_registry
|
||||
_default_registry = None
|
||||
_ASYNC_NO_LOOP_WARNED.clear()
|
||||
|
||||
|
||||
+5
-1
@@ -1862,8 +1862,12 @@ class GatewayRunner:
|
||||
self.pairing_store = PairingStore()
|
||||
|
||||
# Event hook system
|
||||
from gateway.hooks import HookRegistry
|
||||
from gateway.hooks import HookRegistry, install_as_default
|
||||
self.hooks = HookRegistry()
|
||||
# Expose this registry as the process-wide default so in-process
|
||||
# plugins (and any other component that uses ``get_default_registry()``)
|
||||
# share state with file-system-discovered hooks loaded into ``self.hooks``.
|
||||
install_as_default(self.hooks)
|
||||
|
||||
# Per-chat voice reply mode: "off" | "voice_only" | "all"
|
||||
self._voice_mode: Dict[str, str] = self._load_voice_modes()
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ let
|
||||
src = ../web;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-6qhGuifHVtCeep1SiQdCUxBMr7UGhYpdMTvXhrQu/zA=";
|
||||
hash = "sha256-HV0aISBVjwbGqDj8qQynSxGFrrZDzuYAW3D3lB/x3zo=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
||||
|
||||
@@ -16,6 +16,7 @@ from agent.image_routing import (
|
||||
_supports_vision_override,
|
||||
build_native_content_parts,
|
||||
decide_image_input_mode,
|
||||
extract_image_refs,
|
||||
)
|
||||
|
||||
|
||||
@@ -449,3 +450,190 @@ class TestLargeImageHandling:
|
||||
assert len(parts) == 2
|
||||
assert parts[0]["type"] == "text"
|
||||
assert parts[1]["type"] == "image_url"
|
||||
|
||||
|
||||
# ─── extract_image_refs ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractImageRefs:
|
||||
"""Scan task body / inbound text for image paths and URLs (kanban worker
|
||||
enrichment, issue raised May 2026)."""
|
||||
|
||||
def test_empty_or_none_returns_empty(self):
|
||||
assert extract_image_refs("") == ([], [])
|
||||
assert extract_image_refs(None) == ([], []) # type: ignore[arg-type]
|
||||
|
||||
def test_finds_absolute_path(self, tmp_path: Path):
|
||||
img = tmp_path / "screenshot.png"
|
||||
img.write_bytes(_png_bytes())
|
||||
body = f"Look at {img} and tell me what's wrong."
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == [str(img)]
|
||||
assert urls == []
|
||||
|
||||
def test_finds_home_relative_path(self, tmp_path: Path, monkeypatch):
|
||||
# Simulate ~/foo.png by pointing HOME at tmp_path and creating the file
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
img = tmp_path / "foo.png"
|
||||
img.write_bytes(_png_bytes())
|
||||
paths, urls = extract_image_refs("see ~/foo.png please")
|
||||
assert paths == [str(img)]
|
||||
assert urls == []
|
||||
|
||||
def test_skips_nonexistent_paths(self, tmp_path: Path):
|
||||
# Path-shaped but no file on disk → skipped.
|
||||
body = f"What's at {tmp_path}/never_created.png ?"
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == []
|
||||
assert urls == []
|
||||
|
||||
def test_finds_http_image_url(self):
|
||||
body = "Check out https://example.com/photos/cat.png — cute right?"
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == []
|
||||
assert urls == ["https://example.com/photos/cat.png"]
|
||||
|
||||
def test_finds_https_url_with_query_string(self):
|
||||
body = "Diagram: https://cdn.example.com/img.jpeg?size=large&v=2 here"
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert urls == ["https://cdn.example.com/img.jpeg?size=large&v=2"]
|
||||
|
||||
def test_url_trailing_punctuation_stripped(self):
|
||||
# Prose punctuation right after the URL must not be part of the URL.
|
||||
body = "See https://example.com/a.png."
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert urls == ["https://example.com/a.png"]
|
||||
|
||||
def test_ignores_non_image_urls(self):
|
||||
body = "See https://example.com/page.html and https://x.com/y.pdf"
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert urls == []
|
||||
|
||||
def test_dedupes_paths_and_urls(self, tmp_path: Path):
|
||||
img = tmp_path / "dup.png"
|
||||
img.write_bytes(_png_bytes())
|
||||
body = (
|
||||
f"First {img} then again {img}. "
|
||||
"Also https://example.com/x.png and https://example.com/x.png again."
|
||||
)
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == [str(img)]
|
||||
assert urls == ["https://example.com/x.png"]
|
||||
|
||||
def test_ignores_paths_in_fenced_code_block(self, tmp_path: Path):
|
||||
img = tmp_path / "real.png"
|
||||
img.write_bytes(_png_bytes())
|
||||
body = (
|
||||
"Outside the block, attach this:\n"
|
||||
f"{img}\n"
|
||||
"But not these examples:\n"
|
||||
"```\n"
|
||||
f"some_other_image: /tmp/example.png\n"
|
||||
f"url: https://example.com/example.png\n"
|
||||
"```\n"
|
||||
)
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == [str(img)]
|
||||
assert urls == []
|
||||
|
||||
def test_ignores_paths_in_inline_code(self, tmp_path: Path):
|
||||
img = tmp_path / "real.jpg"
|
||||
img.write_bytes(_png_bytes())
|
||||
body = (
|
||||
f"Attach {img}, but ignore the example "
|
||||
"`https://example.com/skip.png` in backticks."
|
||||
)
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == [str(img)]
|
||||
assert urls == []
|
||||
|
||||
def test_does_not_match_paths_inside_urls(self, tmp_path: Path):
|
||||
# The lookbehind in the regex prevents matching the path-portion of
|
||||
# a URL as a local path. Only the URL should be detected.
|
||||
body = "Just the URL: https://example.com/some/dir/image.png"
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == []
|
||||
assert urls == ["https://example.com/some/dir/image.png"]
|
||||
|
||||
def test_mixed_paths_and_urls(self, tmp_path: Path):
|
||||
img = tmp_path / "local.png"
|
||||
img.write_bytes(_png_bytes())
|
||||
body = (
|
||||
f"Compare local {img} against the design at "
|
||||
"https://example.com/design/v2.png — does it match?"
|
||||
)
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == [str(img)]
|
||||
assert urls == ["https://example.com/design/v2.png"]
|
||||
|
||||
def test_case_insensitive_extension(self, tmp_path: Path):
|
||||
img = tmp_path / "shouty.PNG"
|
||||
img.write_bytes(_png_bytes())
|
||||
body = f"see {img}"
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == [str(img)]
|
||||
|
||||
|
||||
# ─── build_native_content_parts with URLs ────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildNativeContentPartsURLs:
|
||||
"""URL pass-through support added so kanban task bodies (and other
|
||||
inbound surfaces) can route remote image URLs straight to the model."""
|
||||
|
||||
def test_url_only_no_local_paths(self):
|
||||
parts, skipped = build_native_content_parts(
|
||||
"what is this?",
|
||||
[],
|
||||
image_urls=["https://example.com/diagram.png"],
|
||||
)
|
||||
assert skipped == []
|
||||
assert len(parts) == 2
|
||||
assert parts[0]["type"] == "text"
|
||||
assert "[Image attached: https://example.com/diagram.png]" in parts[0]["text"]
|
||||
assert parts[0]["text"].startswith("what is this?")
|
||||
assert parts[1] == {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/diagram.png"},
|
||||
}
|
||||
|
||||
def test_mixed_path_and_url(self, tmp_path: Path):
|
||||
img = tmp_path / "local.png"
|
||||
img.write_bytes(_png_bytes())
|
||||
parts, skipped = build_native_content_parts(
|
||||
"compare these",
|
||||
[str(img)],
|
||||
image_urls=["https://example.com/remote.jpg"],
|
||||
)
|
||||
assert skipped == []
|
||||
# 1 text + 2 image parts (local data URL first, then remote URL).
|
||||
image_parts = [p for p in parts if p.get("type") == "image_url"]
|
||||
assert len(image_parts) == 2
|
||||
assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
assert image_parts[1]["image_url"]["url"] == "https://example.com/remote.jpg"
|
||||
text = parts[0]["text"]
|
||||
assert "[Image attached at:" in text
|
||||
assert "[Image attached: https://example.com/remote.jpg]" in text
|
||||
|
||||
def test_empty_url_list_is_no_op(self, tmp_path: Path):
|
||||
img = tmp_path / "x.png"
|
||||
img.write_bytes(_png_bytes())
|
||||
# image_urls=[] should behave the same as not passing it at all.
|
||||
parts_no_urls, _ = build_native_content_parts("hi", [str(img)])
|
||||
parts_empty_urls, _ = build_native_content_parts("hi", [str(img)], image_urls=[])
|
||||
assert parts_no_urls == parts_empty_urls
|
||||
|
||||
def test_blank_url_strings_are_dropped(self):
|
||||
parts, _ = build_native_content_parts(
|
||||
"x", [], image_urls=["", " ", "https://example.com/a.png"]
|
||||
)
|
||||
image_parts = [p for p in parts if p.get("type") == "image_url"]
|
||||
assert len(image_parts) == 1
|
||||
assert image_parts[0]["image_url"]["url"] == "https://example.com/a.png"
|
||||
|
||||
def test_url_only_inserts_default_prompt_when_text_empty(self):
|
||||
parts, _ = build_native_content_parts(
|
||||
"", [], image_urls=["https://example.com/a.png"]
|
||||
)
|
||||
assert parts[0]["type"] == "text"
|
||||
assert parts[0]["text"].startswith("What do you see in this image?")
|
||||
|
||||
+112
-26
@@ -88,7 +88,15 @@ def test_dashboard_slot_reports_up_when_enabled(
|
||||
"""Symmetry: with HERMES_DASHBOARD=1, s6-svstat reports the slot as up."""
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container_name,
|
||||
"-e", "HERMES_DASHBOARD=1", built_image, "sleep", "120"],
|
||||
"-e", "HERMES_DASHBOARD=1",
|
||||
# The default dashboard host is 0.0.0.0, which now engages the
|
||||
# OAuth auth gate. Without a provider registered (no
|
||||
# HERMES_DASHBOARD_OAUTH_CLIENT_ID in this test env), start_server
|
||||
# would fail closed and the slot would never come up. Pin the
|
||||
# explicit insecure opt-in to keep this test focused on the s6
|
||||
# supervision contract, not the auth gate.
|
||||
"-e", "HERMES_DASHBOARD_INSECURE=1",
|
||||
built_image, "sleep", "120"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
# uvicorn takes a moment to bind; poll svstat.
|
||||
@@ -113,7 +121,12 @@ def test_dashboard_opt_in_starts(
|
||||
"""With HERMES_DASHBOARD=1, a dashboard process should be visible."""
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container_name,
|
||||
"-e", "HERMES_DASHBOARD=1", built_image, "sleep", "120"],
|
||||
"-e", "HERMES_DASHBOARD=1",
|
||||
# Default bind is 0.0.0.0; pin insecure opt-in so the auth gate
|
||||
# doesn't fail-closed before the process can come up. See
|
||||
# test_dashboard_slot_reports_up_when_enabled for the full rationale.
|
||||
"-e", "HERMES_DASHBOARD_INSECURE=1",
|
||||
built_image, "sleep", "120"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
# Poll for the dashboard subprocess to appear — the entrypoint
|
||||
@@ -132,6 +145,10 @@ def test_dashboard_port_override(
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container_name,
|
||||
"-e", "HERMES_DASHBOARD=1", "-e", "HERMES_DASHBOARD_PORT=9120",
|
||||
# Default bind is 0.0.0.0; pin insecure opt-in so the auth gate
|
||||
# doesn't fail-closed before the port is bound. See
|
||||
# test_dashboard_slot_reports_up_when_enabled for the full rationale.
|
||||
"-e", "HERMES_DASHBOARD_INSECURE=1",
|
||||
built_image, "sleep", "120"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
@@ -161,7 +178,13 @@ def test_dashboard_restarts_after_crash(
|
||||
"""
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container_name,
|
||||
"-e", "HERMES_DASHBOARD=1", built_image, "sleep", "120"],
|
||||
"-e", "HERMES_DASHBOARD=1",
|
||||
# Default bind is 0.0.0.0; pin insecure opt-in so the auth gate
|
||||
# doesn't fail-closed before the supervised dashboard can come up.
|
||||
# See test_dashboard_slot_reports_up_when_enabled for the full
|
||||
# rationale.
|
||||
"-e", "HERMES_DASHBOARD_INSECURE=1",
|
||||
built_image, "sleep", "120"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
# Wait for the first dashboard to come up.
|
||||
@@ -214,36 +237,67 @@ def test_dashboard_restarts_after_crash(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fetch_api_status(container: str, *, deadline_s: float = 60.0) -> dict:
|
||||
"""Poll ``/api/status`` from inside the container via the venv python.
|
||||
def _http_probe(
|
||||
container: str,
|
||||
path: str,
|
||||
*,
|
||||
deadline_s: float = 60.0,
|
||||
) -> tuple[int, str]:
|
||||
"""Poll ``http://127.0.0.1:9119<path>`` from inside the container.
|
||||
|
||||
The dashboard binds to ``HERMES_DASHBOARD_HOST`` (typically ``0.0.0.0``)
|
||||
so loopback inside the container works. The image doesn't ship
|
||||
``curl`` but Python's stdlib ``urllib`` is good enough.
|
||||
Returns ``(status_code, body)`` as soon as the dashboard answers any
|
||||
HTTP response — 200, 401, 503, anything. The image doesn't ship
|
||||
``curl`` but the venv's stdlib ``urllib`` is good enough; we use a
|
||||
proper ``try``/``except`` to intercept ``HTTPError`` because
|
||||
``urlopen`` raises on 4xx/5xx, and we treat those as legitimate
|
||||
responses (the OAuth gate's 401 IS the success signal for the
|
||||
gate-engaged test).
|
||||
|
||||
Returns the decoded JSON dict on success; raises AssertionError on
|
||||
timeout.
|
||||
Connection errors (uvicorn still starting, fail-closed exited) keep
|
||||
the poll loop running until ``deadline_s`` elapses.
|
||||
|
||||
The probe Python program is fed over stdin (``python -``) rather
|
||||
than ``python -c`` so we can use proper multi-line syntax with
|
||||
``try``/``except`` blocks without escaping hell.
|
||||
|
||||
Raises ``AssertionError`` on timeout.
|
||||
"""
|
||||
py_program = f"""\
|
||||
import urllib.request, urllib.error
|
||||
req = urllib.request.Request("http://127.0.0.1:9119{path}")
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=5)
|
||||
print(r.status)
|
||||
print(r.read().decode(), end="")
|
||||
except urllib.error.HTTPError as h:
|
||||
print(h.code)
|
||||
print(h.read().decode(), end="")
|
||||
"""
|
||||
# Feed the program over stdin via a heredoc so docker_exec_sh's
|
||||
# single bash string stays clean. The 'PY' delimiter is quoted to
|
||||
# disable shell expansion inside the heredoc body.
|
||||
probe = (
|
||||
"/opt/hermes/.venv/bin/python -c "
|
||||
"'import json,urllib.request as u;"
|
||||
"print(u.urlopen(\"http://127.0.0.1:9119/api/status\",timeout=5)"
|
||||
".read().decode())'"
|
||||
"/opt/hermes/.venv/bin/python - <<'PY'\n"
|
||||
f"{py_program}"
|
||||
"PY"
|
||||
)
|
||||
end = time.monotonic() + deadline_s
|
||||
last_err = ""
|
||||
while time.monotonic() < end:
|
||||
r = docker_exec_sh(container, probe, timeout=10)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
lines = r.stdout.split("\n", 1)
|
||||
try:
|
||||
return json.loads(r.stdout)
|
||||
except (ValueError, json.JSONDecodeError) as exc: # noqa: F841
|
||||
last_err = f"json parse: {exc!r} / stdout={r.stdout!r}"
|
||||
status = int(lines[0].strip())
|
||||
body = lines[1] if len(lines) > 1 else ""
|
||||
return status, body
|
||||
except (ValueError, IndexError) as exc:
|
||||
last_err = f"parse: {exc!r} / stdout={r.stdout!r}"
|
||||
else:
|
||||
last_err = f"rc={r.returncode} stderr={r.stderr!r}"
|
||||
time.sleep(0.5)
|
||||
raise AssertionError(
|
||||
f"/api/status never returned valid JSON within {deadline_s}s; "
|
||||
f"Probe of {path} never returned HTTP within {deadline_s}s; "
|
||||
f"last error: {last_err}"
|
||||
)
|
||||
|
||||
@@ -263,6 +317,17 @@ def test_dashboard_oauth_gate_engages_on_non_loopback_bind(
|
||||
flipped ``--insecure`` on for any non-loopback bind, which routed
|
||||
``start_server`` straight back into the legacy ``allow_public=True``
|
||||
branch and disabled the gate every time.
|
||||
|
||||
We verify two independent observable consequences of the gate being
|
||||
on:
|
||||
|
||||
1. ``/api/auth/providers`` (publicly reachable through the gate so
|
||||
the login page can bootstrap) returns 200 with ``nous`` in the
|
||||
provider list — proves the bundled provider registered.
|
||||
2. ``/api/status`` (a public endpoint under the legacy
|
||||
``_SESSION_TOKEN`` middleware) returns 401 — proves the OAuth gate
|
||||
runs upstream of the legacy public list and is actively
|
||||
intercepting unauthenticated callers.
|
||||
"""
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container_name,
|
||||
@@ -272,15 +337,27 @@ def test_dashboard_oauth_gate_engages_on_non_loopback_bind(
|
||||
built_image, "sleep", "120"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
status = _fetch_api_status(container_name)
|
||||
assert status.get("auth_required") is True, (
|
||||
"OAuth gate must be engaged on 0.0.0.0 bind when a provider is "
|
||||
"registered and HERMES_DASHBOARD_INSECURE is unset. Got: "
|
||||
f"{status!r}"
|
||||
|
||||
# (1) Provider registry visible via the public bootstrap endpoint.
|
||||
status_code, body = _http_probe(container_name, "/api/auth/providers")
|
||||
assert status_code == 200, (
|
||||
f"/api/auth/providers should return 200 when a provider is "
|
||||
f"registered; got {status_code} body={body!r}"
|
||||
)
|
||||
assert "nous" in status.get("auth_providers", []), (
|
||||
payload = json.loads(body)
|
||||
provider_names = [p.get("name") for p in payload.get("providers", [])]
|
||||
assert "nous" in provider_names, (
|
||||
"Bundled dashboard_auth/nous provider should register when "
|
||||
f"HERMES_DASHBOARD_OAUTH_CLIENT_ID is set. Got: {status!r}"
|
||||
f"HERMES_DASHBOARD_OAUTH_CLIENT_ID is set. Got: {payload!r}"
|
||||
)
|
||||
|
||||
# (2) /api/status is gated by the OAuth middleware → unauthenticated
|
||||
# callers get 401, not the legacy public 200 JSON.
|
||||
status_code, body = _http_probe(container_name, "/api/status")
|
||||
assert status_code == 401, (
|
||||
"OAuth gate must intercept /api/status on 0.0.0.0 bind when a "
|
||||
"provider is registered and HERMES_DASHBOARD_INSECURE is unset. "
|
||||
f"Got: status={status_code} body={body!r}"
|
||||
)
|
||||
|
||||
|
||||
@@ -291,6 +368,10 @@ def test_dashboard_insecure_env_var_opts_out_of_gate(
|
||||
for operators running on trusted LANs behind a reverse proxy without
|
||||
the OAuth contract. Same opt-out shape as the rest of the s6 boolean
|
||||
envs (``HERMES_DASHBOARD``, ``HERMES_DASHBOARD_TUI``).
|
||||
|
||||
With the gate off, ``/api/status`` (a public endpoint under the
|
||||
legacy ``_SESSION_TOKEN`` middleware) returns 200 with the
|
||||
``auth_required: false`` body — proves the gate is bypassed.
|
||||
"""
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container_name,
|
||||
@@ -300,7 +381,12 @@ def test_dashboard_insecure_env_var_opts_out_of_gate(
|
||||
built_image, "sleep", "120"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
status = _fetch_api_status(container_name)
|
||||
status_code, body = _http_probe(container_name, "/api/status")
|
||||
assert status_code == 200, (
|
||||
f"/api/status should return 200 with the auth gate disabled; "
|
||||
f"got {status_code} body={body!r}"
|
||||
)
|
||||
status = json.loads(body)
|
||||
assert status.get("auth_required") is False, (
|
||||
"HERMES_DASHBOARD_INSECURE=1 must disable the auth gate (explicit "
|
||||
f"opt-in for trusted-LAN deployments). Got: {status!r}"
|
||||
|
||||
@@ -316,3 +316,256 @@ class TestEmitCollect:
|
||||
await reg.emit_collect("agent:start") # no context arg
|
||||
|
||||
assert captured == [("agent:start", {})]
|
||||
|
||||
|
||||
class TestRegister:
|
||||
"""Tests for the programmatic ``HookRegistry.register`` API."""
|
||||
|
||||
def test_registers_handler(self):
|
||||
reg = HookRegistry()
|
||||
calls: list = []
|
||||
|
||||
def handler(event_type, context):
|
||||
calls.append((event_type, context))
|
||||
|
||||
reg.register("agent:start", handler)
|
||||
|
||||
assert "agent:start" in reg._handlers
|
||||
assert reg._handlers["agent:start"] == [handler]
|
||||
|
||||
def test_records_metadata_in_loaded_hooks(self):
|
||||
reg = HookRegistry()
|
||||
|
||||
def my_handler(_e, _c):
|
||||
return None
|
||||
|
||||
reg.register("tui:tool.start", my_handler)
|
||||
|
||||
assert len(reg.loaded_hooks) == 1
|
||||
meta = reg.loaded_hooks[0]
|
||||
assert meta["name"] == "my_handler"
|
||||
assert meta["events"] == ["tui:tool.start"]
|
||||
assert meta["path"] == "<programmatic>"
|
||||
|
||||
def test_custom_name_override(self):
|
||||
reg = HookRegistry()
|
||||
|
||||
reg.register("agent:end", lambda _e, _c: None, name="orb-collector")
|
||||
|
||||
assert reg.loaded_hooks[0]["name"] == "orb-collector"
|
||||
|
||||
def test_returns_working_unregister(self):
|
||||
reg = HookRegistry()
|
||||
|
||||
def handler(_e, _c):
|
||||
return None
|
||||
|
||||
unregister = reg.register("agent:start", handler)
|
||||
|
||||
assert handler in reg._handlers["agent:start"]
|
||||
assert len(reg.loaded_hooks) == 1
|
||||
|
||||
unregister()
|
||||
|
||||
assert handler not in reg._handlers["agent:start"]
|
||||
assert len(reg.loaded_hooks) == 0
|
||||
|
||||
def test_unregister_is_idempotent(self):
|
||||
reg = HookRegistry()
|
||||
unregister = reg.register("agent:start", lambda _e, _c: None)
|
||||
unregister()
|
||||
# Second call should not raise.
|
||||
unregister()
|
||||
|
||||
def test_multiple_handlers_same_event(self):
|
||||
reg = HookRegistry()
|
||||
calls: list = []
|
||||
|
||||
def h1(_e, _c):
|
||||
calls.append("h1")
|
||||
|
||||
def h2(_e, _c):
|
||||
calls.append("h2")
|
||||
|
||||
reg.register("agent:start", h1)
|
||||
reg.register("agent:start", h2)
|
||||
|
||||
assert reg._handlers["agent:start"] == [h1, h2]
|
||||
assert len(reg.loaded_hooks) == 2
|
||||
|
||||
def test_unregister_does_not_affect_other_handlers(self):
|
||||
reg = HookRegistry()
|
||||
|
||||
def h1(_e, _c):
|
||||
return None
|
||||
|
||||
def h2(_e, _c):
|
||||
return None
|
||||
|
||||
unreg1 = reg.register("agent:start", h1)
|
||||
reg.register("agent:start", h2)
|
||||
|
||||
unreg1()
|
||||
|
||||
assert h1 not in reg._handlers["agent:start"]
|
||||
assert h2 in reg._handlers["agent:start"]
|
||||
|
||||
|
||||
class TestEmitSync:
|
||||
"""Tests for the synchronous emit path used from hot non-async callers."""
|
||||
|
||||
def test_fires_sync_handler(self):
|
||||
reg = HookRegistry()
|
||||
calls: list = []
|
||||
|
||||
reg.register(
|
||||
"tui:tool.start",
|
||||
lambda e, c: calls.append((e, c)),
|
||||
)
|
||||
|
||||
reg.emit_sync("tui:tool.start", {"session_id": "s1", "payload": {"name": "foo"}})
|
||||
|
||||
assert calls == [("tui:tool.start", {"session_id": "s1", "payload": {"name": "foo"}})]
|
||||
|
||||
def test_default_context_when_none(self):
|
||||
reg = HookRegistry()
|
||||
seen: list = []
|
||||
reg.register("evt:x", lambda _e, c: seen.append(c))
|
||||
|
||||
reg.emit_sync("evt:x") # no context arg
|
||||
|
||||
assert seen == [{}]
|
||||
|
||||
def test_sync_handler_exception_isolated(self):
|
||||
reg = HookRegistry()
|
||||
calls: list = []
|
||||
|
||||
def bad(_e, _c):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
def good(_e, _c):
|
||||
calls.append("good")
|
||||
|
||||
reg.register("evt:x", bad)
|
||||
reg.register("evt:x", good)
|
||||
|
||||
# Must not raise; second handler still fires.
|
||||
reg.emit_sync("evt:x", {})
|
||||
|
||||
assert calls == ["good"]
|
||||
|
||||
def test_wildcard_matching(self):
|
||||
reg = HookRegistry()
|
||||
calls: list = []
|
||||
|
||||
reg.register("tui:*", lambda e, _c: calls.append(e))
|
||||
reg.register("tui:tool.start", lambda e, _c: calls.append(f"exact:{e}"))
|
||||
|
||||
reg.emit_sync("tui:tool.start", {})
|
||||
|
||||
# Exact match first, then wildcard.
|
||||
assert calls == ["exact:tui:tool.start", "tui:tool.start"]
|
||||
|
||||
def test_no_handlers_does_not_raise(self):
|
||||
reg = HookRegistry()
|
||||
# Just shouldn't blow up.
|
||||
reg.emit_sync("nobody:listening", {"foo": "bar"})
|
||||
|
||||
def test_async_handler_skipped_with_no_loop(self, capsys):
|
||||
from gateway.hooks import _reset_default_registry_for_tests
|
||||
|
||||
_reset_default_registry_for_tests()
|
||||
reg = HookRegistry()
|
||||
marker: list = []
|
||||
|
||||
async def async_handler(_e, _c):
|
||||
marker.append("ran")
|
||||
|
||||
reg.register("evt:x", async_handler, name="async_handler_unique")
|
||||
|
||||
# First emit logs a warning and skips.
|
||||
reg.emit_sync("evt:x", {})
|
||||
captured = capsys.readouterr()
|
||||
# The warning uses the handler's __name__ for diagnostic clarity.
|
||||
assert "async_handler" in captured.out
|
||||
assert "Skipping async handler" in captured.out
|
||||
assert marker == [] # async handler never ran
|
||||
|
||||
# Second emit is silent (warning suppressed).
|
||||
reg.emit_sync("evt:x", {})
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert marker == []
|
||||
|
||||
def test_async_handler_scheduled_when_loop_running(self):
|
||||
import asyncio as _asyncio
|
||||
|
||||
reg = HookRegistry()
|
||||
marker: list = []
|
||||
|
||||
async def async_handler(_e, _c):
|
||||
marker.append("ran")
|
||||
|
||||
reg.register("evt:x", async_handler)
|
||||
|
||||
async def driver():
|
||||
reg.emit_sync("evt:x", {})
|
||||
# Yield to the loop so the scheduled task can run.
|
||||
await _asyncio.sleep(0)
|
||||
await _asyncio.sleep(0)
|
||||
|
||||
_asyncio.run(driver())
|
||||
|
||||
assert marker == ["ran"]
|
||||
|
||||
|
||||
class TestDefaultRegistry:
|
||||
"""Tests for the module-level default-registry singleton."""
|
||||
|
||||
def test_get_default_returns_same_instance(self):
|
||||
from gateway.hooks import (
|
||||
_reset_default_registry_for_tests,
|
||||
get_default_registry,
|
||||
)
|
||||
|
||||
_reset_default_registry_for_tests()
|
||||
|
||||
first = get_default_registry()
|
||||
second = get_default_registry()
|
||||
|
||||
assert first is second
|
||||
|
||||
def test_install_as_default_replaces(self):
|
||||
from gateway.hooks import (
|
||||
_reset_default_registry_for_tests,
|
||||
get_default_registry,
|
||||
install_as_default,
|
||||
)
|
||||
|
||||
_reset_default_registry_for_tests()
|
||||
|
||||
custom = HookRegistry()
|
||||
install_as_default(custom)
|
||||
|
||||
assert get_default_registry() is custom
|
||||
|
||||
def test_install_then_get_picks_up_handlers(self):
|
||||
from gateway.hooks import (
|
||||
_reset_default_registry_for_tests,
|
||||
get_default_registry,
|
||||
install_as_default,
|
||||
)
|
||||
|
||||
_reset_default_registry_for_tests()
|
||||
|
||||
custom = HookRegistry()
|
||||
install_as_default(custom)
|
||||
|
||||
calls: list = []
|
||||
get_default_registry().register("agent:x", lambda _e, _c: calls.append("hit"))
|
||||
|
||||
# Same handler is visible on the installed instance.
|
||||
custom.emit_sync("agent:x", {})
|
||||
|
||||
assert calls == ["hit"]
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Worker-side image enrichment for kanban tasks.
|
||||
|
||||
When a kanban task body contains a local image path or an ``http(s)://``
|
||||
image URL, the worker must surface that image to the model on its first
|
||||
user turn — matching the CLI/gateway behaviour for inbound images.
|
||||
|
||||
The dispatcher spawns the worker as
|
||||
``hermes -p <profile> chat -q "work kanban task <id>"``. The task body
|
||||
itself never appears in argv; the worker has to read it from the kanban
|
||||
DB during startup. These tests cover the round-trip:
|
||||
|
||||
task body → kanban_db.get_task → extract_image_refs →
|
||||
build_native_content_parts → multimodal user turn
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
from agent.image_routing import (
|
||||
build_native_content_parts,
|
||||
extract_image_refs,
|
||||
)
|
||||
|
||||
|
||||
# Tiny 1×1 transparent PNG used to back any path the tests stick into a
|
||||
# task body. extract_image_refs validates the path exists on disk, so the
|
||||
# byte content has to be a real readable file (any image bytes will do).
|
||||
_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABpfZFQAAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kanban_home(tmp_path: Path, monkeypatch):
|
||||
"""Isolated HERMES_HOME with a fresh kanban DB for each test."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
return home
|
||||
|
||||
|
||||
def _add_task_with_body(body: str, *, title: str = "Look at this") -> str:
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = kb.create_task(
|
||||
conn,
|
||||
title=title,
|
||||
body=body,
|
||||
assignee="worker-a",
|
||||
tenant=None,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return task_id
|
||||
|
||||
|
||||
def _read_body(task_id: str) -> str:
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task = kb.get_task(conn, task_id)
|
||||
return (task.body if task is not None else "") or ""
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestExtractFromTaskBody:
|
||||
"""Read a real kanban task body and run it through extract_image_refs."""
|
||||
|
||||
def test_local_path_in_body_round_trips(self, kanban_home, tmp_path):
|
||||
img = tmp_path / "screenshot.png"
|
||||
img.write_bytes(_PNG)
|
||||
tid = _add_task_with_body(
|
||||
f"Please review the screenshot at {img} and confirm "
|
||||
"the alignment is right."
|
||||
)
|
||||
|
||||
body = _read_body(tid)
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == [str(img)]
|
||||
assert urls == []
|
||||
|
||||
def test_url_in_body_round_trips(self, kanban_home):
|
||||
tid = _add_task_with_body(
|
||||
"The design lives at https://example.com/mock/v3.png — "
|
||||
"make the implementation match it."
|
||||
)
|
||||
|
||||
body = _read_body(tid)
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == []
|
||||
assert urls == ["https://example.com/mock/v3.png"]
|
||||
|
||||
def test_mixed_path_and_url_in_body(self, kanban_home, tmp_path):
|
||||
img = tmp_path / "current.png"
|
||||
img.write_bytes(_PNG)
|
||||
tid = _add_task_with_body(
|
||||
f"Compare the current screenshot {img} against the design at "
|
||||
"https://example.com/target.png and write a diff."
|
||||
)
|
||||
|
||||
body = _read_body(tid)
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == [str(img)]
|
||||
assert urls == ["https://example.com/target.png"]
|
||||
|
||||
def test_body_without_images_yields_nothing(self, kanban_home):
|
||||
tid = _add_task_with_body(
|
||||
"Refactor the auth module to use the new session helper."
|
||||
)
|
||||
|
||||
body = _read_body(tid)
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == []
|
||||
assert urls == []
|
||||
|
||||
def test_empty_body_is_safe(self, kanban_home):
|
||||
tid = _add_task_with_body("")
|
||||
|
||||
body = _read_body(tid)
|
||||
paths, urls = extract_image_refs(body)
|
||||
assert paths == []
|
||||
assert urls == []
|
||||
|
||||
|
||||
class TestBuildPartsFromTaskBody:
|
||||
"""Verify the full pipeline produces a multimodal user turn."""
|
||||
|
||||
def test_local_path_becomes_native_image_part(self, kanban_home, tmp_path):
|
||||
img = tmp_path / "design.png"
|
||||
img.write_bytes(_PNG)
|
||||
tid = _add_task_with_body(f"Check out {img} — what's broken?")
|
||||
body = _read_body(tid)
|
||||
paths, urls = extract_image_refs(body)
|
||||
|
||||
# Mirrors the cli.py wiring: pass the worker's literal -q argument
|
||||
# (the dispatcher uses ``"work kanban task <id>"``) plus the
|
||||
# extracted refs through build_native_content_parts.
|
||||
parts, skipped = build_native_content_parts(
|
||||
f"work kanban task {tid}",
|
||||
paths,
|
||||
image_urls=urls or None,
|
||||
)
|
||||
|
||||
assert skipped == []
|
||||
# text part + one image_url part
|
||||
assert len(parts) == 2
|
||||
assert parts[0]["type"] == "text"
|
||||
assert parts[0]["text"].startswith(f"work kanban task {tid}")
|
||||
assert f"[Image attached at: {img}]" in parts[0]["text"]
|
||||
assert parts[1]["type"] == "image_url"
|
||||
assert parts[1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
def test_url_becomes_image_url_part(self, kanban_home):
|
||||
tid = _add_task_with_body(
|
||||
"Reference: https://example.com/target.jpg — match it."
|
||||
)
|
||||
body = _read_body(tid)
|
||||
paths, urls = extract_image_refs(body)
|
||||
|
||||
parts, skipped = build_native_content_parts(
|
||||
f"work kanban task {tid}",
|
||||
paths,
|
||||
image_urls=urls or None,
|
||||
)
|
||||
|
||||
assert skipped == []
|
||||
assert len(parts) == 2
|
||||
assert parts[0]["type"] == "text"
|
||||
assert "[Image attached: https://example.com/target.jpg]" in parts[0]["text"]
|
||||
assert parts[1] == {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/target.jpg"},
|
||||
}
|
||||
|
||||
def test_body_with_both_yields_two_image_parts(self, kanban_home, tmp_path):
|
||||
img = tmp_path / "local.png"
|
||||
img.write_bytes(_PNG)
|
||||
tid = _add_task_with_body(
|
||||
f"Diff {img} vs https://example.com/target.png — explain it."
|
||||
)
|
||||
body = _read_body(tid)
|
||||
paths, urls = extract_image_refs(body)
|
||||
|
||||
parts, skipped = build_native_content_parts(
|
||||
f"work kanban task {tid}",
|
||||
paths,
|
||||
image_urls=urls or None,
|
||||
)
|
||||
|
||||
assert skipped == []
|
||||
image_parts = [p for p in parts if p.get("type") == "image_url"]
|
||||
assert len(image_parts) == 2
|
||||
# Local file is embedded as a data URL; remote URL passes through.
|
||||
assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
assert image_parts[1]["image_url"]["url"] == "https://example.com/target.png"
|
||||
|
||||
def test_body_with_no_images_leaves_query_untouched(self, kanban_home):
|
||||
tid = _add_task_with_body(
|
||||
"Rewrite the README intro paragraph to focus on use cases."
|
||||
)
|
||||
body = _read_body(tid)
|
||||
paths, urls = extract_image_refs(body)
|
||||
|
||||
parts, skipped = build_native_content_parts(
|
||||
f"work kanban task {tid}",
|
||||
paths,
|
||||
image_urls=urls or None,
|
||||
)
|
||||
|
||||
# No images → plain text-only return (single part, no list mutation).
|
||||
assert skipped == []
|
||||
assert len(parts) == 1
|
||||
assert parts[0]["type"] == "text"
|
||||
assert parts[0]["text"] == f"work kanban task {tid}"
|
||||
|
||||
def test_code_block_example_is_not_attached(self, kanban_home, tmp_path):
|
||||
# Only the real image outside the fenced code block should attach.
|
||||
real = tmp_path / "real.png"
|
||||
real.write_bytes(_PNG)
|
||||
tid = _add_task_with_body(
|
||||
f"Real screenshot:\n{real}\n\n"
|
||||
"Example we DON'T want attached:\n"
|
||||
"```\n"
|
||||
"image: /tmp/example_only.png\n"
|
||||
"url: https://example.com/example.png\n"
|
||||
"```\n"
|
||||
)
|
||||
body = _read_body(tid)
|
||||
paths, urls = extract_image_refs(body)
|
||||
|
||||
assert paths == [str(real)]
|
||||
assert urls == []
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tests for the TUI gateway → ``gateway.hooks`` bridge.
|
||||
|
||||
Every call into ``tui_gateway.server._emit`` should mirror the event onto the
|
||||
process-wide ``HookRegistry`` under the ``tui:`` namespace so in-process
|
||||
plugins can subscribe via :func:`gateway.hooks.get_default_registry`.
|
||||
|
||||
The mirror runs as a side-effect after ``write_json`` and is wrapped in a
|
||||
broad try/except so a buggy subscriber can never break the main JSON-RPC
|
||||
dispatch path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.hooks import (
|
||||
HookRegistry,
|
||||
_reset_default_registry_for_tests,
|
||||
get_default_registry,
|
||||
install_as_default,
|
||||
)
|
||||
from tui_gateway import server
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry_and_module_cache():
|
||||
"""Reset the default registry and the TUI module-level cache before each test.
|
||||
|
||||
Without this the cache from a previous test (or a previous run within the
|
||||
same process) would shadow our fresh install_as_default call and the
|
||||
mirrored event would land on the wrong registry.
|
||||
"""
|
||||
_reset_default_registry_for_tests()
|
||||
# Force the deferred-import cache in the TUI module to re-resolve.
|
||||
server._hook_registry = None
|
||||
yield
|
||||
_reset_default_registry_for_tests()
|
||||
server._hook_registry = None
|
||||
|
||||
|
||||
class _StubTransport:
|
||||
"""Captures write_json calls so the test doesn't actually touch stdout."""
|
||||
|
||||
def __init__(self):
|
||||
self.written: list[dict] = []
|
||||
|
||||
def write(self, obj):
|
||||
self.written.append(obj)
|
||||
return True
|
||||
|
||||
|
||||
def test_emit_mirrors_to_default_registry():
|
||||
transport = _StubTransport()
|
||||
captured: list = []
|
||||
reg = HookRegistry()
|
||||
install_as_default(reg)
|
||||
reg.register("tui:tool.start", lambda e, c: captured.append((e, c)))
|
||||
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "sid-123", {"name": "search_files"})
|
||||
|
||||
# The JSON-RPC event was written as before.
|
||||
assert len(transport.written) == 1
|
||||
assert transport.written[0]["params"]["type"] == "tool.start"
|
||||
|
||||
# The hook bus saw a tui:-prefixed mirror.
|
||||
assert captured == [
|
||||
(
|
||||
"tui:tool.start",
|
||||
{"session_id": "sid-123", "payload": {"name": "search_files"}},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_emit_with_no_payload_yields_empty_payload_dict():
|
||||
transport = _StubTransport()
|
||||
captured: list = []
|
||||
reg = HookRegistry()
|
||||
install_as_default(reg)
|
||||
reg.register("tui:session.info", lambda _e, c: captured.append(c))
|
||||
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("session.info", "sid-1", None)
|
||||
|
||||
assert captured == [{"session_id": "sid-1", "payload": {}}]
|
||||
|
||||
|
||||
def test_emit_subscriber_exception_does_not_break_dispatch():
|
||||
transport = _StubTransport()
|
||||
reg = HookRegistry()
|
||||
install_as_default(reg)
|
||||
|
||||
def broken(_e, _c):
|
||||
raise RuntimeError("subscriber blew up")
|
||||
|
||||
reg.register("tui:tool.start", broken)
|
||||
|
||||
# If _publish_tui_hook propagated, this would raise.
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "sid-1", {"name": "x"})
|
||||
|
||||
# JSON-RPC event still landed on stdout — host pipeline intact.
|
||||
assert len(transport.written) == 1
|
||||
assert transport.written[0]["params"]["type"] == "tool.start"
|
||||
|
||||
|
||||
def test_wildcard_subscriber_sees_all_tui_events():
|
||||
transport = _StubTransport()
|
||||
seen_types: list = []
|
||||
reg = HookRegistry()
|
||||
install_as_default(reg)
|
||||
reg.register("tui:*", lambda e, _c: seen_types.append(e))
|
||||
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "s", {})
|
||||
server._emit("message.delta", "s", {"text": "hi"})
|
||||
server._emit("session.info", "s", {})
|
||||
|
||||
assert seen_types == [
|
||||
"tui:tool.start",
|
||||
"tui:message.delta",
|
||||
"tui:session.info",
|
||||
]
|
||||
|
||||
|
||||
def test_emit_does_not_blow_up_when_no_subscribers():
|
||||
transport = _StubTransport()
|
||||
# No registry installed beyond the lazy default — and no handlers.
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "s", {"name": "x"})
|
||||
|
||||
# Dispatch worked, no subscribers fired (default registry is empty).
|
||||
assert len(transport.written) == 1
|
||||
|
||||
|
||||
def test_hook_registry_resolved_lazily_via_get_default_registry():
|
||||
"""The TUI module caches whatever ``get_default_registry`` returns at first
|
||||
use. Re-set the default before the first ``_emit`` and confirm the cache
|
||||
picks up the new instance, not a stale or never-installed one."""
|
||||
transport = _StubTransport()
|
||||
captured: list = []
|
||||
custom = HookRegistry()
|
||||
install_as_default(custom)
|
||||
custom.register("tui:tool.start", lambda _e, c: captured.append(c))
|
||||
|
||||
# Sanity: server._hook_registry starts unset thanks to the fixture.
|
||||
assert server._hook_registry is None
|
||||
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "s", {"x": 1})
|
||||
|
||||
# The cache now points at the installed registry.
|
||||
assert server._hook_registry is custom
|
||||
assert captured == [{"session_id": "s", "payload": {"x": 1}}]
|
||||
|
||||
# Subsequent emits keep using the cached reference even if the default
|
||||
# is swapped — the contract is "resolve once, cache thereafter."
|
||||
new_reg = HookRegistry()
|
||||
install_as_default(new_reg)
|
||||
second: list = []
|
||||
new_reg.register("tui:tool.start", lambda _e, c: second.append(c))
|
||||
custom.register("tui:tool.start", lambda _e, c: captured.append({"second": c}))
|
||||
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "s", {"x": 2})
|
||||
|
||||
# The cached registry (``custom``) saw the new event, the freshly-installed
|
||||
# ``new_reg`` did not.
|
||||
assert second == []
|
||||
# ``custom`` has two handlers registered now (the original lambda still
|
||||
# fires on every event, plus the second one that wraps payload in
|
||||
# ``{"second": ...}``). Both fire on the second ``_emit`` call.
|
||||
assert captured == [
|
||||
{"session_id": "s", "payload": {"x": 1}},
|
||||
{"session_id": "s", "payload": {"x": 2}},
|
||||
{"second": {"session_id": "s", "payload": {"x": 2}}},
|
||||
]
|
||||
assert get_default_registry() is new_reg
|
||||
@@ -34,6 +34,39 @@ def test_resolve_stdio_command_falls_back_to_hermes_node_bin(tmp_path):
|
||||
assert env["PATH"].split(os.pathsep)[0] == str(node_bin)
|
||||
|
||||
|
||||
def test_resolve_stdio_command_falls_back_to_usr_local_bin():
|
||||
"""When ``npx`` isn't on the filtered PATH and isn't under ``$HERMES_HOME/node/bin``
|
||||
or ``~/.local/bin``, the resolver should still locate it at ``/usr/local/bin/npx``.
|
||||
|
||||
This is the canonical install location for Node on Linux from-source builds,
|
||||
the upstream ``node:bookworm-slim`` image (which the Hermes Docker image
|
||||
copies ``node + npm + corepack`` from since #4977), and macOS Homebrew on
|
||||
Intel. Without this candidate, MCP servers run with an ``env.PATH`` that
|
||||
omits ``/usr/local/bin`` (common when users hand-author PATH for sandboxing)
|
||||
fail with ENOENT at ``execvp``.
|
||||
"""
|
||||
target = os.path.join(os.sep, "usr", "local", "bin", "npx")
|
||||
|
||||
# Pretend ONLY the /usr/local/bin/npx candidate exists and is executable —
|
||||
# the other candidates ($HERMES_HOME/node/bin/npx and ~/.local/bin/npx)
|
||||
# should fail isfile() and the resolver must fall through to /usr/local/bin.
|
||||
def _fake_isfile(path):
|
||||
return path == target
|
||||
|
||||
def _fake_access(path, _mode):
|
||||
return path == target
|
||||
|
||||
with patch("tools.mcp_tool.shutil.which", return_value=None), \
|
||||
patch("tools.mcp_tool.os.path.isfile", side_effect=_fake_isfile), \
|
||||
patch("tools.mcp_tool.os.access", side_effect=_fake_access):
|
||||
command, env = _resolve_stdio_command("npx", {"PATH": "/opt/data/bin:/usr/bin:/bin"})
|
||||
|
||||
assert command == target
|
||||
# /usr/local/bin must be prepended so npx's shebang (`/usr/bin/env node`)
|
||||
# can find node in the same directory.
|
||||
assert env["PATH"].split(os.pathsep)[0] == os.path.dirname(target)
|
||||
|
||||
|
||||
def test_resolve_stdio_command_respects_explicit_empty_path():
|
||||
seen_paths = []
|
||||
|
||||
|
||||
@@ -422,6 +422,17 @@ def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
|
||||
candidates = [
|
||||
os.path.join(hermes_home, "node", "bin", resolved_command),
|
||||
os.path.join(os.path.expanduser("~"), ".local", "bin", resolved_command),
|
||||
# /usr/local/bin is the canonical install location for Node on
|
||||
# Linux from-source builds, the upstream node:bookworm-slim
|
||||
# image (which the Hermes Docker image copies node + npm +
|
||||
# corepack from since #4977), and macOS Homebrew on Intel.
|
||||
# Without this candidate, any MCP server configured with an
|
||||
# env.PATH that omits /usr/local/bin (a common pattern when
|
||||
# users hand-author PATH for sandboxing) fails with ENOENT
|
||||
# at execvp, and a naive symlink workaround into the user's
|
||||
# PATH only fails one layer deeper because npx's shebang
|
||||
# re-execs /usr/bin/env node which needs the same directory.
|
||||
os.path.join(os.sep, "usr", "local", "bin", resolved_command),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
|
||||
@@ -388,6 +388,38 @@ def _emit(event: str, sid: str, payload: dict | None = None):
|
||||
if payload is not None:
|
||||
params["payload"] = payload
|
||||
write_json({"jsonrpc": "2.0", "method": "event", "params": params})
|
||||
_publish_tui_hook(event, sid, payload)
|
||||
|
||||
|
||||
def _publish_tui_hook(event: str, sid: str, payload: dict | None) -> None:
|
||||
"""Mirror a TUI gateway dispatch event onto the hook bus.
|
||||
|
||||
Every call from ``_emit`` produces a ``tui:<event>`` hook event so plugins
|
||||
can observe TUI activity (tool starts, message deltas, etc.) without
|
||||
forking ``_emit`` itself. Wrapped in a broad try/except — a bus subscriber
|
||||
bug must never break the main JSON-RPC dispatch path that already wrote
|
||||
the event to stdout above.
|
||||
|
||||
The import is deferred to first call to keep TUI cold-start cheap; the
|
||||
gateway hook module isn't otherwise needed in the TUI process.
|
||||
"""
|
||||
try:
|
||||
global _hook_registry # noqa: PLW0603 — module-level cache by design
|
||||
if _hook_registry is None:
|
||||
from gateway.hooks import get_default_registry # local import
|
||||
_hook_registry = get_default_registry()
|
||||
_hook_registry.emit_sync( # type: ignore[union-attr]
|
||||
f"tui:{event}",
|
||||
{"session_id": sid, "payload": payload or {}},
|
||||
)
|
||||
except Exception:
|
||||
# Never propagate. Hook bus is best-effort.
|
||||
pass
|
||||
|
||||
|
||||
# Lazily-resolved on first ``_emit`` call. Cached in module scope so the
|
||||
# steady-state cost of mirroring is a dict lookup, not an import.
|
||||
_hook_registry: "object | None" = None
|
||||
|
||||
|
||||
def _status_update(sid: str, kind: str, text: str | None = None):
|
||||
|
||||
Generated
+2786
-6
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -10,7 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
"@nous-research/ui": "0.18.2",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
||||
+2
-2
@@ -50,12 +50,12 @@ import {
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import { SidebarFooter } from "@/components/SidebarFooter";
|
||||
import { SidebarStatusStrip, gatewayLine } from "@/components/SidebarStatusStrip";
|
||||
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
|
||||
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { AuthWidget } from "@/components/AuthWidget";
|
||||
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Switch } from "@nous-research/ui/ui/components/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
|
||||
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
|
||||
const keyPath = schemaKey.includes(".") ? schemaKey : "";
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
const CLOSE_DRAG_MIN_PX = 72;
|
||||
const CLOSE_DRAG_RATIO = 0.18;
|
||||
const SHEET_TRANSITION_MS = 280;
|
||||
|
||||
/**
|
||||
* Mobile-first picker shell: fixed backdrop + bottom sheet, portaled to `body`
|
||||
* so nested overflow/transform in the sidebar cannot clip menus (theme /
|
||||
* language switchers). Open/close uses slide + fade; teardown is delayed until
|
||||
* the exit animation finishes so animations can complete.
|
||||
*
|
||||
* Drag the header/handle downward to dismiss (skipped when reduced motion is on).
|
||||
*/
|
||||
export function BottomPickSheet({
|
||||
backdropDismissLabel = "Dismiss",
|
||||
children,
|
||||
onClose,
|
||||
open,
|
||||
title,
|
||||
}: BottomPickSheetProps) {
|
||||
const [renderPortal, setRenderPortal] = useState(open);
|
||||
const [entered, setEntered] = useState(false);
|
||||
const [dragOffsetPx, setDragOffsetPx] = useState(0);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const dragTrackingRef = useRef(false);
|
||||
const dragStartYRef = useRef(0);
|
||||
const dragOffsetRef = useRef(0);
|
||||
|
||||
const reducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const syncDragPx = (next: number) => {
|
||||
dragOffsetRef.current = next;
|
||||
setDragOffsetPx(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
|
||||
const ms = reducedMotion ? 0 : SHEET_TRANSITION_MS;
|
||||
|
||||
let openRafId = 0;
|
||||
let exitRafId = 0;
|
||||
|
||||
if (open) {
|
||||
openRafId = requestAnimationFrame(() => {
|
||||
dragTrackingRef.current = false;
|
||||
dragOffsetRef.current = 0;
|
||||
setDragActive(false);
|
||||
setDragOffsetPx(0);
|
||||
setRenderPortal(true);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setEntered(true));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
exitRafId = requestAnimationFrame(() => {
|
||||
dragTrackingRef.current = false;
|
||||
setDragActive(false);
|
||||
setEntered(false);
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
dragOffsetRef.current = 0;
|
||||
setDragOffsetPx(0);
|
||||
setRenderPortal(false);
|
||||
closeTimerRef.current = null;
|
||||
}, ms);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(openRafId);
|
||||
cancelAnimationFrame(exitRafId);
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, reducedMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renderPortal) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [renderPortal]);
|
||||
|
||||
if (!renderPortal || typeof document === "undefined") return null;
|
||||
|
||||
const durationClass = reducedMotion ? "duration-0" : "duration-[280ms]";
|
||||
|
||||
const draggingVisual = dragActive || dragOffsetPx > 0;
|
||||
|
||||
const onDragPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (reducedMotion || !entered) return;
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
|
||||
dragTrackingRef.current = true;
|
||||
setDragActive(true);
|
||||
dragStartYRef.current = e.clientY;
|
||||
syncDragPx(0);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onDragPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!dragTrackingRef.current) return;
|
||||
const dy = e.clientY - dragStartYRef.current;
|
||||
const next = Math.max(0, dy);
|
||||
const sheetH = sheetRef.current?.offsetHeight ?? 560;
|
||||
syncDragPx(Math.min(next, sheetH));
|
||||
};
|
||||
|
||||
const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!dragTrackingRef.current) return;
|
||||
dragTrackingRef.current = false;
|
||||
setDragActive(false);
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
|
||||
const sheetH = sheetRef.current?.offsetHeight ?? 560;
|
||||
const threshold = Math.max(CLOSE_DRAG_MIN_PX, sheetH * CLOSE_DRAG_RATIO);
|
||||
const d = dragOffsetRef.current;
|
||||
|
||||
if (d >= threshold) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
syncDragPx(0);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex flex-col justify-end">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={backdropDismissLabel}
|
||||
className={cn(
|
||||
"absolute inset-0 bg-black/55 backdrop-blur-[2px]",
|
||||
"transition-opacity ease-out motion-reduce:transition-none",
|
||||
durationClass,
|
||||
entered ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-label={title}
|
||||
aria-modal="true"
|
||||
ref={sheetRef}
|
||||
className={cn(
|
||||
themedBody,
|
||||
"relative flex max-h-[85dvh] min-h-0 flex-col rounded-t-xl border border-current/20",
|
||||
"bg-background-base/98 pb-[max(1rem,env(safe-area-inset-bottom))]",
|
||||
"shadow-[0_-12px_40px_-8px_rgba(0,0,0,0.55)] backdrop-blur-md",
|
||||
"ease-out motion-reduce:transition-none transform-gpu",
|
||||
draggingVisual ? "transition-none" : cn("transition-transform", durationClass),
|
||||
entered ? "translate-y-0" : "translate-y-full",
|
||||
)}
|
||||
role="dialog"
|
||||
style={
|
||||
entered && dragOffsetPx > 0
|
||||
? { transform: `translateY(${dragOffsetPx}px)` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col gap-2 border-b border-current/15 px-4 pb-3 pt-2",
|
||||
"touch-none select-none",
|
||||
reducedMotion ? "cursor-default" : "cursor-grab active:cursor-grabbing",
|
||||
)}
|
||||
onPointerCancel={endDrag}
|
||||
onPointerDown={onDragPointerDown}
|
||||
onPointerMove={onDragPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="mx-auto h-1 w-10 shrink-0 rounded-full bg-current/20"
|
||||
/>
|
||||
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
interface BottomPickSheetProps {
|
||||
backdropDismissLabel?: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Card } from "@nous-research/ui/ui/components/card";
|
||||
|
||||
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
|
||||
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export function DeleteConfirmDialog({
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { BottomPickSheet } from "@/components/BottomPickSheet";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
|
||||
import { BottomSheet } from "@nous-research/ui/ui/components/bottom-sheet";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
|
||||
import { useI18n } from "@/i18n/context";
|
||||
import { LOCALE_META } from "@/i18n";
|
||||
import type { Locale } from "@/i18n";
|
||||
@@ -87,7 +87,7 @@ export function LanguageSwitcher({ collapsed = false, dropUp = false }: Language
|
||||
</Button>
|
||||
|
||||
{useMobileSheet && (
|
||||
<BottomPickSheet
|
||||
<BottomSheet
|
||||
backdropDismissLabel={t.common.close}
|
||||
onClose={() => setOpen(false)}
|
||||
open={open}
|
||||
@@ -101,7 +101,7 @@ export function LanguageSwitcher({ collapsed = false, dropUp = false }: Language
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
</BottomPickSheet>
|
||||
</BottomSheet>
|
||||
)}
|
||||
|
||||
{open && !useMobileSheet && (() => {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { forwardRef, type ElementType, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TypographyProps = HTMLAttributes<HTMLElement> & {
|
||||
as?: ElementType;
|
||||
children?: ReactNode;
|
||||
compressed?: boolean;
|
||||
courier?: boolean;
|
||||
expanded?: boolean;
|
||||
mondwest?: boolean;
|
||||
mono?: boolean;
|
||||
sans?: boolean;
|
||||
variant?: "sm" | "md" | "lg" | "xl";
|
||||
};
|
||||
|
||||
const variantClasses: Record<NonNullable<TypographyProps["variant"]>, string> = {
|
||||
sm: "leading-[1.4] text-[.9375rem] tracking-[0.1875rem]",
|
||||
md: "text-[2.625rem] leading-[1] tracking-[0.0525rem]",
|
||||
lg: "text-[2.625rem] leading-[1] tracking-[0.0525rem]",
|
||||
xl: "text-[4.5rem] leading-[1] tracking-[0.135rem]",
|
||||
};
|
||||
|
||||
export const Typography = forwardRef<HTMLElement, TypographyProps>(function Typography(
|
||||
{
|
||||
as: Component = "span",
|
||||
className,
|
||||
compressed,
|
||||
courier,
|
||||
expanded,
|
||||
mondwest,
|
||||
mono,
|
||||
sans,
|
||||
variant,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const hasFontVariant = compressed || courier || expanded || mondwest || mono || sans;
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
compressed && "font-compressed",
|
||||
courier && "font-courier",
|
||||
expanded && "font-expanded",
|
||||
mondwest && "font-mondwest tracking-[0.1875rem]",
|
||||
mono && "font-mono",
|
||||
(!hasFontVariant || sans) && "font-sans",
|
||||
variant && variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const H2 = forwardRef<HTMLHeadingElement, Omit<TypographyProps, "as">>(function H2(
|
||||
{ className, variant = "lg", ...props },
|
||||
ref,
|
||||
) {
|
||||
return <Typography as="h2" className={cn("font-bold", className)} variant={variant} ref={ref} {...props} />;
|
||||
});
|
||||
@@ -3,9 +3,9 @@ import { ExternalLink, X, Check } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { CopyButton } from "@nous-research/ui/ui/components/command-block";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { H2 } from "@/components/NouiTypography";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
} from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
|
||||
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AlertTriangle, Radio, Wifi, WifiOff } from "lucide-react";
|
||||
import type { PlatformStatus } from "@/lib/api";
|
||||
import { isoTimeAgo } from "@/lib/utils";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export function PlatformsCard({ platforms }: PlatformsCardProps) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
@@ -3,9 +3,9 @@ import { createPortal } from "react-dom";
|
||||
import { Palette, Check } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { BottomPickSheet } from "@/components/BottomPickSheet";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
|
||||
import { BottomSheet } from "@nous-research/ui/ui/components/bottom-sheet";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
|
||||
import { BUILTIN_THEMES, useTheme } from "@/themes";
|
||||
import type { DashboardTheme, ThemeListEntry } from "@/themes";
|
||||
import { useI18n } from "@/i18n";
|
||||
@@ -91,7 +91,7 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch
|
||||
</Button>
|
||||
|
||||
{useMobileSheet && (
|
||||
<BottomPickSheet
|
||||
<BottomSheet
|
||||
backdropDismissLabel={t.common.close}
|
||||
onClose={close}
|
||||
open={open}
|
||||
@@ -105,7 +105,7 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch
|
||||
themeName={themeName}
|
||||
/>
|
||||
</div>
|
||||
</BottomPickSheet>
|
||||
</BottomSheet>
|
||||
)}
|
||||
|
||||
{open && !useMobileSheet && (() => {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [current, setCurrent] = useState(toast);
|
||||
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
setCurrent(toast);
|
||||
setVisible(true);
|
||||
} else {
|
||||
setVisible(false);
|
||||
const timer = setTimeout(() => setCurrent(null), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
// Portal to document.body so the toast escapes any ancestor stacking context
|
||||
// (e.g. <main> has `relative z-2`, which would trap z-50 below the header's z-40).
|
||||
return createPortal(
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed top-16 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
|
||||
current.type === "success"
|
||||
? "bg-success/15 text-success border-success/30"
|
||||
: "bg-destructive/15 text-destructive border-destructive/30"
|
||||
}`}
|
||||
style={{
|
||||
animation: visible ? "toast-in 200ms ease-out forwards" : "toast-out 200ms ease-in forwards",
|
||||
}}
|
||||
>
|
||||
{current.message}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Themed card primitive. Themes can restyle every card without touching
|
||||
* call sites by setting CSS vars under the `card` component-style bucket:
|
||||
*
|
||||
* componentStyles:
|
||||
* card:
|
||||
* clipPath: "polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px)"
|
||||
* border: "1px solid var(--color-ring)"
|
||||
* background: "linear-gradient(180deg, var(--color-card) 0%, transparent 100%)"
|
||||
* boxShadow: "0 0 0 1px var(--color-ring) inset, 0 0 24px -8px var(--warm-glow)"
|
||||
*
|
||||
* All properties are optional — vars that aren't set compute to their
|
||||
* CSS initial value, so the default shadcn-y card keeps looking normal
|
||||
* for themes that don't override anything.
|
||||
*/
|
||||
const CARD_STYLE: React.CSSProperties = {
|
||||
clipPath: "var(--component-card-clip-path)",
|
||||
borderImage: "var(--component-card-border-image)",
|
||||
background: "var(--component-card-background)",
|
||||
boxShadow: "var(--component-card-box-shadow)",
|
||||
};
|
||||
|
||||
export function Card({ className, style, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-border bg-card/80 text-card-foreground w-full",
|
||||
themedBody,
|
||||
className,
|
||||
)}
|
||||
style={{ ...CARD_STYLE, ...style }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1.5 p-4 border-b border-border", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"font-mondwest text-display text-sm tracking-[0.12em] text-text-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p className={cn("font-mondwest normal-case text-xs text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-4", className)} {...props} />;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
export function ConfirmDialog({
|
||||
cancelLabel = "Cancel",
|
||||
confirmLabel = "Confirm",
|
||||
description,
|
||||
destructive = false,
|
||||
loading = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
open,
|
||||
title,
|
||||
}: ConfirmDialogProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus the confirm button when opened; trap ESC to cancel.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const prevActive = document.activeElement as HTMLElement | null;
|
||||
dialogRef.current
|
||||
?.querySelector<HTMLButtonElement>("[data-confirm]")
|
||||
?.focus();
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
prevActive?.focus?.();
|
||||
};
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby={description ? "confirm-dialog-desc" : undefined}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 flex items-center justify-center",
|
||||
"bg-black/60 backdrop-blur-sm",
|
||||
"animate-[fade-in_150ms_ease-out]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={cn(
|
||||
themedBody,
|
||||
"relative w-full max-w-md mx-4",
|
||||
"border border-border bg-card shadow-lg",
|
||||
"animate-[dialog-in_180ms_ease-out]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4 border-b border-border">
|
||||
{destructive && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="mt-0.5 shrink-0 text-destructive"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<h2
|
||||
id="confirm-dialog-title"
|
||||
className="font-mondwest text-display text-sm font-bold tracking-[0.12em] blend-lighter"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{description && (
|
||||
<p
|
||||
id="confirm-dialog-desc"
|
||||
className="font-mondwest normal-case text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button
|
||||
type="button"
|
||||
outlined
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
data-confirm
|
||||
type="button"
|
||||
destructive={destructive}
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "…" : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
description?: string;
|
||||
destructive?: boolean;
|
||||
loading?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"flex h-9 w-full border border-border bg-background/40 px-3 py-1 font-courier text-sm transition-colors",
|
||||
"placeholder:text-muted-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"font-mondwest text-xs tracking-[0.1em] uppercase leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }) {
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ActionStatusResponse } from "@/lib/api";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useI18n } from "@/i18n";
|
||||
import {
|
||||
SystemActionsContext,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** True when viewport width is strictly below `px` (matches Tailwind `min-width: px`). */
|
||||
export function useBelowBreakpoint(px: number) {
|
||||
const query = `(max-width: ${px - 1}px)`;
|
||||
const [matches, setMatches] = useState(() =>
|
||||
typeof window !== "undefined" ? window.matchMedia(query).matches : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const sync = () => setMatches(mql.matches);
|
||||
sync();
|
||||
mql.addEventListener("change", sync);
|
||||
return () => mql.removeEventListener("change", sync);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useConfirmDelete<TId>({
|
||||
onDelete,
|
||||
}: {
|
||||
onDelete: (id: TId) => Promise<void>;
|
||||
}) {
|
||||
const [pendingId, setPendingId] = useState<TId | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const requestDelete = useCallback((id: TId) => {
|
||||
setPendingId(id);
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (!isDeleting) setPendingId(null);
|
||||
}, [isDeleting]);
|
||||
|
||||
const confirm = useCallback(async () => {
|
||||
if (pendingId === null) return;
|
||||
const id = pendingId;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete(id);
|
||||
setPendingId(null);
|
||||
} catch {
|
||||
// Dialog stays open; caller can surface errors in onDelete before rethrowing
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [pendingId, onDelete]);
|
||||
|
||||
return {
|
||||
cancel,
|
||||
confirm,
|
||||
isDeleting,
|
||||
isOpen: pendingId !== null,
|
||||
pendingId,
|
||||
requestDelete,
|
||||
} as const;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useToast(duration = 3000) {
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, type: "success" | "error") => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), duration);
|
||||
},
|
||||
[duration],
|
||||
);
|
||||
|
||||
return { toast, showToast };
|
||||
}
|
||||
+27
-3
@@ -41,7 +41,11 @@ function setSessionHeader(headers: Headers, token: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
export async function fetchJSON<T>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: FetchJSONOptions,
|
||||
): Promise<T> {
|
||||
// Inject the session token into all /api/ requests.
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
@@ -100,7 +104,7 @@ export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T>
|
||||
// that reload once on the first stale-token 401 — gated mode is
|
||||
// handled above, so reaching here in gated mode means a real
|
||||
// middleware failure that should not reload-loop.
|
||||
if (!window.__HERMES_AUTH_REQUIRED__) {
|
||||
if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) {
|
||||
let alreadyReloaded = false;
|
||||
try {
|
||||
alreadyReloaded =
|
||||
@@ -198,8 +202,19 @@ export const api = {
|
||||
* still exists but is never useful there (no Session, no cookie). The
|
||||
* AuthWidget component swallows 401s from this call: if the gate isn't
|
||||
* engaged, /api/auth/me returns 401 and the widget renders nothing.
|
||||
*
|
||||
* ``allowUnauthorized`` is load-bearing: in loopback mode this endpoint
|
||||
* 401s by design, and fetchJSON's default loopback behaviour treats a
|
||||
* 401 as a rotated session token and full-page-reloads to pick up a
|
||||
* fresh one. Because every *other* dashboard request succeeds (and so
|
||||
* clears the one-shot reload guard), that turns this expected 401 into
|
||||
* an infinite reload loop. Opting out keeps the 401 a plain throw the
|
||||
* widget can catch.
|
||||
*/
|
||||
getAuthMe: () => fetchJSON<AuthMeResponse>("/api/auth/me"),
|
||||
getAuthMe: () =>
|
||||
fetchJSON<AuthMeResponse>("/api/auth/me", undefined, {
|
||||
allowUnauthorized: true,
|
||||
}),
|
||||
logout: () =>
|
||||
fetch(`${BASE}/auth/logout`, {
|
||||
method: "POST",
|
||||
@@ -514,6 +529,15 @@ export interface ActionResponse {
|
||||
pid: number;
|
||||
}
|
||||
|
||||
/** Per-call overrides for {@link fetchJSON}. */
|
||||
interface FetchJSONOptions {
|
||||
/** When true, a 401 response is surfaced as a normal thrown error rather
|
||||
* than triggering the loopback stale-token page reload. Use for probes
|
||||
* whose 401 is an expected signal (e.g. /api/auth/me in non-gated mode)
|
||||
* rather than evidence of a rotated session token. */
|
||||
allowUnauthorized?: boolean;
|
||||
}
|
||||
|
||||
export interface ActionStatusResponse {
|
||||
exit_code: number | null;
|
||||
lines: string[];
|
||||
|
||||
@@ -20,7 +20,7 @@ import { timeAgo } from "@/lib/utils";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Stats } from "@nous-research/ui/ui/components/stats";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
@@ -23,7 +23,7 @@ import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy, PanelRight, X } from "lucide-react";
|
||||
|
||||
@@ -38,15 +38,15 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { AutoField } from "@/components/AutoField";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
|
||||
@@ -4,17 +4,17 @@ import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { H2 } from "@/components/NouiTypography";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CronJob, ProfileInfo } from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
import { api } from "@/lib/api";
|
||||
import type { EnvVarInfo } from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
@@ -30,10 +30,10 @@ import {
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
} from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
@@ -12,8 +12,8 @@ import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { FilterGroup, Segmented } from "@nous-research/ui/ui/components/segmented";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Switch } from "@nous-research/ui/ui/components/switch";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
@@ -24,9 +24,9 @@ import { formatTokenCount } from "@/lib/format";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Stats } from "@nous-research/ui/ui/components/stats";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
@@ -10,12 +10,12 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Switch } from "@nous-research/ui/ui/components/switch";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { CommandBlock } from "@nous-research/ui/ui/components/command-block";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -14,19 +14,19 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import spinners from "unicode-animations";
|
||||
import { H2 } from "@/components/NouiTypography";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ProfileInfo } from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
|
||||
@@ -34,18 +34,18 @@ import type {
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { PlatformsCard } from "@/components/PlatformsCard";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { Segmented } from "@nous-research/ui/ui/components/segmented";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
@@ -17,16 +17,16 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Switch } from "@nous-research/ui/ui/components/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
@@ -23,10 +23,10 @@ import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Separator } from "@nous-research/ui/ui/components/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@nous-research/ui/ui/components/tabs";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { registerSlot, PluginSlot } from "./slots";
|
||||
|
||||
@@ -82,10 +82,65 @@ async def handle(event_type: str, context: dict):
|
||||
| `agent:step` | Each iteration of the tool-calling loop | `platform`, `user_id`, `session_id`, `iteration`, `tool_names` |
|
||||
| `agent:end` | Agent finishes processing | `platform`, `user_id`, `session_id`, `message`, `response` |
|
||||
| `command:*` | Any slash command executed | `platform`, `user_id`, `command`, `args` |
|
||||
| `tui:<sub-event>` | TUI dispatch event mirrored to the bus (see below) | `session_id`, `payload` (raw TUI payload) |
|
||||
|
||||
#### Wildcard Matching
|
||||
|
||||
Handlers registered for `command:*` fire for any `command:` event (`command:model`, `command:reset`, etc.). Monitor all slash commands with a single subscription.
|
||||
Handlers registered for `command:*` fire for any `command:<name>` event (`command:model`, `command:reset`, etc.) — useful for monitoring all slash commands with a single subscription. The same pattern works for `tui:*` to receive every TUI dispatch event.
|
||||
|
||||
Wildcards match **one** colon-separated namespace level: `foo:*` matches every `foo:<anything>` event, but does not cross another colon (a handler for `agent:*` does not fire for an unrelated `gateway:startup`).
|
||||
|
||||
#### `tui:*` events
|
||||
|
||||
The TUI gateway (`hermes --tui` or the embedded dashboard PTY) mirrors every JSON-RPC event it sends to the front-end onto the hook bus under the `tui:` namespace. Each handler receives `context = {"session_id": str, "payload": dict}` — the `payload` matches the JSON-RPC event payload the TUI wrote to stdout for that frame.
|
||||
|
||||
Common sub-events:
|
||||
|
||||
| Sub-event | When it fires |
|
||||
|-----------|--------------|
|
||||
| `tui:tool.start` | A tool call begins |
|
||||
| `tui:tool.progress` | A long-running tool reports progress |
|
||||
| `tui:tool.complete` | A tool call finishes |
|
||||
| `tui:message.start` / `message.delta` / `message.complete` | Assistant message lifecycle |
|
||||
| `tui:reasoning.available` | Reasoning content for the latest turn |
|
||||
| `tui:thinking.delta` | Streamed thinking text |
|
||||
| `tui:session.info` | Session metadata changed (model, tools, etc.) |
|
||||
| `tui:status.update` | Inline status line update |
|
||||
| `tui:error` | Error frame |
|
||||
|
||||
This list isn't exhaustive — anything ``_emit`` writes ends up on the bus. The full set is whatever `tui_gateway/server.py:_emit` happens to send today; payload shapes are documented alongside the front-end consumers in `ui-tui/src/`.
|
||||
|
||||
Subscribers fire **synchronously** inside the TUI's hot dispatch path, so handlers should be cheap (push to a queue, set an `asyncio.Event`, etc.) and never block. Exceptions inside a handler are caught and logged — they never break TUI dispatch.
|
||||
|
||||
### Programmatic registration
|
||||
|
||||
Discovery from `~/.hermes/hooks/` is the common path, but plugins, tests, and built-in code can also register handlers in-process:
|
||||
|
||||
```python
|
||||
from gateway.hooks import get_default_registry
|
||||
|
||||
def on_tool_start(event_type, context):
|
||||
name = context["payload"].get("name", "?")
|
||||
print(f"[my-plugin] {event_type} -> {name}")
|
||||
|
||||
unregister = get_default_registry().register("tui:tool.start", on_tool_start)
|
||||
# ... later, to clean up:
|
||||
unregister()
|
||||
```
|
||||
|
||||
`HookRegistry.register(event_type, handler, *, name=None)` accepts the same handler signature as discovered hooks (`handle(event_type, context)`, sync or async) and returns a no-arg callable that removes that specific handler. Other handlers on the same event are unaffected.
|
||||
|
||||
For callers that cannot `await` — the TUI's `_emit` is a good example — fire events via the synchronous path:
|
||||
|
||||
```python
|
||||
registry.emit_sync("tui:tool.start", {"session_id": "s1", "payload": {...}})
|
||||
```
|
||||
|
||||
`emit_sync` runs sync handlers immediately; async handlers are scheduled on the running event loop when one is available, or skipped with a one-time per-handler warning when there isn't.
|
||||
|
||||
### Per-process registries
|
||||
|
||||
Each Hermes process (gateway, TUI) has its **own** `HookRegistry`. Hooks dropped into `~/.hermes/hooks/` are discovered independently in each process. A handler registered programmatically in the gateway's registry is **not** visible to the TUI's registry and vice-versa — if you want a single subscriber to observe both surfaces, drop a `HOOK.yaml` so each process picks it up on startup.
|
||||
|
||||
### Examples
|
||||
|
||||
|
||||
@@ -500,7 +500,7 @@ export default function SkillsDashboard() {
|
||||
const sources = useMemo(() => {
|
||||
const set = new Set(allSkillsLocal.map((s) => s.source));
|
||||
return SOURCE_ORDER.filter((s) => s === "all" || set.has(s));
|
||||
}, []);
|
||||
}, [allSkillsLocal]);
|
||||
|
||||
const categoryEntries = useMemo(() => {
|
||||
const pool =
|
||||
@@ -523,7 +523,7 @@ export default function SkillsDashboard() {
|
||||
return Array.from(map.entries())
|
||||
.sort((a, b) => b[1].count - a[1].count)
|
||||
.map(([key, { label, count }]) => ({ key, label, count }));
|
||||
}, [sourceFilter]);
|
||||
}, [sourceFilter, allSkillsLocal]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debouncedSearch.toLowerCase().trim();
|
||||
|
||||
Reference in New Issue
Block a user