Compare commits

...

12 Commits

Author SHA1 Message Date
Ben 299b9dba67 feat(hooks): extend HookRegistry with programmatic registration, sync emit, and tui:* mirror
Adds three small extensions to the existing event hook system so plugins can
observe agent activity without introducing a parallel pub/sub bus (cf. closed
PR #34195 which duplicated this surface).

Changes to gateway/hooks.py:

- HookRegistry.register(event_type, handler, *, name=None) — programmatic
  registration that pairs with file-system discovery from ~/.hermes/hooks/.
  Returns a no-arg callable that deregisters that specific handler. Other
  handlers on the same event are unaffected.

- HookRegistry.emit_sync(event_type, context) — companion to the async
  emit() for hot-path callers that cannot await. Sync handlers run
  immediately; async handlers are scheduled on the current running event
  loop (if any) via asyncio.ensure_future, or skipped with a one-time
  per-handler warning when no loop is available. Like emit(), it never
  raises and a buggy subscriber can't break the host pipeline.

- get_default_registry() / install_as_default(registry) — module-level
  default registry singleton so plugins and in-process callers can find
  'the' registry without threading a reference through every API. The
  gateway installs its own self.hooks as the default during startup.

Changes to tui_gateway/server.py:

- _emit() now mirrors every JSON-RPC event onto the default registry as
  a 'tui:<sub-event>' hook event with context = {session_id, payload}.
  The mirror runs as a side-effect after write_json and is wrapped in a
  broad try/except so a subscriber bug can never break TUI dispatch. The
  gateway.hooks module is imported lazily on first _emit call to keep
  TUI cold-start cheap.

Wildcard semantics unchanged — handlers registered for 'tui:*' fire for
every tui:<anything> event, just like the existing 'command:*' pattern.

Test coverage: +10 unit tests in tests/gateway/test_hooks.py covering
register/unregister, emit_sync sync+async+wildcard+exception paths, and
default-registry singleton behavior. New tests/test_tui_gateway_hook_bridge.py
exercises the _emit → registry plumbing end-to-end including subscriber
exception isolation, wildcard subscriptions, and the lazy resolve cache.

Docs: website/docs/user-guide/features/hooks.md gains a 'tui:*' events
table, the new 'Programmatic registration' section, and a note that
each Hermes process has its own registry (gateway-discovered hooks are
loaded independently in each process).

Test counts: 38 hooks tests (was 28), 6 new bridge tests.
Full ./scripts/run_tests.sh tests/gateway/ tests/test_tui_gateway* run:
6127 tests passed, 0 failed (272 files, 52s on 24 workers).
2026-05-29 11:58:20 +10:00
Teknium 769ee86cd2 feat(kanban): attach images referenced in task bodies to worker vision (#34210)
Kanban workers now scan the task body for local image paths and
http(s) image URLs and attach them to the worker's first user turn —
matching the CLI/gateway behaviour for inbound images. Before, a
user pasting `/home/me/screenshot.png` or `https://example.com/img.png`
into a kanban task description had it sent to the model as plain
text and the pixels were never seen.

How it works:
* agent/image_routing.py gains extract_image_refs(text) → (paths, urls)
  that mirrors gateway/platforms/base.py:extract_local_files (absolute /
  ~-relative paths, image extensions only, ignores fenced/inline code).
* build_native_content_parts() accepts an optional image_urls= kwarg
  and emits passthrough image_url parts for remote URLs alongside the
  base64 data: URLs used for local paths.
* cli.py (single-query/quiet branch — the path every dispatcher-spawned
  worker takes) detects HERMES_KANBAN_TASK, reads the task body via
  kanban_db.get_task, runs extract_image_refs, and threads the results
  into the existing image-routing decision (native vs text). Best-effort:
  enrichment failures never block worker startup.

Tested:
* tests/agent/test_image_routing.py — 22 new tests for extract_image_refs
  and URL pass-through in build_native_content_parts.
* tests/hermes_cli/test_kanban_worker_image_extraction.py — 10 new tests
  driving real kanban_db round-trip (create task → read body → extract
  refs → build parts).
* E2E: created a fake kanban task with a body referencing both a local
  PNG and an https URL; verified the worker pipeline produces a
  multimodal user turn with 1 text part + 2 image_url parts (data URL
  for the local file, passthrough URL for the remote).
2026-05-28 17:50:42 -07:00
Ben 1b1e30510a test(docker): repair dashboard tests broken by the insecure-opt-in fix
The Docker integration test job started failing on main after
fb5125362 ("docker: opt in to dashboard --insecure via env var").
Two distinct failures, both fallout from that change being more
behaviour-changing than the existing test harness anticipated.

Failure 1 — test_dashboard_port_override (silent regression in an
already-existing test)
The test starts the container with just HERMES_DASHBOARD=1, defaults
to host=0.0.0.0, no HERMES_DASHBOARD_OAUTH_CLIENT_ID, no
HERMES_DASHBOARD_INSECURE. Pre-fix that combination got --insecure
auto-injected by the s6 run script (anything non-loopback was
implicitly insecure), so the OAuth gate stayed off and start_server
bound the port. Post-fix the gate engages, no provider is
registered, and start_server raises SystemExit before binding —
under s6 the dashboard goes into a restart loop and the test's
/proc/net/tcp poll finds nothing.

Same silent regression was masking three sibling tests
(test_dashboard_slot_reports_up_when_enabled, test_dashboard_opt_in_starts,
test_dashboard_restarts_after_crash) — they all only sample pgrep
or s6-svstat and so caught the supervised process mid-restart
loop, appearing to pass while the dashboard was actually never
reaching a healthy state.

Fix: pin HERMES_DASHBOARD_INSECURE=1 on every test that enables
the dashboard but doesn't itself exercise the auth gate. Each
pinned site carries an inline comment pointing back to
test_dashboard_slot_reports_up_when_enabled for the full
rationale.

Failure 2 — test_dashboard_oauth_gate_engages_on_non_loopback_bind
(bug in the test I added in fb5125362)
The probe used urllib.request.urlopen() against /api/status. Under
the now-engaged OAuth gate /api/status no longer answers
unauthenticated callers (the gate middleware runs upstream of the
legacy _SESSION_TOKEN allowlist and 401s anything without a valid
session cookie). urlopen() raises HTTPError on the 401, the wrapper
treated that as "not ready yet", and the poll loop hit
timeout.

Fix: split the probe into a generic _http_probe() helper that
returns (status_code, body) for any HTTP response — including 401,
which IS the gate-engaged success signal. The helper feeds a
multi-line Python program over stdin via a POSIX heredoc so the
try/except branch reads naturally; far less fragile than the
earlier semicolon-laden -c one-liner.

The OAuth-gate test now verifies two independent observable
consequences of the gate being on:

  1. GET /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. GET /api/status returns 401 — proves the OAuth gate runs
     upstream of the legacy public-paths allowlist and is
     actively intercepting unauthenticated callers.

The insecure-opt-out test still hits /api/status, but now
asserts status_code == 200 first (proves the gate is bypassed)
before parsing the JSON for auth_required: false (proves the
gate-state flag is also correctly off).

Verified locally end-to-end against a fresh image build on a
real Docker daemon: all 41 tests under tests/docker/ pass in
2m38s, including the two formerly-failing dashboard tests and
the three sibling tests that were passing by accident.
2026-05-29 10:30:52 +10:00
Teknium f3acdd94fe Merge pull request #30698 from NousResearch/refactor/use-ds-primitives
refactor(web): consume DS primitives, remove local component copies
2026-05-28 17:29:28 -07:00
Teknium 78a54d2c00 fix(skills-page): source pills and category sidebar collapsed to All only (#34194)
Regression from PR #33809 (lazy-fetch refactor). The `sources` and
`categoryEntries` useMemo blocks were derived from `allSkillsLocal`
but had empty/incomplete deps arrays — so they computed once at mount
when the catalog was still `[]`, then never recomputed when the fetch
resolved.

Symptom: live site shows only the "All 87,639" source button and
"All Skills 87,639" category — no per-source pills (ClawHub, skills.sh,
LobeHub, etc.) and no category breakdown. Filtering by source/category
is unusable.

Fix: add `allSkillsLocal` to both deps arrays so they recompute when
data arrives. Local build green on en + zh-Hans.
2026-05-28 17:11:40 -07:00
Ben e7c99651fb fix(mcp): resolve bare npx/npm/node against /usr/local/bin
When the Hermes Docker image runs an stdio MCP server configured with an
explicit env.PATH that omits /usr/local/bin (a common pattern when users
hand-author PATH for sandboxing), the MCP env-filter passes that narrow
PATH straight through to the subprocess. _resolve_stdio_command's
fallback for bare 'npx' / 'npm' / 'node' commands only checked
$HERMES_HOME/node/bin/ and ~/.local/bin/, so execvp() failed with
'[Errno 2] No such file or directory: npx' on every Node-based stdio
MCP server (Railway, Anthropic, GitHub Copilot, etc.).

The naive workaround — symlink /usr/local/bin/npx into the user's PATH —
fails one layer deeper because npx's shebang re-execs /usr/bin/env node
and node also lives at /usr/local/bin/node.

Fix: add /usr/local/bin/<cmd> as a third candidate in the fallback list.
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 (the Node 22 LTS
    refactor that exposed this)
  - macOS Homebrew on Intel

Because the resolver already calls _prepend_path(resolved_env, command_dir)
after locating the command, /usr/local/bin gets prepended to the env's
PATH automatically, which also fixes the second-layer shebang failure
(npx-cli.js can now find node).

Scope is intentionally narrow: the fix activates only when the bare
command isn't otherwise locatable through the user's PATH. Users who
explicitly narrowed PATH for a non-Node MCP server see no change in
behavior.

Tested:
  - tests/tools/test_mcp_tool_issue_948.py: new test
    test_resolve_stdio_command_falls_back_to_usr_local_bin (mirrors the
    existing hermes-node-bin fallback test)
  - Full MCP test suite: 254/254 pass across 7 test files
  - E2E against a freshly-built Docker image: reproduced the original
    failure mode (env.PATH=/opt/data/bin:/usr/bin:/bin), confirmed the
    resolver returns /usr/local/bin/npx and prepends /usr/local/bin to
    PATH; subprocess.run of the resolved command prints '10.9.8' and
    exits 0 with empty stderr
  - Negative E2E on the host (where Node is already on PATH via mise):
    resolver still hits the mise install dir, /usr/local/bin candidate
    is not consulted, PATH is unchanged
2026-05-29 10:05:42 +10:00
Austin Pickett a5c1f925b5 fix(web): stop /api/auth/me 401 from triggering a reload loop
In loopback mode the dashboard's identity probe (/api/auth/me) returns
401 by design — AuthWidget swallows it and renders nothing. But the
probe routed through fetchJSON, whose loopback 401 handler treats a 401
as a rotated session token and full-page-reloads to pick up a fresh one.
That reload is guarded by a one-shot sessionStorage flag which every
*successful* request clears, so with auth/me reliably 401ing and the
other dashboard calls (status/config/sessions) reliably succeeding, the
guard never sticks and the page reload-loops indefinitely (the "boot
flash").

Add an allowUnauthorized option to fetchJSON that skips only the loopback
stale-token reload (the 401 still throws so AuthWidget can catch it, and
the gated-mode login_url envelope redirect is unaffected), and use it for
getAuthMe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 16:58:42 -04:00
Austin Pickett 0acb7f4583 fix(nix): update hermes-web npmDepsHash for @nous-research/ui 0.18.2
The web/package-lock.json changed when bumping @nous-research/ui to
0.18.2, so the fetchNpmDeps fixed-output hash in nix/web.nix was stale.
Update it to the hash prefetch-npm-deps computes for the new lockfile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 16:24:01 -04:00
Austin Pickett a3cd974ee7 chore(web): bump @nous-research/ui to 0.18.2
Picks up the deferred GPU-tier detection fix (design-language) that
stops the synchronous WebGL probe from blocking first paint, which was
causing a boot-time flash in the dashboard backdrop.

nix/web.nix npmDepsHash is a placeholder here and is corrected in the
follow-up commit using the hash reported by the Nix CI job.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 16:20:14 -04:00
Austin Pickett 102eb4adc0 fix(nix): update hermes-web npmDepsHash for bumped @nous-research/ui
The web/package-lock.json changed when bumping @nous-research/ui to 0.18.0,
so the fetchNpmDeps fixed-output hash in nix/web.nix was stale and the nix
build failed. Update it to the hash prefetch-npm-deps computes for the new
lockfile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 14:27:08 -04:00
Austin Pickett c661fefa08 Merge remote-tracking branch 'origin/main' into refactor/use-ds-primitives
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	web/src/components/BottomPickSheet.tsx
#	web/src/components/SidebarFooter.tsx
#	web/src/components/ui/card.tsx
#	web/src/components/ui/confirm-dialog.tsx
#	web/src/pages/ChatPage.tsx
2026-05-28 14:20:49 -04:00
Austin Pickett c9e5a9bb08 refactor(web): consume DS primitives, remove local component copies
Replace locally-forked UI components and hooks with their newly
promoted counterparts from @nous-research/ui:

Deleted local components (now in DS):
- components/ui/input.tsx, label.tsx, separator.tsx, card.tsx,
  confirm-dialog.tsx
- components/Toast.tsx, BottomPickSheet.tsx, NouiTypography.tsx
- hooks/useToast.ts, useModalBehavior.ts, useBelowBreakpoint.ts,
  useConfirmDelete.ts

Import updates across 25 files to use DS deep imports:
- @nous-research/ui/ui/components/{input,label,separator,card,
  confirm-dialog,toast,bottom-sheet}
- @nous-research/ui/ui/components/typography (replaces NouiTypography)
- @nous-research/ui/hooks/{use-toast,use-modal-behavior,
  use-below-breakpoint,use-confirm-delete}

Requires design-language >= feat/promote-hermes-web-primitives.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:57:59 -04:00
53 changed files with 4420 additions and 799 deletions
+134 -14
View File
@@ -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",
]
+50 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"; };
+188
View File
@@ -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
View File
@@ -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}"
+253
View File
@@ -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 == []
+180
View File
@@ -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
+33
View File
@@ -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 = []
+11
View File
@@ -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):
+32
View File
@@ -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):
+2786 -6
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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";
+2 -2
View File
@@ -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 : "";
-225
View File
@@ -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;
}
+1 -1
View File
@@ -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 -1
View File
@@ -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({
+5 -5
View File
@@ -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 -2
View File
@@ -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";
-63
View File
@@ -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} />;
});
+2 -2
View File
@@ -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";
+2 -2
View File
@@ -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";
+1 -1
View File
@@ -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 -1
View File
@@ -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";
+5 -5
View File
@@ -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 && (() => {
-40
View File
@@ -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,
);
}
-63
View File
@@ -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} />;
}
-137
View File
@@ -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;
}
-16
View File
@@ -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}
/>
);
}
-13
View File
@@ -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}
/>
);
}
-19
View File
@@ -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 -1
View File
@@ -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,
-19
View File
@@ -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;
}
-41
View File
@@ -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;
}
-15
View File
@@ -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
View File
@@ -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[];
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+5 -5
View File
@@ -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";
+7 -7
View File
@@ -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";
+6 -6
View File
@@ -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";
+2 -2
View File
@@ -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";
+2 -2
View File
@@ -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";
+6 -6
View File
@@ -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";
+7 -7
View File
@@ -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";
+5 -5
View File
@@ -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";
+4 -4
View File
@@ -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";
+4 -4
View File
@@ -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";
+56 -1
View File
@@ -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
+2 -2
View File
@@ -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();