From e7dbfdaad7b1bb45cd9eb9c09a03c0213c65320d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 14:18:30 -0500 Subject: [PATCH 001/244] feat(pets): pet engine + display.pet config Add the shared pet engine under agent/pet/: spritesheet manifest loading and in-process caching, six-state animation model, frame rendering, and the persistent pet store. Register the display.pet config block (pet, scale, enabled, etc.) that every surface reads from. Covered by tests/agent/test_pet_engine.py. --- agent/pet/__init__.py | 51 +++ agent/pet/constants.py | 167 +++++++++ agent/pet/manifest.py | 128 +++++++ agent/pet/render.py | 618 +++++++++++++++++++++++++++++++++ agent/pet/state.py | 81 +++++ agent/pet/store.py | 316 +++++++++++++++++ hermes_cli/config.py | 25 ++ tests/agent/test_pet_engine.py | 371 ++++++++++++++++++++ 8 files changed, 1757 insertions(+) create mode 100644 agent/pet/__init__.py create mode 100644 agent/pet/constants.py create mode 100644 agent/pet/manifest.py create mode 100644 agent/pet/render.py create mode 100644 agent/pet/state.py create mode 100644 agent/pet/store.py create mode 100644 tests/agent/test_pet_engine.py diff --git a/agent/pet/__init__.py b/agent/pet/__init__.py new file mode 100644 index 00000000000..b045598d2eb --- /dev/null +++ b/agent/pet/__init__.py @@ -0,0 +1,51 @@ +"""Petdex pet engine — shared core for the CLI, TUI, and desktop surfaces. + +Petdex (https://github.com/crafter-station/petdex) is a public gallery of +animated sprite "pets" for coding agents. Each pet is a ``pet.json`` plus a +``spritesheet.{webp,png}`` of 192×208 px cells. Current Codex/petdex sheets use +an 8-column × 9-row atlas; older Hermes/petdex sheets used an 8-row atlas. +Hermes infers the row taxonomy from the sheet and maps agent activity onto +idle/run/review/failed/wave/jump. + +This package is the **single source of truth** for the feature so the base +CLI (Python) and TUI (Ink, via ``tui_gateway``) never duplicate the hard +parts: + +- :mod:`agent.pet.constants` — frame geometry + the :class:`PetState` enum. +- :mod:`agent.pet.state` — map agent activity → a :class:`PetState`. +- :mod:`agent.pet.manifest` — fetch the public petdex manifest. +- :mod:`agent.pet.store` — install / list / resolve pets on disk + (profile-aware via ``get_hermes_home()``). +- :mod:`agent.pet.render` — decode a spritesheet and encode frames for a + terminal (kitty / iTerm2 / sixel graphics + protocols, with a Unicode half-block + fallback). + +Rendering in the Electron desktop is necessarily TypeScript (canvas), but it +reuses the same on-disk store and the same state semantics. + +The whole feature is a *display* concern: it adds no model tool, mutates no +system prompt or toolset, and therefore has zero effect on prompt caching. +""" + +from agent.pet.constants import ( + DEFAULT_SCALE, + FRAME_H, + FRAME_W, + FRAMES_PER_STATE, + LOOP_MS, + STATE_ROWS, + PetState, +) +from agent.pet.state import derive_pet_state + +__all__ = [ + "DEFAULT_SCALE", + "FRAME_H", + "FRAME_W", + "FRAMES_PER_STATE", + "LOOP_MS", + "STATE_ROWS", + "PetState", + "derive_pet_state", +] diff --git a/agent/pet/constants.py b/agent/pet/constants.py new file mode 100644 index 00000000000..a7e816c4012 --- /dev/null +++ b/agent/pet/constants.py @@ -0,0 +1,167 @@ +"""Pet sprite geometry + animation-state taxonomy. + +These values are the common petdex/Codex pet geometry. The real ``pet.json`` +usually only carries ``id``/``displayName``/``description``/``spritesheetPath``; +row taxonomy is inferred from the atlas shape so Hermes can render both legacy +8-row sheets and current 9-row Codex sheets. +""" + +from __future__ import annotations + +from enum import Enum + +# Frame geometry (pixels). Current Codex/petdex spritesheets are 8 columns x 9 +# rows (1536x1872), while older Hermes/petdex sheets used 9 columns x 8 rows +# (1728x1664). Renderers derive both row taxonomy and real column count from the +# concrete sheet, so either shape works. +FRAME_W = 192 +FRAME_H = 208 + +# Frames consumed per animation state (the petdex web app uses CSS +# ``steps(6)``). A sheet may physically contain more columns; we only step +# through the first ``FRAMES_PER_STATE``. +FRAMES_PER_STATE = 6 + +# Full-loop duration for one state, milliseconds (petdex default). +LOOP_MS = 1100 + +# Default on-screen scale relative to native frame size. ``display.pet.scale`` +# is the single master scalar: the desktop canvas multiplies its native pixels +# by it and every terminal surface derives its half-block/kitty column width +# from it (see :func:`cols_for_scale`), so one number shrinks all three +# interfaces together. (petdex's own clients render at 0.7; we default smaller +# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block +# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its +# legibility floor instead.) +DEFAULT_SCALE = 0.33 + +# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the +# pet clickable/visible; ceiling stops a fat-fingered value from filling the +# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``. +MIN_SCALE = 0.1 +MAX_SCALE = 3.0 + + +def clamp_scale(scale: float) -> float: + """Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point).""" + return max(MIN_SCALE, min(MAX_SCALE, scale)) + +# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px +# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty +# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees. +BASE_UNICODE_COLS = FRAME_W // 8 + +# Legibility floor for the half-block fallback. A half-block cell samples the +# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208 +# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw +# true pixels and have no such floor — that's why the same ``scale: 0.33`` is +# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down +# TO this floor (and grows it above), instead of past it into noise. +UNICODE_MIN_COLS = 16 + + +def cols_for_scale(scale: float) -> int: + """Half-block width implied by *scale*, clamped to the legibility floor. + + Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two + renderers converge at larger sizes; below it the floor keeps the sprite + readable rather than letting it devolve into a blob. + """ + return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE))) + + +def resolve_cols(scale: float, unicode_cols: int = 0) -> int: + """Resolve terminal width: explicit *unicode_cols* override, else from *scale*.""" + return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale) + + +class PetState(str, Enum): + """Animation state a pet can be shown in. + + These are Hermes' activity state names. They are not always identical to the + source atlas row names: Codex-format pets use rows like ``jumping`` / + ``running`` while the UI keeps the shorter ``jump`` / ``run`` names. + """ + + IDLE = "idle" + WAVE = "wave" + RUN = "run" + FAILED = "failed" + REVIEW = "review" + JUMP = "jump" + WAITING = "waiting" + + +# Legacy Hermes/petdex row order (top -> bottom) used by the older 8-row, +# 9-column atlas shape. +LEGACY_STATE_ROWS: list[str] = [ + PetState.IDLE.value, + PetState.WAVE.value, + PetState.RUN.value, + PetState.FAILED.value, + PetState.REVIEW.value, + PetState.JUMP.value, + "extra1", + "extra2", +] + +# Current Petdex row order (top -> bottom) used by 1536x1872 atlases: +# 8 columns x 9 rows of 192x208 cells. +CODEX_STATE_ROWS: list[str] = [ + PetState.IDLE.value, + "running-right", + "running-left", + "waving", + "jumping", + PetState.FAILED.value, + PetState.WAITING.value, + "running", + PetState.REVIEW.value, +] + +# Default/fallback for callers without a sheet. Prefer the current 9-row Codex +# format because generated pets and the public Codex pet contract use it. +STATE_ROWS: list[str] = CODEX_STATE_ROWS + +# Canonical Hermes activity names -> accepted row-name aliases in descending +# preference. This keeps our internal state names stable (`wave`/`jump`/`run`) +# while matching Petdex's current `waving`/`jumping`/`running` taxonomy. +STATE_ALIASES: dict[str, tuple[str, ...]] = { + PetState.IDLE.value: (PetState.IDLE.value,), + PetState.WAVE.value: (PetState.WAVE.value, "waving"), + PetState.JUMP.value: (PetState.JUMP.value, "jumping"), + PetState.RUN.value: (PetState.RUN.value, "running"), + PetState.FAILED.value: (PetState.FAILED.value,), + PetState.REVIEW.value: (PetState.REVIEW.value,), + PetState.WAITING.value: (PetState.WAITING.value,), +} + + +def state_aliases_for(state: "PetState | str") -> tuple[str, ...]: + """Return accepted row-name aliases for *state* (always non-empty).""" + value = state.value if isinstance(state, PetState) else str(state) + aliases = STATE_ALIASES.get(value) + return aliases if aliases else (value,) + + +def state_rows_for_grid(row_count: int | None) -> list[str]: + """Return the row taxonomy for a spritesheet with *row_count* rows.""" + try: + rows = int(row_count or 0) + except (TypeError, ValueError): + rows = 0 + + if rows >= len(CODEX_STATE_ROWS): + return CODEX_STATE_ROWS + return LEGACY_STATE_ROWS + + +def state_row_index(state: "PetState | str", row_count: int | None = None) -> int: + """Return the spritesheet row index for *state* (clamped, never raises).""" + rows = state_rows_for_grid(row_count) + for name in state_aliases_for(state): + try: + return rows.index(name) + except ValueError: + continue + return 0 # fall back to the idle row diff --git a/agent/pet/manifest.py b/agent/pet/manifest.py new file mode 100644 index 00000000000..94d539691dc --- /dev/null +++ b/agent/pet/manifest.py @@ -0,0 +1,128 @@ +"""Fetch the public petdex manifest. + +``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2: + + { + "generatedAt": "...", + "total": 2926, + "pets": [ + {"slug": "boba", "displayName": "Boba", "kind": "creature", + "submittedBy": "railly", + "spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp", + "petJsonUrl": "https://assets.petdex.dev/.../pet.json", + "zipUrl": "https://assets.petdex.dev/.../boba.zip"}, + ... + ] + } + +Read-only and unauthenticated; no credentials involved. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +MANIFEST_URL = "https://petdex.dev/api/manifest" + +_DEFAULT_TIMEOUT = 20.0 + +# In-process cache for the (large, slow, identical-per-call) manifest. The list +# is a static CDN object that barely changes, yet a single session can ask for +# it many times — every gallery open, plus a full re-fetch per install/select +# (``find_entry``). A short TTL collapses those into one network hit without +# going stale for long. Cleared by :func:`clear_cache` (tests). +_MANIFEST_TTL = 300.0 +_cache: tuple[float, list[ManifestEntry]] | None = None + + +def clear_cache() -> None: + """Drop the cached manifest (forces the next fetch to hit the network).""" + global _cache + _cache = None + + +@dataclass(frozen=True) +class ManifestEntry: + """A single pet's row in the manifest.""" + + slug: str + display_name: str + kind: str + submitted_by: str + spritesheet_url: str + pet_json_url: str + zip_url: str + + @classmethod + def from_dict(cls, data: dict) -> "ManifestEntry": + return cls( + slug=str(data.get("slug", "")).strip(), + display_name=str(data.get("displayName", "") or data.get("slug", "")), + kind=str(data.get("kind", "") or "pet"), + submitted_by=str(data.get("submittedBy", "") or ""), + spritesheet_url=str(data.get("spritesheetUrl", "") or ""), + pet_json_url=str(data.get("petJsonUrl", "") or ""), + zip_url=str(data.get("zipUrl", "") or ""), + ) + + +class ManifestError(RuntimeError): + """Raised when the manifest can't be fetched or parsed.""" + + +def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]: + """Return every approved pet from the public manifest. + + Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to + bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on + any network/parse failure so callers can surface a clean message. + """ + global _cache + + if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL: + return _cache[1] + + try: + import httpx + except ImportError as exc: # pragma: no cover - httpx is a core dep + raise ManifestError("httpx is required to fetch the petdex manifest") from exc + + try: + resp = httpx.get( + MANIFEST_URL, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) + resp.raise_for_status() + payload = resp.json() + except Exception as exc: # noqa: BLE001 - normalize to one error type + raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc + + pets = payload.get("pets") if isinstance(payload, dict) else None + if not isinstance(pets, list): + raise ManifestError("petdex manifest had no 'pets' array") + + entries: list[ManifestEntry] = [] + for raw in pets: + if not isinstance(raw, dict): + continue + entry = ManifestEntry.from_dict(raw) + if entry.slug and entry.spritesheet_url: + entries.append(entry) + + _cache = (time.monotonic(), entries) + return entries + + +def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None: + """Return the manifest entry for *slug*, or ``None`` if not listed.""" + slug = slug.strip().lower() + for entry in fetch_manifest(timeout=timeout): + if entry.slug.lower() == slug: + return entry + return None diff --git a/agent/pet/render.py b/agent/pet/render.py new file mode 100644 index 00000000000..1618c0751d2 --- /dev/null +++ b/agent/pet/render.py @@ -0,0 +1,618 @@ +"""Decode a pet spritesheet and encode frames for a terminal. + +Shared by the base CLI (writes the escape bytes to its own stdout) and the +TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the +decode + capability-detection + protocol-encoding logic exists exactly once. + +Supported output modes, in fidelity order: + +- ``kitty`` — the kitty graphics protocol (kitty, Ghostty, WezTerm). +- ``iterm`` — iTerm2 inline images (iTerm2, WezTerm). +- ``sixel`` — DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, …). +- ``unicode`` — 24-bit half-block downscale; works in any truecolor terminal. + +Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the +spritesheet is unavailable the renderer degrades to ``unicode`` text or an +empty string rather than raising. +""" + +from __future__ import annotations + +import base64 +import io +import logging +import os +import sys +from functools import lru_cache +from pathlib import Path + +from agent.pet.constants import ( + DEFAULT_SCALE, + FRAME_H, + FRAME_W, + FRAMES_PER_STATE, + PetState, + state_row_index, +) + +logger = logging.getLogger(__name__) + +# Public render-mode names accepted by ``display.pet.render_mode``. +RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off") + + +# ───────────────────────────────────────────────────────────────────────── +# Terminal capability detection +# ───────────────────────────────────────────────────────────────────────── + +def detect_terminal_graphics() -> str: + """Best-effort detection of the richest graphics protocol available. + + Env-based (non-blocking — we never issue a DA1/terminal query that could + hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` / + ``unicode``. Conservative: unknown terminals get ``unicode``, which works + anywhere with truecolor. + """ + term = os.environ.get("TERM", "").lower() + term_program = os.environ.get("TERM_PROGRAM", "").lower() + + # The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode + # authoritatively but does NOT scrub the terminal env vars it inherits when + # launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …). + # Trusting those leaks emits an image protocol the embedded xterm.js can't + # display — you get a blank frame. Inline images there are opt-in + # (terminal.integrated.enableImages), so default to half-blocks, which + # always render in its truecolor grid. Users who enabled images can pin + # display.pet.render_mode explicitly. + if term_program == "vscode": + return "unicode" + + # kitty graphics protocol + if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term: + return "kitty" + if term_program in {"ghostty"}: + return "kitty" + + # WezTerm speaks both kitty and iterm; prefer kitty (richer placement). + if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"): + return "kitty" + + # iTerm2 inline images + if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"): + return "iterm" + + # sixel-capable terminals (env heuristics only) + if term_program in {"mintty"} or "foot" in term or "mlterm" in term: + return "sixel" + if "sixel" in term: + return "sixel" + + return "unicode" + + +def resolve_mode(configured: str | None, *, stream=None) -> str: + """Resolve the effective render mode from config + the environment. + + ``configured`` is ``display.pet.render_mode`` (``auto`` → detect). Returns + ``off`` when not attached to a TTY (no point emitting graphics into a pipe + or logfile). + """ + mode = (configured or "auto").strip().lower() + if mode not in RENDER_MODES: + mode = "auto" + if mode == "off": + return "off" + + stream = stream or sys.stdout + try: + if not (hasattr(stream, "isatty") and stream.isatty()): + return "off" + except (ValueError, OSError): + return "off" + + if mode == "auto": + return detect_terminal_graphics() + return mode + + +# ───────────────────────────────────────────────────────────────────────── +# Frame decoding +# ───────────────────────────────────────────────────────────────────────── + +def _open_sheet(path: Path): + from PIL import Image + + img = Image.open(path) + return img.convert("RGBA") + + +# Max alpha at/below which a frame counts as blank padding. petdex sheets are +# left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills +# the trailing columns with fully transparent cells. Animating into one flashes +# the pet blank, so we stop the row at the first such gap. +_BLANK_ALPHA = 8 + + +def _frame_is_blank(frame) -> bool: + """True if *frame* has no meaningfully opaque pixel (transparent padding).""" + return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA + + +@lru_cache(maxsize=16) +def _raw_frames( + sheet_path: str, + state_value: str, + frame_w: int, + frame_h: int, + frames_per_state: int, +) -> tuple: + """Cropped, padding-trimmed RGBA frames for one state row (unscaled). + + Steps across the row until the first blank column so pets with ragged + per-state frame counts never animate into empty padding. Cached; returns + ``()`` on any decode failure. + """ + try: + sheet = _open_sheet(Path(sheet_path)) + cols = max(1, sheet.width // frame_w) + rows = max(1, sheet.height // frame_h) + row = state_row_index(state_value, rows) + top = row * frame_h + # Clamp the row to the sheet (some pets ship fewer rows than the 8 the + # taxonomy reserves). + if top + frame_h > sheet.height: + top = max(0, sheet.height - frame_h) + + frames = [] + for i in range(min(frames_per_state, cols)): + left = i * frame_w + frame = sheet.crop((left, top, left + frame_w, top + frame_h)) + if _frame_is_blank(frame): + break # trailing transparent padding — real frames end here + frames.append(frame) + return tuple(frames) + except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal + logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc) + return () + + +@lru_cache(maxsize=8) +def _frames_for( + sheet_path: str, + state_value: str, + frame_w: int, + frame_h: int, + frames_per_state: int, + scale_w: int, + scale_h: int, +): + """Return padding-trimmed RGBA frames for one state row, scaled. + + Thin scaling layer over :func:`_raw_frames`; both are cached so repeated + frame requests during animation are free. + """ + raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state) + if not raw or (scale_w, scale_h) == (frame_w, frame_h): + return list(raw) + from PIL import Image + + return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw] + + +def state_frame_counts( + sheet_path: str | Path, + *, + frame_w: int = FRAME_W, + frame_h: int = FRAME_H, + frames_per_state: int = FRAMES_PER_STATE, +) -> dict[str, int]: + """Map each driven :class:`PetState` → its real (padding-trimmed) frame count. + + The single source of truth for "how many frames does this state actually + have?". The CLI/TUI consume the trimmed frame lists directly; the gateway + ships this map to the desktop canvas, which steps its own loop. + """ + return { + state.value: len( + _raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state) + ) + for state in PetState + } + + +# ───────────────────────────────────────────────────────────────────────── +# Encoders +# ───────────────────────────────────────────────────────────────────────── + +def _png_bytes(frame) -> bytes: + buf = io.BytesIO() + frame.save(buf, format="PNG") + return buf.getvalue() + + +def _kitty_apc(ctrl: str, data: str) -> str: + """Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces.""" + chunk = 4096 + if len(data) <= chunk: + return f"\x1b_G{ctrl},m=0;{data}\x1b\\" + out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"] + rest = data[chunk:] + while rest: + piece, rest = rest[:chunk], rest[chunk:] + out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\") + return "".join(out) + + +def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str: + """Encode one frame via the kitty graphics protocol (transmit + display). + + ``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display + box in terminal cells so successive frames overwrite the same area. + """ + ctrl = "f=100,a=T,q=2" + if cell_cols: + ctrl += f",c={cell_cols}" + if cell_rows: + ctrl += f",r={cell_rows}" + return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii")) + + +# ───────────────────────────────────────────────────────────────────────── +# kitty Unicode placeholders +# +# Ink (the TUI's React-for-terminal layer) owns the screen and measures every +# cell's width, so it can't host raw kitty image escapes (no width to count, +# clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the +# grid-safe path: transmit the image once (q=2, virtual placement U=1), then the +# host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose +# foreground color encodes the image id. Ink counts those as width-1 text, so +# layout stays correct and the terminal paints the image underneath. +# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders +# ───────────────────────────────────────────────────────────────────────── + +_KITTY_PLACEHOLDER = "\U0010eeee" + +# Row/column diacritics, in order (index → diacritic). Verbatim from kitty's +# gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is +# the diacritic that encodes the number i; we only ever need the row index. +_ROWCOL_DIACRITICS: tuple[int, ...] = ( + 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, + 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, + 0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, + 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597, + 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9, + 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615, + 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, + 0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, + 0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, + 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A, + 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, + 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822, + 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, + 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, + 0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, + 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1, + 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6, + 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, + 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, + 0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, + 0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, + 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, + 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, + 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA, + 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, + 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, + 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, + 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23, + 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188, + 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244, +) + + +def kitty_image_id(slug: str) -> int: + """Stable per-pet image id in ``[1, 0x7FFF]``. + + The id is encoded in the placeholder's 24-bit foreground color, so it must + be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it + deterministic per slug (so re-renders reuse the same terminal-side image) + while making collisions between two different pets unlikely. + """ + import zlib + + return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1 + + +def kitty_color_hex(image_id: int) -> str: + """Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty.""" + return "#%06x" % (image_id & 0xFFFFFF) + + +def kitty_placeholder_rows(cols: int, rows: int) -> list[str]: + """Build the placeholder text grid for an *rows*×*cols* image. + + Each line is one row of the grid: the first cell carries the row diacritic + (column defaults to 0), and the remaining ``cols-1`` bare placeholders let + the terminal auto-increment the column. The foreground color (the image id) + is applied by the caller / Ink, not embedded here. + """ + cols = max(1, cols) + out: list[str] = [] + for r in range(max(1, rows)): + idx = min(r, len(_ROWCOL_DIACRITICS) - 1) + first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx]) + out.append(first + _KITTY_PLACEHOLDER * (cols - 1)) + return out + + +def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str: + """Transmit a frame as a kitty *virtual* placement for Unicode placeholders. + + ``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it + virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the + terminal's OK/error replies that would otherwise corrupt the host app's + output. Re-sending with the same ``i`` replaces the image, so the static + placeholder cells animate underneath. + """ + ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2" + return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii")) + + +def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str: + """Encode one frame as an iTerm2 inline image (OSC 1337 File).""" + payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii") + size = len(payload) + args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"] + if cell_cols: + args.append(f"width={cell_cols}") + if cell_rows: + args.append(f"height={cell_rows}") + return f"\x1b]1337;File={';'.join(args)}:{payload}\x07" + + +def _encode_sixel(frame) -> str: + """Encode one frame as DEC sixel. + + Quantizes to an adaptive palette (≤255 colors) and emits the sixel band + stream. Pillow has no sixel writer, so this is a compact hand-rolled + encoder. Transparent pixels render as background (color register skipped). + """ + from PIL import Image + + rgba = frame + # Composite onto transparent-as-skip: track alpha to decide background. + pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT) + palette = pal.getpalette() or [] + px = pal.load() + alpha = rgba.getchannel("A").load() + w, h = pal.size + + out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)] + # Color register definitions (sixel uses 0..100 scale). + used = sorted({px[x, y] for y in range(h) for x in range(w)}) + for idx in used: + r = palette[idx * 3] if idx * 3 < len(palette) else 0 + g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0 + b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0 + out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255)) + + # Emit in 6-row bands. + for band in range(0, h, 6): + for color_idx in used: + line = ["#%d" % color_idx] + run_char = None + run_len = 0 + + def flush(): + nonlocal run_char, run_len + if run_char is None: + return + if run_len > 3: + line.append("!%d%s" % (run_len, run_char)) + else: + line.append(run_char * run_len) + run_char, run_len = None, 0 + + for x in range(w): + bits = 0 + for bit in range(6): + y = band + bit + if y < h and alpha[x, y] > 32 and px[x, y] == color_idx: + bits |= 1 << bit + ch = chr(63 + bits) + if ch == run_char: + run_len += 1 + else: + flush() + run_char, run_len = ch, 1 + flush() + out.append("".join(line) + "$") # carriage return within band + out.append("-") # next band + out.append("\x1b\\") + return "".join(out) + + +_HALF_BLOCK = "▀" + +# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples. +Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]] + + +def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]: + """Downscale a frame to a grid of half-block cells. + + Each cell pairs a top and bottom pixel so one terminal row encodes two + pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` — the + framework-neutral representation shared by the ANSI encoder (CLI) and the + structured ``cells`` API (Ink). + """ + from PIL import Image + + target_cols = max(4, target_cols) + aspect = frame.height / max(1, frame.width) + target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2) + small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA") + px = small.load() + + grid: list[list[Cell]] = [] + for y in range(0, target_rows, 2): + row: list[Cell] = [] + for x in range(target_cols): + top = px[x, y] + bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0) + row.append((top, bottom)) + grid.append(row) + return grid + + +def _encode_unicode(frame, *, target_cols: int) -> str: + """Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels).""" + lines: list[str] = [] + for row in _downscale_cells(frame, target_cols=target_cols): + cells: list[str] = [] + for (tr, tg, tb, ta), (br, bg, bb, ba) in row: + if ta < 32 and ba < 32: + cells.append("\x1b[0m ") # fully transparent → blank + continue + cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}") + lines.append("".join(cells) + "\x1b[0m") + return "\n".join(lines) + + +# ───────────────────────────────────────────────────────────────────────── +# Public renderer +# ───────────────────────────────────────────────────────────────────────── + +class PetRenderer: + """Holds a pet's spritesheet and yields encoded frames per (state, index). + + Construct once per pet, then call :meth:`frame` on an animation timer. + Cheap to call repeatedly — decoded frames are cached. + """ + + def __init__( + self, + spritesheet: str | Path, + *, + mode: str = "unicode", + scale: float = DEFAULT_SCALE, + unicode_cols: int = 20, + frame_w: int = FRAME_W, + frame_h: int = FRAME_H, + frames_per_state: int = FRAMES_PER_STATE, + ) -> None: + self.spritesheet = str(spritesheet) + self.mode = mode if mode in RENDER_MODES else "unicode" + self.scale = scale + self.unicode_cols = unicode_cols + self.frame_w = frame_w + self.frame_h = frame_h + self.frames_per_state = frames_per_state + + @property + def available(self) -> bool: + return self.mode != "off" and Path(self.spritesheet).is_file() + + def frame_count(self, state: PetState | str) -> int: + return len(self._frames(state)) + + def _frames(self, state: PetState | str): + value = state.value if isinstance(state, PetState) else str(state) + scale_w = max(1, int(self.frame_w * self.scale)) + scale_h = max(1, int(self.frame_h * self.scale)) + return _frames_for( + self.spritesheet, + value, + self.frame_w, + self.frame_h, + self.frames_per_state, + scale_w, + scale_h, + ) + + def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]: + """Return one frame as a half-block cell grid (framework-neutral). + + Used by the TUI, which renders the grid with native Ink color props + instead of raw ANSI. Returns ``[]`` when no frame is available. + """ + frames = self._frames(state) + if not frames: + return [] + frame = frames[index % len(frames)] + return _downscale_cells(frame, target_cols=cols or self.unicode_cols) + + def _cell_box(self, frame) -> tuple[int, int]: + """Terminal cell box for a scaled frame (~8×16 px per cell). + + Must match :meth:`frame` graphics sizing — kitty stretches the image to + fill ``c``×``r`` cells, so these must reflect the scaled pixel + dimensions, not a native-aspect column count (that upscales small pets). + """ + return max(1, frame.width // 8), max(1, frame.height // 16) + + def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None: + """Build the kitty Unicode-placeholder payload for one state. + + Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a + list of transmit escapes (one per animation frame, all reusing + ``image_id``) and ``placeholder`` is the static text grid Ink paints. + Placement geometry is derived from the scaled frame pixels (via + :meth:`_cell_box`), not ``unicode_cols`` — kitty upscales to fill + ``c``×``r`` cells. ``None`` when no frame is available. + """ + frames = self._frames(state) + if not frames: + return None + cols, rows = self._cell_box(frames[0]) + return { + "cols": cols, + "rows": rows, + "placeholder": kitty_placeholder_rows(cols, rows), + "frames": [ + _encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames + ], + } + + def frame(self, state: PetState | str, index: int) -> str: + """Return the encoded escape string for one frame, or ``""``. + + ``index`` is taken modulo the available frame count so callers can pass + a free-running counter. + """ + if self.mode == "off": + return "" + frames = self._frames(state) + if not frames: + return "" + frame = frames[index % len(frames)] + cell_cols, cell_rows = self._cell_box(frame) + + try: + if self.mode == "kitty": + return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows) + if self.mode == "iterm": + return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows) + if self.mode == "sixel": + return _encode_sixel(frame) + return _encode_unicode(frame, target_cols=self.unicode_cols) + except Exception as exc: # noqa: BLE001 - degrade silently + logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc) + return "" + + +def build_renderer( + spritesheet: str | Path, + *, + configured_mode: str | None = None, + scale: float = DEFAULT_SCALE, + unicode_cols: int = 20, + stream=None, +) -> PetRenderer: + """Convenience factory: resolve the mode from config+env, then construct.""" + mode = resolve_mode(configured_mode, stream=stream) + return PetRenderer( + spritesheet, + mode=mode, + scale=scale, + unicode_cols=unicode_cols, + ) diff --git a/agent/pet/state.py b/agent/pet/state.py new file mode 100644 index 00000000000..a9ad5afd801 --- /dev/null +++ b/agent/pet/state.py @@ -0,0 +1,81 @@ +"""Map agent activity → a :class:`PetState`. + +This is the one place the "what is the agent doing right now?" → "which +animation row?" decision lives. Each surface feeds it the signals it already +tracks: + +- CLI — ``KawaiiSpinner`` waiting/thinking state + tool outcomes. +- TUI — gateway ``tool.start/complete`` + ``message.delta/complete`` events. +- Desktop — the ``$busy``/``$awaitingResponse``/tool-event nanostores + (re-implemented in TS, but mirroring this priority order). + +Keeping the priority order here (and documenting it) lets the TypeScript +mirror stay faithful without a second design. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from agent.pet.constants import PetState + + +def todos_all_done(todos: Iterable[Any] | None) -> bool: + """True iff there's ≥1 todo and every one is completed/cancelled. + + The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors + the TUI's ``isTodoDone`` so the trigger is defined once across surfaces. + Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr. + """ + items = list(todos or []) + if not items: + return False + + def _status(t: Any) -> Any: + return t.get("status") if isinstance(t, dict) else getattr(t, "status", None) + + return all(_status(t) in ("completed", "cancelled") for t in items) + + +def derive_pet_state( + *, + busy: bool = False, + awaiting_input: bool = False, + error: bool = False, + celebrate: bool = False, + just_completed: bool = False, + tool_running: bool = False, + reasoning: bool = False, +) -> PetState: + """Resolve the animation state from coarse activity signals. + + Priority (highest first) — only one row can show at a time, so the most + salient signal wins: + + 1. ``error`` → ``FAILED`` (a tool/turn just failed) + 2. ``celebrate`` → ``JUMP`` (explicit success beat, e.g. todos done) + 3. ``just_completed`` → ``WAVE`` (turn finished cleanly / greeting) + 4. ``awaiting_input`` → ``WAITING`` (blocked on the user — a clarify/approval + prompt is open; this outranks the in-flight signals below because the turn + is paused on *you*, even though a tool is technically mid-call) + 5. ``tool_running`` → ``RUN`` (a tool is executing) + 6. ``reasoning`` → ``REVIEW`` (model is thinking / reading) + 7. ``busy`` → ``RUN`` (turn in flight, unspecified work) + 8. otherwise → ``IDLE`` + """ + if error: + return PetState.FAILED + if celebrate: + return PetState.JUMP + if just_completed: + return PetState.WAVE + if awaiting_input: + return PetState.WAITING + if tool_running: + return PetState.RUN + if reasoning: + return PetState.REVIEW + if busy: + return PetState.RUN + return PetState.IDLE diff --git a/agent/pet/store.py b/agent/pet/store.py new file mode 100644 index 00000000000..46cc3bc9cac --- /dev/null +++ b/agent/pet/store.py @@ -0,0 +1,316 @@ +"""On-disk pet store — install / list / resolve pets. + +Pets live under ``get_hermes_home()/pets//`` so every profile gets its +own set (we deliberately do **not** reuse petdex's ``~/.codex/pets`` default — +that's owned by the petdex npm CLI and isn't profile-aware). Each installed +pet directory holds: + + pets// + pet.json # {id, displayName, description, spritesheetPath} + spritesheet.webp # (or .png) + +The active pet is resolved from the caller-supplied ``display.pet.slug`` config +value (falling back to the first installed pet), so this module stays free of +the config loader. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from pathlib import Path + +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + +_DOWNLOAD_TIMEOUT = 60.0 + + +class PetStoreError(RuntimeError): + """Raised on install/IO failures.""" + + +@dataclass(frozen=True) +class InstalledPet: + """A pet present on disk.""" + + slug: str + display_name: str + description: str + directory: Path + spritesheet: Path + + @property + def exists(self) -> bool: + return self.spritesheet.is_file() + + +def pets_dir() -> Path: + """Return the profile-scoped pets directory (created on demand).""" + path = get_hermes_home() / "pets" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _read_pet_json(directory: Path) -> dict: + pet_json = directory / "pet.json" + if not pet_json.is_file(): + return {} + try: + return json.loads(pet_json.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + logger.debug("unreadable pet.json in %s: %s", directory, exc) + return {} + + +def _resolve_spritesheet(directory: Path, meta: dict) -> Path: + """Find the spritesheet for a pet dir. + + Honors ``spritesheetPath`` from pet.json, else probes the conventional + filenames (``spritesheet.{webp,png}`` and petdex R2's ``sprite.webp``). + """ + declared = str(meta.get("spritesheetPath", "") or "").strip() + if declared: + candidate = directory / declared + if candidate.is_file(): + return candidate + for name in ("spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"): + candidate = directory / name + if candidate.is_file(): + return candidate + # Default expectation even if missing, so callers get a stable path. + return directory / "spritesheet.webp" + + +def load_pet(slug: str) -> InstalledPet | None: + """Return the :class:`InstalledPet` for *slug*, or ``None`` if absent.""" + slug = slug.strip() + directory = pets_dir() / slug + if not directory.is_dir(): + return None + meta = _read_pet_json(directory) + return InstalledPet( + slug=slug, + display_name=str(meta.get("displayName", "") or slug), + description=str(meta.get("description", "") or ""), + directory=directory, + spritesheet=_resolve_spritesheet(directory, meta), + ) + + +def installed_pets() -> list[InstalledPet]: + """Return every installed pet (dirs containing a usable spritesheet).""" + out: list[InstalledPet] = [] + for child in sorted(pets_dir().iterdir()): + if not child.is_dir(): + continue + pet = load_pet(child.name) + if pet and pet.exists: + out.append(pet) + return out + + +def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None: + """Resolve which pet to display. + + Precedence: the configured slug (``display.pet.slug``) if it's installed, + otherwise the first installed pet alphabetically, otherwise ``None``. + """ + if configured_slug: + pet = load_pet(configured_slug.strip()) + if pet and pet.exists: + return pet + pets = installed_pets() + return pets[0] if pets else None + + +def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet: + """Download *slug* from the manifest into the pets directory. + + Idempotent: a fully-installed pet is returned as-is unless *force*. Raises + :class:`PetStoreError` / :class:`~agent.pet.manifest.ManifestError` on + failure. + """ + from agent.pet.manifest import find_entry + + slug = slug.strip() + existing = load_pet(slug) + if existing and existing.exists and not force: + return existing + + entry = find_entry(slug, timeout=timeout) + if entry is None: + raise PetStoreError(f"pet '{slug}' is not in the petdex manifest") + + directory = pets_dir() / slug + directory.mkdir(parents=True, exist_ok=True) + + sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp" + sprite_path = directory / f"spritesheet{sprite_ext}" + + _download(entry.spritesheet_url, sprite_path, timeout=timeout) + + # Fetch the upstream pet.json if present; otherwise synthesize a minimal + # one so the local layout is self-describing. + meta: dict = {} + if entry.pet_json_url: + try: + meta = _download_json(entry.pet_json_url, timeout=timeout) + except Exception as exc: # noqa: BLE001 - non-fatal, fall back below + logger.debug("pet.json fetch failed for %s: %s", slug, exc) + if not isinstance(meta, dict) or not meta: + meta = {"id": slug, "displayName": entry.display_name, "description": ""} + meta["spritesheetPath"] = sprite_path.name + meta.setdefault("id", slug) + meta.setdefault("displayName", entry.display_name) + (directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8") + + pet = load_pet(slug) + if pet is None or not pet.exists: + raise PetStoreError(f"install of '{slug}' did not produce a spritesheet") + return pet + + +_THUMB_FRAME_W = 192 +_THUMB_FRAME_H = 208 +_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI + + +def _thumbs_dir() -> Path: + path = pets_dir() / ".thumbs" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _is_petdex_host(url: str) -> bool: + """True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF).""" + from urllib.parse import urlparse + + try: + host = (urlparse(url).hostname or "").lower() + except ValueError: + return False + return host == "petdex.dev" or host.endswith(".petdex.dev") + + +def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None: + """Return a small idle-frame PNG for *slug*, cached on disk. + + Crops the top-left (idle, frame 0) cell of the spritesheet and downsamples + it to a thumbnail. Source preference: an installed spritesheet on disk, else + *source_url* — but only when it points at petdex (so the gateway never + fetches an arbitrary client-supplied URL). Returns ``None`` when there's no + usable source or Pillow/network fails; callers render a placeholder. + + Doing this server-side sidesteps the renderer's CSP / R2 hotlink limits that + break a direct ```` and lets the result ride the authenticated + gateway as a same-origin data URL. + """ + slug = slug.strip() + if not slug: + return None + + cache = _thumbs_dir() / f"{slug}.png" + if cache.is_file(): + try: + return cache.read_bytes() + except OSError: + pass + + sheet_bytes: bytes | None = None + pet = load_pet(slug) + if pet and pet.exists: + try: + sheet_bytes = pet.spritesheet.read_bytes() + except OSError: + sheet_bytes = None + + if sheet_bytes is None and source_url and _is_petdex_host(source_url): + try: + import httpx + + resp = httpx.get( + source_url, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) + resp.raise_for_status() + sheet_bytes = resp.content + except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder + logger.debug("thumb fetch failed for %s: %s", slug, exc) + + if not sheet_bytes: + return None + + try: + import io + + from PIL import Image + + with Image.open(io.BytesIO(sheet_bytes)) as im: + frame = im.convert("RGBA").crop( + (0, 0, min(_THUMB_FRAME_W, im.width), min(_THUMB_FRAME_H, im.height)) + ) + height = round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W) + frame = frame.resize((_THUMB_W, height), Image.NEAREST) + buf = io.BytesIO() + frame.save(buf, format="PNG") + data = buf.getvalue() + except Exception as exc: # noqa: BLE001 + logger.debug("thumb crop failed for %s: %s", slug, exc) + return None + + try: + cache.write_bytes(data) + except OSError: + pass + return data + + +def remove_pet(slug: str) -> bool: + """Delete an installed pet directory. Returns True if anything was removed.""" + import shutil + + directory = pets_dir() / slug.strip() + if not directory.is_dir(): + return False + shutil.rmtree(directory, ignore_errors=True) + return not directory.exists() + + +def _download(url: str, dest: Path, *, timeout: float) -> None: + import httpx + + try: + with httpx.stream( + "GET", + url, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) as resp: + resp.raise_for_status() + tmp = dest.with_suffix(dest.suffix + ".part") + with tmp.open("wb") as fh: + for chunk in resp.iter_bytes(): + fh.write(chunk) + tmp.replace(dest) + except Exception as exc: # noqa: BLE001 + raise PetStoreError(f"download failed for {url}: {exc}") from exc + + +def _download_json(url: str, *, timeout: float) -> dict: + import httpx + + resp = httpx.get( + url, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, dict) else {} diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f698c11d5ac..a557899ae98 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1633,6 +1633,31 @@ DEFAULT_CONFIG = { "fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide }, "copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled" + # Petdex animated mascot (https://github.com/crafter-station/petdex). + # A purely cosmetic sprite that reacts to agent activity across the + # CLI, TUI, and desktop app. Manage with `hermes pets`. Disabled until + # a pet is installed + selected (no effect on prompt caching — this is + # a display concern only). + "pet": { + "enabled": False, + # Active pet slug; resolved against installed pets in + # get_hermes_home()/pets/. Empty → first installed pet. + "slug": "", + # Terminal render protocol for CLI/TUI: + # auto — detect kitty/iTerm2/sixel, else unicode half-blocks + # kitty | iterm | sixel | unicode | off + "render_mode": "auto", + # Master size scalar (relative to native 192×208 frames). One knob + # shrinks every surface: the desktop canvas scales its pixels by it + # and the CLI/TUI derive their terminal column width from it. The + # half-block fallback clamps to a legibility floor (it can't shrink + # as far as true-pixel kitty/GUI without turning to mush). + "scale": 0.33, + # Hard override for terminal column width. 0 = auto (derive from + # scale); set a positive int only to pin the half-block/kitty width + # independently of scale. + "unicode_cols": 0, + }, }, # Web dashboard settings diff --git a/tests/agent/test_pet_engine.py b/tests/agent/test_pet_engine.py new file mode 100644 index 00000000000..e7816134133 --- /dev/null +++ b/tests/agent/test_pet_engine.py @@ -0,0 +1,371 @@ +"""Tests for the petdex pet engine (agent/pet/*). + +Behavior/invariant focused — no network, no live manifest. A tiny synthetic +spritesheet is generated with Pillow so render paths exercise real decode +without depending on a downloaded pet. +""" + +from __future__ import annotations + +import io + +import pytest + +from agent.pet import constants, render, state, store +from agent.pet.constants import FRAME_H, FRAME_W, PetState + + +# ───────────────────────────────────────────────────────────────────────── +# state mapping — priority invariants +# ───────────────────────────────────────────────────────────────────────── + +def test_derive_idle_default(): + assert state.derive_pet_state() is PetState.IDLE + # awaiting input uses the dedicated waiting row when available. + assert state.derive_pet_state(awaiting_input=True) is PetState.WAITING + + +def test_derive_priority_order(): + # error beats everything + assert state.derive_pet_state(error=True, celebrate=True, busy=True) is PetState.FAILED + # celebrate beats completion/tool + assert state.derive_pet_state(celebrate=True, just_completed=True, tool_running=True) is PetState.JUMP + # completion beats waiting/tool + assert state.derive_pet_state(just_completed=True, awaiting_input=True) is PetState.WAVE + # waiting (blocked on the user) outranks the in-flight signals — a clarify + # mid-turn pauses on you even though a tool is technically still open. + assert state.derive_pet_state(awaiting_input=True, tool_running=True, busy=True) is PetState.WAITING + # tool beats reasoning + assert state.derive_pet_state(tool_running=True, reasoning=True) is PetState.RUN + # reasoning beats bare-busy + assert state.derive_pet_state(reasoning=True, busy=True) is PetState.REVIEW + # bare busy runs + assert state.derive_pet_state(busy=True) is PetState.RUN + + +def test_todos_all_done(): + # empty / falsy → not done (no plan to celebrate) + assert state.todos_all_done(None) is False + assert state.todos_all_done([]) is False + # any open item → not done + assert state.todos_all_done([{"status": "completed"}, {"status": "pending"}]) is False + assert state.todos_all_done([{"status": "in_progress"}]) is False + # every item terminal → done (completed and/or cancelled) + assert state.todos_all_done([{"status": "completed"}, {"status": "cancelled"}]) is True + + # objects with a .status attr work too (mirrors dict + attr access) + class _T: + def __init__(self, status): + self.status = status + + assert state.todos_all_done([_T("completed")]) is True + assert state.todos_all_done([_T("completed"), _T("pending")]) is False + + +def test_state_row_index_maps_to_supported_atlas_taxonomies(): + # Current Petdex sheets are 8 columns x 9 rows. + assert constants.state_row_index(PetState.IDLE, 9) == 0 + assert constants.state_row_index(PetState.WAVE, 9) == 3 + assert constants.state_row_index(PetState.JUMP, 9) == 4 + assert constants.state_row_index(PetState.FAILED, 9) == 5 + assert constants.state_row_index(PetState.WAITING, 9) == 6 + assert constants.state_row_index(PetState.RUN, 9) == 7 + assert constants.state_row_index(PetState.REVIEW, 9) == 8 + + # Legacy Hermes/petdex sheets were 8 rows with Hermes state names packed in + # order. Keep those readable instead of forcing old installs through the + # newer Codex taxonomy. + assert constants.state_row_index(PetState.WAVE, 8) == 1 + assert constants.state_row_index(PetState.RUN, 8) == 2 + assert constants.state_row_index(PetState.FAILED, 8) == 3 + assert constants.state_row_index(PetState.REVIEW, 8) == 4 + assert constants.state_row_index(PetState.JUMP, 8) == 5 + assert constants.state_row_index(PetState.WAITING, 8) == 0 + + # Alias rows resolve as expected. + assert constants.state_row_index("wave", 9) == constants.state_row_index("waving", 9) == 3 + assert constants.state_row_index("jump", 9) == constants.state_row_index("jumping", 9) == 4 + assert constants.state_row_index("run", 9) == constants.state_row_index("running", 9) == 7 + + # unknown row names clamp to idle (row 0), never raise + assert constants.state_row_index("nonsense") == 0 + + +def test_cols_for_scale_is_monotonic_and_floored(): + # scale is the master size knob: smaller scale never yields more columns, + # and half-blocks clamp to a legibility floor rather than devolving to mush. + sizes = [constants.cols_for_scale(s) for s in (0.1, 0.3, 0.5, 0.7, 1.0, 1.5)] + assert sizes == sorted(sizes) + assert all(c >= constants.UNICODE_MIN_COLS for c in sizes) + # tiny scales pin to the floor; large scales grow past it. + assert constants.cols_for_scale(0.05) == constants.UNICODE_MIN_COLS + assert constants.cols_for_scale(0.33) == constants.UNICODE_MIN_COLS + assert constants.cols_for_scale(2.0) > constants.UNICODE_MIN_COLS + + +def test_resolve_cols_override_else_scale(): + # 0 / falsy → derive from scale; a positive int hard-overrides scale. + assert constants.resolve_cols(0.7, 0) == constants.cols_for_scale(0.7) + assert constants.resolve_cols(0.7, None) == constants.cols_for_scale(0.7) + assert constants.resolve_cols(2.0, 12) == 12 + assert constants.resolve_cols(0.1, -5) == constants.cols_for_scale(0.1) + + +# ───────────────────────────────────────────────────────────────────────── +# synthetic spritesheet fixture +# ───────────────────────────────────────────────────────────────────────── + +@pytest.fixture +def boba_like(tmp_path, monkeypatch): + """Install a synthetic 8-col × 9-row pet into a temp HERMES_HOME.""" + from PIL import Image + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + cols, rows = 8, 9 + sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0)) + # paint each row a distinct opaque color so frames are non-empty + for r in range(rows): + color = (20 + r * 25, 60, 120, 255) + for c in range(cols): + block = Image.new("RGBA", (FRAME_W, FRAME_H), color) + sheet.paste(block, (c * FRAME_W, r * FRAME_H)) + + pet_dir = store.pets_dir() / "boba" + pet_dir.mkdir(parents=True, exist_ok=True) + sheet.save(pet_dir / "spritesheet.webp") + (pet_dir / "pet.json").write_text( + '{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}' + ) + return pet_dir + + +def test_store_install_resolution(boba_like): + pets = store.installed_pets() + assert [p.slug for p in pets] == ["boba"] + assert store.installed_pets()[0].exists + + # configured slug wins when installed + assert store.resolve_active_pet("boba").slug == "boba" + # bogus slug falls back to first installed + assert store.resolve_active_pet("does-not-exist").slug == "boba" + # display metadata flows from pet.json + assert store.load_pet("boba").display_name == "Boba" + + +def test_store_remove(boba_like): + assert store.remove_pet("boba") is True + assert store.installed_pets() == [] + assert store.remove_pet("boba") is False # idempotent + + +# ───────────────────────────────────────────────────────────────────────── +# render — decode + every encoder produces output +# ───────────────────────────────────────────────────────────────────────── + +def test_renderer_decodes_frames(boba_like): + sprite = store.load_pet("boba").spritesheet + r = render.PetRenderer(str(sprite), mode="unicode", scale=0.5, unicode_cols=12) + assert r.available + # standard sheet yields FRAMES_PER_STATE frames per state + assert r.frame_count("idle") == constants.FRAMES_PER_STATE + assert r.frame_count(PetState.RUN) == constants.FRAMES_PER_STATE + + +def test_trims_trailing_blank_frames(tmp_path): + """Ragged state rows (real frames + transparent padding) trim to real count. + + petdex sheets are left-packed: a state with fewer than FRAMES_PER_STATE real + frames pads the trailing columns transparent. Stepping into one flashes the + pet blank, so the engine must stop the row at the first gap. + """ + from PIL import Image + + cols, rows = 8, 9 + sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0)) + # row index -> number of real (opaque) frames; the rest stay transparent. + # Codex row taxonomy: idle, running-right, running-left, wave, jump, failed, + # waiting, run, review. + real = {0: 6, 3: 4, 4: 5, 5: 8, 7: 6, 8: 5} + for r, k in real.items(): + for c in range(k): + block = Image.new("RGBA", (FRAME_W, FRAME_H), (200, 80, 80, 255)) + sheet.paste(block, (c * FRAME_W, r * FRAME_H)) + sprite = tmp_path / "ragged.webp" + sheet.save(sprite) + + r = render.PetRenderer(str(sprite), mode="unicode", scale=0.5) + # Full rows cap at FRAMES_PER_STATE; ragged rows trim to their real count. + assert r.frame_count("idle") == constants.FRAMES_PER_STATE + assert r.frame_count("run") == constants.FRAMES_PER_STATE + assert r.frame_count("wave") == 4 + assert r.frame_count("jump") == 5 + assert r.frame_count("failed") == constants.FRAMES_PER_STATE + assert r.frame_count("review") == 5 + + # Every stepped frame is non-empty — no blank flash for the trimmed states. + for state in ("wave", "jump", "review"): + for i in range(r.frame_count(state)): + assert r.frame(state, i), f"{state}[{i}] rendered blank" + + counts = render.state_frame_counts(str(sprite)) + assert counts == { + "idle": 6, + "wave": 4, + "run": 6, + "failed": 6, + "review": 5, + "jump": 5, + "waiting": 0, + } + + +@pytest.mark.parametrize("mode", ["unicode", "kitty", "iterm", "sixel"]) +def test_every_encoder_emits(boba_like, mode): + sprite = store.load_pet("boba").spritesheet + r = render.PetRenderer(str(sprite), mode=mode, scale=0.4) + frame = r.frame("run", 1) + assert isinstance(frame, str) and frame, f"{mode} produced no frame" + if mode == "unicode": + assert "\x1b[" in frame # has color escapes + elif mode == "kitty": + assert frame.startswith("\x1b_G") + elif mode == "iterm": + assert frame.startswith("\x1b]1337;File=") + elif mode == "sixel": + assert frame.startswith("\x1bP") + + +def test_frame_index_wraps(boba_like): + sprite = store.load_pet("boba").spritesheet + r = render.PetRenderer(str(sprite), mode="unicode", scale=0.4) + # index beyond count wraps rather than indexing out of range + assert r.frame("idle", 999) == r.frame("idle", 999 % r.frame_count("idle")) + + +def test_cells_grid_shape(boba_like): + sprite = store.load_pet("boba").spritesheet + r = render.PetRenderer(str(sprite), mode="unicode", scale=0.4, unicode_cols=14) + grid = r.cells("run", 0, cols=14) + assert grid, "no cells produced" + # every row is the requested width; every cell is (top, bottom) RGBA pairs + assert all(len(row) == 14 for row in grid) + (top, bottom) = grid[0][0] + assert len(top) == 4 and len(bottom) == 4 + # missing-sheet renderer yields no cells, never raises + assert render.PetRenderer(str(sprite.parent / "missing.webp"), mode="unicode").cells("idle", 0) == [] + + +# ───────────────────────────────────────────────────────────────────────── +# render — kitty Unicode placeholders (TUI graphics path) +# ───────────────────────────────────────────────────────────────────────── + +def test_kitty_image_id_stable_bounded_nonzero(): + # Deterministic per slug so re-renders reuse the same terminal-side image, + # and always a valid 24-bit-encodable, non-zero id. + a = render.kitty_image_id("boba") + assert a == render.kitty_image_id("boba") + assert 1 <= a <= 0x7FFF + + +def test_kitty_color_hex_decodes_to_id(): + # The placeholder's foreground color IS the image id (24-bit). The terminal + # reconstructs id = (r<<16)|(g<<8)|b, so the hex must round-trip. + for slug in ("boba", "clawd", "pixel-fox"): + image_id = render.kitty_image_id(slug) + h = render.kitty_color_hex(image_id) + assert h.startswith("#") and len(h) == 7 + assert int(h[1:], 16) == image_id + + +def test_kitty_placeholder_rows_grid_contract(): + cols, rows = 18, 10 + grid = render.kitty_placeholder_rows(cols, rows) + assert len(grid) == rows + placeholder = "\U0010eeee" + for r, row in enumerate(grid): + # Each line is exactly `cols` placeholder cells (combining diacritics + # are zero-width, so this is the rendered width Ink must measure). + assert row.count(placeholder) == cols + # First cell carries this row's diacritic; the rest inherit row + col. + assert row.startswith(placeholder + chr(render._ROWCOL_DIACRITICS[r])) + + +def test_kitty_payload_structure(boba_like): + sprite = store.load_pet("boba").spritesheet + image_id = render.kitty_image_id("boba") + scale = 0.4 + r = render.PetRenderer(str(sprite), mode="kitty", scale=scale, unicode_cols=18) + payload = r.kitty_payload("run", image_id=image_id) + assert payload is not None + # placement box must follow scaled pixels, not unicode_cols (kitty upscales to c×r). + frames = r._frames("run") + expect_cols, expect_rows = r._cell_box(frames[0]) + assert payload["cols"] == expect_cols + assert payload["rows"] == expect_rows + assert expect_cols < 18 # 0.4 scale is much smaller than a pinned 18-col box + # placeholder grid matches the requested geometry + assert len(payload["placeholder"]) == payload["rows"] + # one transmit escape per animation frame, each a kitty virtual placement + assert len(payload["frames"]) == r.frame_count("run") + for esc in payload["frames"]: + assert esc.startswith("\x1b_G") + assert esc.endswith("\x1b\\") + assert f"i={image_id}" in esc + assert "a=T" in esc and "U=1" in esc + assert f"c={payload['cols']}" in esc and f"r={payload['rows']}" in esc + + +def test_kitty_payload_none_when_no_frames(tmp_path): + r = render.PetRenderer(str(tmp_path / "missing.webp"), mode="kitty") + assert r.kitty_payload("idle", image_id=1) is None + + +def test_off_mode_and_missing_sheet_degrade(tmp_path): + # off mode never emits + r_off = render.PetRenderer(str(tmp_path / "nope.webp"), mode="off") + assert r_off.frame("idle", 0) == "" + # missing sheet → not available, empty frames, no raise + r_missing = render.PetRenderer(str(tmp_path / "nope.webp"), mode="unicode") + assert not r_missing.available + assert r_missing.frame("idle", 0) == "" + + +def test_resolve_mode_non_tty_is_off(): + # a non-tty stream forces 'off' regardless of configured mode + assert render.resolve_mode("kitty", stream=io.StringIO()) == "off" + assert render.resolve_mode("auto", stream=io.StringIO()) == "off" + + +def test_detect_terminal_graphics_env(monkeypatch): + for key in ("KITTY_WINDOW_ID", "TERM_PROGRAM", "ITERM_SESSION_ID", "WEZTERM_PANE", "TERM"): + monkeypatch.delenv(key, raising=False) + + monkeypatch.setenv("KITTY_WINDOW_ID", "1") + assert render.detect_terminal_graphics() == "kitty" + monkeypatch.delenv("KITTY_WINDOW_ID") + + monkeypatch.setenv("TERM_PROGRAM", "iTerm.app") + assert render.detect_terminal_graphics() == "iterm" + monkeypatch.delenv("TERM_PROGRAM") + + monkeypatch.setenv("TERM", "xterm-256color") + assert render.detect_terminal_graphics() == "unicode" + + +def test_vscode_terminal_ignores_leaked_graphics_env(monkeypatch): + # The VS Code / Cursor integrated terminal can't show inline images by + # default, yet inherits ITERM_SESSION_ID/KITTY_WINDOW_ID when launched from + # those terminals. TERM_PROGRAM=vscode must win → unicode, never a protocol + # whose escapes the embedded terminal would silently drop. + for key in ("KITTY_WINDOW_ID", "TERM_PROGRAM", "ITERM_SESSION_ID", "WEZTERM_PANE", "TERM"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("TERM_PROGRAM", "vscode") + + assert render.detect_terminal_graphics() == "unicode" + for leaked in ("ITERM_SESSION_ID", "KITTY_WINDOW_ID", "WEZTERM_PANE"): + monkeypatch.setenv(leaked, "1") + assert render.detect_terminal_graphics() == "unicode" + monkeypatch.delenv(leaked) From 83aa84ae3b00ba5f2923dad2194a491f6bab6888 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 14:18:33 -0500 Subject: [PATCH 002/244] feat(pets): CLI pet pane + /pet command Render the reactive pet pane in the classic CLI (steady redraw, right-aligned) and wire the /pet command to list and switch pets, plus an enable/disable toggle. Backed by hermes_cli/pets.py and the CLI commands mixin, registered in the central command registry. Covered by the CLI pet pane and toggle tests. --- cli.py | 262 ++++++++++++++- hermes_cli/cli_commands_mixin.py | 58 ++++ hermes_cli/commands.py | 2 + hermes_cli/main.py | 22 +- hermes_cli/pets.py | 482 ++++++++++++++++++++++++++++ tests/cli/test_cli_pet_pane.py | 136 ++++++++ tests/hermes_cli/test_pet_toggle.py | 104 ++++++ 7 files changed, 1064 insertions(+), 2 deletions(-) create mode 100644 hermes_cli/pets.py create mode 100644 tests/cli/test_cli_pet_pane.py create mode 100644 tests/hermes_cli/test_pet_toggle.py diff --git a/cli.py b/cli.py index b1c9a4bc8ef..4d5ac86994b 100644 --- a/cli.py +++ b/cli.py @@ -60,7 +60,7 @@ from prompt_toolkit.history import FileHistory from prompt_toolkit.styles import Style as PTStyle from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.application import Application -from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer +from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer, WindowAlign from prompt_toolkit.layout.processors import Processor, Transformation, PasswordProcessor, ConditionalProcessor from prompt_toolkit.filters import Condition from prompt_toolkit.layout.dimension import Dimension @@ -3590,6 +3590,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup) self._command_running = False self._command_status = "" + # Petdex mascot (opt-in via display.pet). The base CLI mirrors the TUI's + # PetPane: a half-block sprite above the prompt that reacts to agent + # activity. Lazily resolved; an invalidate timer drives the animation. + self._pet_renderer = None # agent.pet.render.PetRenderer | None + self._pet_slug: str = "" + self._pet_enabled: bool = False + self._pet_cols: int = 18 + self._pet_scale: float = 0.7 + self._pet_frames_cache: dict = {} # state -> list[grid] + self._pet_frame_idx: int = 0 + self._pet_lock = threading.Lock() + self._pet_cfg_checked: float = 0.0 + self._pet_anim_running: bool = False + self._pet_anim_thread = None + # Transient reaction beats (wave/jump/failed) + steady reasoning flag. + self._pet_event: str = "" + self._pet_event_until: float = 0.0 + self._pet_reasoning: bool = False + self._pet_turn_error: bool = False self._attached_images: list[Path] = [] self._image_counter = 0 self.preloaded_skills: list[str] = [] @@ -4130,6 +4149,218 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): return f" {txt} ({elapsed_str})" return f" {txt}" + # ── Petdex mascot (base-CLI pet pane) ─────────────────────────────── + # + # Parity with the TUI: a half-block sprite rendered as a prompt_toolkit + # window above the prompt, reacting to agent state and animated by a timer + # that calls ``app.invalidate()``. Half-blocks only — the crisp Kitty image + # protocol can't coexist with prompt_toolkit's patch_stdout output layer + # (raw image escapes get swallowed/mangled), so we use truecolor styled + # text, which prompt_toolkit renders natively in any 24-bit terminal. + + _PET_FRAME_INTERVAL = 0.16 + _PET_CFG_INTERVAL = 2.5 + + def _pet_resolve_config(self) -> None: + """(Re)resolve the active pet from config — picks up live enable/disable/ + + switch made via ``/pet`` or ``hermes pets`` without a restart, mirroring + the TUI's steady poll. Cheap and fail-open: any problem disables the pet. + """ + try: + from agent.pet import constants, store + from agent.pet.render import PetRenderer + from hermes_cli.config import load_config + + cfg = load_config() + display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} + pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {} + + enabled = bool(pet_cfg.get("enabled")) + slug = str(pet_cfg.get("slug", "") or "") + scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) + cols = constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0)) + + if not enabled: + with self._pet_lock: + self._pet_enabled = False + self._pet_renderer = None + self._pet_frames_cache.clear() + return + + pet = store.resolve_active_pet(slug) + if pet is None or not pet.exists: + with self._pet_lock: + self._pet_enabled = False + self._pet_renderer = None + self._pet_frames_cache.clear() + return + + with self._pet_lock: + # Rebuild only when the resolved pet or geometry changes. + if ( + self._pet_renderer is None + or self._pet_slug != pet.slug + or self._pet_cols != cols + or self._pet_scale != scale + ): + self._pet_renderer = PetRenderer( + str(pet.spritesheet), mode="unicode", scale=scale, unicode_cols=cols + ) + self._pet_slug = pet.slug + self._pet_cols = cols + self._pet_scale = scale + self._pet_frames_cache.clear() + self._pet_frame_idx = 0 + self._pet_enabled = True + except Exception: + with self._pet_lock: + self._pet_enabled = False + self._pet_renderer = None + + def _pet_flash(self, state: str, secs: float = 1.6) -> None: + """Briefly force a transient reaction (wave/jump/failed) before resting.""" + self._pet_event = state + self._pet_event_until = time.monotonic() + secs + + def _pet_react_turn_end(self) -> None: + """Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave.""" + if not self._pet_enabled: + return + from agent.pet.state import todos_all_done + + if self._pet_turn_error: + self._pet_flash("failed") + return + try: + store = getattr(self.agent, "_todo_store", None) + done = todos_all_done(store.read()) if store else False + except Exception: + done = False + self._pet_flash("jump" if done else "wave") + + def _derive_pet_state(self) -> str: + """Map current CLI activity to a pet animation state. + + A transient reaction beat (wave/jump/failed) wins while it's live; + otherwise the steady state comes from the shared + :func:`agent.pet.state.derive_pet_state` so the CLI can't drift from the + TUI/desktop priority order. + """ + if self._pet_event and time.monotonic() < self._pet_event_until: + return self._pet_event + self._pet_event = "" + from agent.pet.state import derive_pet_state + + # A live blocking modal (approval / clarify / sudo / secret / slash + # confirm) means the agent is paused on the user → the `waiting` pose, + # which outranks the in-flight signals in derive_pet_state. + awaiting_input = bool( + self._approval_state + or self._clarify_state + or self._sudo_state + or self._secret_state + or getattr(self, "_slash_confirm_state", None) + ) + + return derive_pet_state( + awaiting_input=awaiting_input, + busy=getattr(self, "_agent_running", False), + reasoning=self._pet_reasoning, + ).value + + def _pet_frames_for(self, state: str) -> list: + """Return (and cache) the half-block grids for one state.""" + cached = self._pet_frames_cache.get(state) + if cached is not None: + return cached + renderer = self._pet_renderer + if renderer is None: + return [] + try: + count = renderer.frame_count(state) or 1 + grids = [renderer.cells(state, i, cols=self._pet_cols) for i in range(count)] + except Exception: + grids = [] + self._pet_frames_cache[state] = grids + return grids + + def _pet_fragments(self): + """Return prompt_toolkit FormattedText for the current pet frame, or [].""" + with self._pet_lock: + if not self._pet_enabled or self._pet_renderer is None: + return [] + state = self._derive_pet_state() + grids = self._pet_frames_for(state) + if not grids: + return [] + grid = grids[self._pet_frame_idx % len(grids)] + + frags = [] + for y, row in enumerate(grid): + if y: + frags.append(("", "\n")) + for top, bottom in row: + tr, tg, tb, ta = top + br, bg, bb, ba = bottom + top_op = ta >= 32 + bot_op = ba >= 32 + if not top_op and not bot_op: + frags.append(("", " ")) + elif top_op and bot_op: + frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x} bg:#{br:02x}{bg:02x}{bb:02x}", "▀")) + elif top_op: + # Upper half only — leave the lower half the terminal's bg + # instead of painting it black (cleaner on light themes). + frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x}", "▀")) + else: + frags.append((f"fg:#{br:02x}{bg:02x}{bb:02x}", "▄")) + return frags + + def _pet_widget_height(self) -> int: + """Visible rows for the pet window — 0 collapses it when no pet shows.""" + with self._pet_lock: + if not self._pet_enabled or self._pet_renderer is None: + return 0 + grids = self._pet_frames_for(self._derive_pet_state()) + if not grids or not grids[0]: + return 0 + return len(grids[0]) + + def _pet_anim_loop(self) -> None: + """Advance the frame + invalidate on a timer while a pet is enabled.""" + while self._pet_anim_running: + time.sleep(self._PET_FRAME_INTERVAL) + now = time.monotonic() + if now - self._pet_cfg_checked >= self._PET_CFG_INTERVAL: + self._pet_cfg_checked = now + self._pet_resolve_config() + if not self._pet_enabled: + continue + with self._pet_lock: + self._pet_frame_idx += 1 + app = getattr(self, "_app", None) + if app is not None: + try: + app.invalidate() + except Exception: + pass + + def _pet_start_anim(self) -> None: + if self._pet_anim_running: + return + self._pet_resolve_config() + self._pet_anim_running = True + self._pet_anim_thread = threading.Thread(target=self._pet_anim_loop, daemon=True) + self._pet_anim_thread.start() + + def _pet_stop_anim(self) -> None: + self._pet_anim_running = False + thread = self._pet_anim_thread + if thread is not None: + thread.join(timeout=0.3) + self._pet_anim_thread = None + def _voice_record_key_label(self) -> str: """Return the configured voice push-to-talk key formatted for UI. @@ -7475,6 +7706,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): elif canonical == "personality": # Use original case (handler lowercases the personality name itself) self._handle_personality_command(cmd_original) + elif canonical == "pet": + self._handle_pet_command(cmd_original) elif canonical == "retry": retry_msg = self.retry_last() if retry_msg and hasattr(self, '_pending_input'): @@ -9638,6 +9871,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): stacked line to scrollback on tool.completed so users can see the full history of tool calls (not just the current one in the spinner). """ + # Feed the pet: tools mean "running" (not reasoning); a failed tool + # latches the turn so it ends on a sulk. + if event_type == "tool.started": + self._pet_reasoning = False + elif event_type == "tool.completed" and kwargs.get("is_error"): + self._pet_turn_error = True + elif event_type and event_type.startswith("reasoning"): + self._pet_reasoning = True + if event_type == "tool.completed": self._tool_start_time = 0.0 # Print stacked scrollback line for "all" / "new" modes @@ -11590,6 +11832,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): spinner_widget, spacer, *self._get_extra_tui_widgets(), + getattr(self, "_pet_widget", None), status_bar, input_rule_top, image_bar, @@ -12938,6 +13181,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): wrap_lines=True, ) + # Petdex mascot — right-aligned half-block sprite above the prompt, + # mirroring the TUI's PetPane. Collapses to height 0 when no pet is + # enabled, so it's a no-op for everyone else. The _pet_anim_loop thread + # advances frames + invalidates; align=RIGHT pins it to the edge. + self._pet_widget = Window( + content=FormattedTextControl(self._pet_fragments), + height=self._pet_widget_height, + align=WindowAlign.RIGHT, + ) + spacer = Window( content=FormattedTextControl(get_hint_text), height=get_hint_height, @@ -13708,6 +13961,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # Regular chat - run agent self._agent_running = True + self._pet_turn_error = False + self._pet_reasoning = False app.invalidate() # Refresh status line try: @@ -13718,6 +13973,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._tool_start_time = 0.0 self._pending_tool_info.clear() self._last_scrollback_tool = "" + self._pet_reasoning = False + self._pet_react_turn_end() app.invalidate() # Refresh status line @@ -13950,6 +14207,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # The app enables focus reporting + mouse tracking; record that # so _run_cleanup resets them on exit (#36823). _mark_tui_input_modes_active() + # Drive the petdex mascot animation (no-op when no pet enabled). + self._pet_start_anim() app.run() except (EOFError, KeyboardInterrupt, BrokenPipeError): pass @@ -13976,6 +14235,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): raise finally: self._should_exit = True + self._pet_stop_anim() # Interrupt the agent immediately so its daemon thread stops making # API calls and exits promptly (agent_thread is daemon, so the # process will exit once the main thread finishes, but interrupting diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 499f8e9a1a5..a064321b4d1 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1039,6 +1039,64 @@ class CLICommandsMixin: print(" Usage: /personality ") print() + def _handle_pet_command(self, cmd: str): + """Toggle, browse, or adopt a petdex mascot. + + ``/pet`` / ``/pet toggle`` → flip ``display.pet.enabled`` on/off + ``/pet list`` → browse the petdex gallery + ``/pet scale `` → resize the pet everywhere (e.g. 0.5) + ``/pet `` → adopt (install if needed) + make active + ``/pet off`` → disable (alias for toggle-off) + + Writes ``display.pet.*`` to config; the CLI/TUI/desktop pet surfaces + pick the change up on their next poll, so the pet appears shortly. + """ + from agent.pet import store + from agent.pet.manifest import ManifestError + from hermes_cli.pets import _set_active, _set_enabled, print_pet_gallery, set_pet_scale, toggle_pet_display + + parts = cmd.split(maxsplit=1) + arg = parts[1].strip() if len(parts) > 1 else "" + low = arg.lower() + + if not arg or low == "toggle": + enabled, name, err = toggle_pet_display() + if err: + print(f"(x_x) {err}") + return + if enabled: + print(f"(^_^)b {name} is out — it'll pop in shortly.") + else: + print(f"(-_-)zzZ {name} put away." if name else "(-_-)zzZ Pet put away.") + return + + if low in ("list", "gallery", "browse", "all"): + print_pet_gallery() + return + + if low == "scale" or low.startswith("scale "): + value = arg[len("scale"):].strip() + if not value: + print("(o_o) Usage: /pet scale (e.g. /pet scale 0.5)") + return + scale, err = set_pet_scale(value) + print(f"(x_x) {err}" if err else f"(^_^) Pet scale → {scale:g}.") + return + + if low == "off": + _set_enabled(False) + print("(-_-)zzZ Pet put away.") + return + + print(f"(o_o) Fetching '{arg}' from petdex…") + try: + pet = store.install_pet(arg) + except (store.PetStoreError, ManifestError) as exc: + print(f"(x_x) Couldn't adopt '{arg}': {exc}") + return + _set_active(arg) + print(f"(^_^)b {pet.display_name} is out — it'll pop in shortly.") + def _handle_cron_command(self, cmd: str): """Handle the /cron command to manage scheduled tasks.""" from cli import get_job diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 514e7f659b3..b7e19bdeebf 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -176,6 +176,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ subcommands=("pending", "approve", "reject", "approval")), CommandDef("bundles", "List skill bundles (aliases / for multiple skills)", "Tools & Skills"), + CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet )", "Tools & Skills", + cli_only=True, args_hint="[toggle|list|scale |]", subcommands=("toggle", "list", "scale", "off")), CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]", subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4508642d0cb..d29f92975c3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11083,7 +11083,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "config", "cron", "curator", "dashboard", "debug", "doctor", "dump", "fallback", "gateway", "hooks", "import", "insights", "gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", - "model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy", + "model", "pairing", "pets", "plugins", "portal", "postinstall", "profile", "proxy", "prompt-size", "send", "sessions", "setup", "skills", "slack", "status", "tools", "uninstall", "update", @@ -11975,6 +11975,26 @@ def main(): except Exception as _exc: logging.getLogger(__name__).debug("curator CLI wiring failed: %s", _exc) + # ========================================================================= + # pets command — petdex animated mascots (CLI / TUI / desktop display) + # ========================================================================= + pets_parser = subparsers.add_parser( + "pets", + help="Browse, install, and select petdex animated pets", + description=( + "Petdex (https://github.com/crafter-station/petdex) is a public " + "gallery of animated sprite pets for coding agents. Install one " + "and Hermes shows it reacting to agent activity across the CLI, " + "TUI, and desktop app." + ), + ) + try: + from hermes_cli.pets import register_cli as _register_pets_cli + + _register_pets_cli(pets_parser) + except Exception as _exc: + logging.getLogger(__name__).debug("pets CLI wiring failed: %s", _exc) + # ========================================================================= # memory command (parser built in hermes_cli/subcommands/memory.py) # ========================================================================= diff --git a/hermes_cli/pets.py b/hermes_cli/pets.py new file mode 100644 index 00000000000..1cb74c63411 --- /dev/null +++ b/hermes_cli/pets.py @@ -0,0 +1,482 @@ +"""CLI subcommand: ``hermes pets ``. + +Thin shell around :mod:`agent.pet`. Browses the public petdex gallery, +installs pets into the profile's ``pets/`` directory, selects the active +mascot (writes ``display.pet.*`` to config.yaml), and runs a doctor check. + +No side effects at import time — ``main.py`` wires the argparse subparsers on +demand via :func:`register_cli`. +""" + +from __future__ import annotations + +import argparse +import sys + + +def _print(msg: str = "") -> None: + print(msg) + + +def _err(msg: str) -> None: + print(msg, file=sys.stderr) + + +def _cmd_list(args) -> int: + """List gallery pets (or only installed ones with ``--installed``).""" + from agent.pet import store + + if getattr(args, "installed", False): + pets = store.installed_pets() + if not pets: + _print("No pets installed. Try: hermes pets install boba") + return 0 + _print(f"Installed pets ({len(pets)}):") + for pet in pets: + _print(f" {pet.slug:<24} {pet.display_name}") + return 0 + + from agent.pet.manifest import ManifestError, fetch_manifest + + try: + entries = fetch_manifest() + except ManifestError as exc: + _err(f"✗ {exc}") + return 1 + + query = (getattr(args, "query", "") or "").strip().lower() + if query: + entries = [ + e + for e in entries + if query in e.slug.lower() or query in e.display_name.lower() + ] + + limit = getattr(args, "limit", 0) or 0 + shown = entries[:limit] if limit > 0 else entries + installed = {p.slug for p in store.installed_pets()} + + _print(f"petdex gallery — {len(entries)} pet(s){' matching ' + repr(query) if query else ''}:") + for entry in shown: + mark = "✓" if entry.slug in installed else " " + _print(f" {mark} {entry.slug:<28} {entry.display_name} ({entry.kind})") + if limit and len(entries) > limit: + _print(f" … {len(entries) - limit} more (use --limit 0 or --query to filter)") + _print("\nInstall one with: hermes pets install ") + return 0 + + +def _cmd_install(args) -> int: + from agent.pet import store + from agent.pet.manifest import ManifestError + + slug = args.slug.strip() + try: + pet = store.install_pet(slug, force=getattr(args, "force", False)) + except (store.PetStoreError, ManifestError) as exc: + _err(f"✗ install failed: {exc}") + return 1 + + _print(f"✓ installed {pet.display_name} → {pet.directory}") + + if getattr(args, "select", False) or not _has_active_pet(): + _set_active(slug) + _print(f"✓ {pet.display_name} is now the active pet (display.pet.slug={slug}, enabled)") + else: + _print(f" Make it active with: hermes pets select {slug}") + return 0 + + +def _cmd_remove(args) -> int: + from agent.pet import store + + slug = args.slug.strip() + if store.remove_pet(slug): + _print(f"✓ removed {slug}") + return 0 + _err(f"✗ '{slug}' is not installed") + return 1 + + +def _cmd_select(args) -> int: + from agent.pet import store + + slug = (getattr(args, "slug", "") or "").strip() + if not slug: + pets = store.installed_pets() + if not pets: + _err("✗ no pets installed — run: hermes pets install boba") + return 1 + slug = _interactive_pick(pets) + if not slug: + return 1 + + pet = store.load_pet(slug) + if pet is None or not pet.exists: + _err(f"✗ '{slug}' is not installed — run: hermes pets install {slug}") + return 1 + + _set_active(slug) + _print(f"✓ active pet set to {pet.display_name} (display.pet.slug={slug}, enabled)") + return 0 + + +def _cmd_off(args) -> int: + _set_enabled(False) + _print("✓ pet disabled (display.pet.enabled=false)") + return 0 + + +def _cmd_scale(args) -> int: + """Persist ``display.pet.scale`` — one knob resizes every surface.""" + scale, err = set_pet_scale(args.factor) + if err: + _err(f"✗ {err}") + return 1 + _print(f"✓ pet scale set to {scale:g} (display.pet.scale)") + return 0 + + +def _cmd_show(args) -> int: + """Animate the active (or named) pet in the terminal. + + Uses the shared :class:`~agent.pet.render.PetRenderer` — full graphics + protocol (kitty/iTerm2/sixel) when the terminal supports it, else a + truecolor Unicode half-block fallback. Ctrl+C to stop. + """ + import time + + from agent.pet import store + from agent.pet.constants import DEFAULT_SCALE, LOOP_MS, STATE_ROWS, PetState, resolve_cols + from agent.pet.render import build_renderer + + cfg = _pet_config() + slug = (getattr(args, "slug", "") or "").strip() or str(cfg.get("slug", "") or "") + pet = store.resolve_active_pet(slug) + if pet is None: + _err("✗ no pet to show — run: hermes pets install boba") + return 1 + + mode_cfg = getattr(args, "mode", None) or str(cfg.get("render_mode", "auto") or "auto") + scale = float(getattr(args, "scale", 0) or cfg.get("scale", DEFAULT_SCALE) or DEFAULT_SCALE) + cols = resolve_cols(scale, cfg.get("unicode_cols", 0)) + + renderer = build_renderer( + pet.spritesheet, + configured_mode=mode_cfg, + scale=scale, + unicode_cols=cols, + ) + if not renderer.available: + _err( + "✗ cannot render here (no TTY / graphics disabled). " + f"Effective mode: {renderer.mode}." + ) + return 1 + + # Which states to play: one named state, or cycle the driveable rows. + requested = (getattr(args, "state", "") or "").strip().lower() + if requested: + states = [requested] + elif getattr(args, "cycle", False): + states = [s for s in STATE_ROWS if s in {e.value for e in PetState}] + else: + states = [PetState.IDLE.value] + + is_unicode = renderer.mode == "unicode" + frame_delay = max(0.05, (LOOP_MS / 1000.0) / max(1, renderer.frame_count(states[0]) or 1)) + + # Right-align the sprite against the terminal's right edge — half-blocks by + # indenting each row, graphics protocols by padding the cursor to the right + # column before the image draws (kitty/iTerm/sixel all render at the cursor). + import shutil + + term_cols = shutil.get_terminal_size((80, 24)).columns + indent = "" + g_indent = "" + if is_unicode: + indent = " " * max(0, term_cols - cols - 1) + else: + cell_cols = max(1, int(renderer.frame_w * renderer.scale) // 8) + g_indent = " " * max(0, term_cols - cell_cols - 1) + + out = sys.stdout + out.write("\x1b[?25l") # hide cursor + out.flush() + prev_lines = 0 + try: + _print(f"{pet.display_name} — mode={renderer.mode} (Ctrl+C to stop)") + loops = 0 + while True: + for state in states: + count = renderer.frame_count(state) or 1 + for i in range(count): + encoded = renderer.frame(state, i) + if is_unicode: + if indent: + encoded = "\n".join(indent + ln for ln in encoded.split("\n")) + if prev_lines: + out.write(f"\x1b[{prev_lines}F") # cursor up to redraw in place + out.write(encoded) + out.write("\x1b[0m\n") + # Lines drawn = sprite rows + the trailing newline; move + # back up exactly that many so the next frame overwrites. + prev_lines = encoded.count("\n") + 1 + else: + out.write("\x1b[2J\x1b[3J\x1b[H") # clear for image protocols + out.write(f"{pet.display_name} [{state}]\n") + if g_indent: + out.write(g_indent) + out.write(encoded) + out.write("\n") + out.flush() + time.sleep(frame_delay) + loops += 1 + if getattr(args, "once", False) and loops >= len(states): + break + except KeyboardInterrupt: + pass + finally: + out.write("\x1b[?25h") # show cursor + out.write("\x1b[0m\n") + out.flush() + return 0 + + +def _cmd_doctor(args) -> int: + """Report install state, active pet, config, and terminal capability.""" + from agent.pet import store + from agent.pet.render import detect_terminal_graphics, resolve_mode + + cfg = _pet_config() + enabled = bool(cfg.get("enabled")) + configured_slug = str(cfg.get("slug", "") or "") + mode_cfg = str(cfg.get("render_mode", "auto") or "auto") + + pets = store.installed_pets() + active = store.resolve_active_pet(configured_slug) + + _print("petdex doctor") + _print(f" pets dir: {store.pets_dir()}") + _print(f" installed: {len(pets)} ({', '.join(p.slug for p in pets) or 'none'})") + _print(f" display.pet.enabled: {enabled}") + _print(f" display.pet.slug: {configured_slug or '(unset)'}") + _print(f" active (resolved): {active.slug if active else '(none)'}") + _print(f" display.pet.render_mode: {mode_cfg}") + _print(f" detected graphics: {detect_terminal_graphics()}") + _print(f" effective mode (TTY): {resolve_mode(mode_cfg)}") + + ok = True + if not pets: + _print(" → no pets installed. Run: hermes pets install boba") + ok = False + elif active is None: + _print(" → active pet unresolved. Run: hermes pets select ") + ok = False + elif not enabled: + _print(" → pet display is disabled. Run: hermes pets select " + active.slug) + + try: + import PIL # noqa: F401 + except ImportError: + _print(" ✗ Pillow not importable — sprite decoding will be unavailable") + ok = False + + _print(" ✓ ready" if ok and enabled else " (run the suggestions above to finish setup)") + return 0 + + +# ───────────────────────────────────────────────────────────────────────── +# config helpers +# ───────────────────────────────────────────────────────────────────────── + +def _pet_config() -> dict: + from hermes_cli.config import load_config + + cfg = load_config() + display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} + pet = display.get("pet", {}) + return pet if isinstance(pet, dict) else {} + + +def _has_active_pet() -> bool: + return bool(_pet_config().get("enabled")) and bool(_pet_config().get("slug")) + + +def _set_active(slug: str) -> None: + from hermes_cli.config import load_config, save_config + + cfg = load_config() + display = cfg.setdefault("display", {}) + pet = display.setdefault("pet", {}) + pet["slug"] = slug + pet["enabled"] = True + save_config(cfg) + + +def _set_enabled(enabled: bool) -> None: + from hermes_cli.config import load_config, save_config + + cfg = load_config() + display = cfg.setdefault("display", {}) + pet = display.setdefault("pet", {}) + pet["enabled"] = enabled + save_config(cfg) + + +def _set_scale(scale: float) -> None: + from hermes_cli.config import load_config, save_config + + cfg = load_config() + display = cfg.setdefault("display", {}) + pet = display.setdefault("pet", {}) + pet["scale"] = scale + save_config(cfg) + + +def set_pet_scale(value: float | str) -> tuple[float, str | None]: + """Set ``display.pet.scale`` (clamped to bounds). Returns ``(applied, error)``. + + The single write path behind ``/pet scale`` and the desktop slider, so every + surface that resolves scale from config picks it up identically. *error* is + set (and nothing written) only when *value* isn't a number. + """ + from agent.pet.constants import clamp_scale + + try: + scale = clamp_scale(float(value)) + except (TypeError, ValueError): + return 0.0, f"not a number: {value!r} — try a value like 0.5" + + _set_scale(scale) + return scale, None + + +def toggle_pet_display() -> tuple[bool, str | None, str | None]: + """Toggle ``display.pet.enabled``. + + Returns ``(enabled, display_name, error_message)``. *error_message* is set + when turning on but nothing is installed to show. + """ + from agent.pet import store + + cfg = _pet_config() + slug = str(cfg.get("slug", "") or "") + pet = store.resolve_active_pet(slug) + + if bool(cfg.get("enabled")): + _set_enabled(False) + return False, pet.display_name if pet else None, None + + if pet is None: + installed = store.installed_pets() + if not installed: + return False, None, "no pets installed — /pet list to browse, or /pet to adopt" + pet = installed[0] + _set_active(pet.slug) + else: + _set_enabled(True) + return True, pet.display_name, None + + +def print_pet_gallery(*, limit: int = 20) -> None: + """Print a slice of the public petdex gallery (CLI/TUI text fallback).""" + from agent.pet import store + from agent.pet.manifest import ManifestError, fetch_manifest + + try: + entries = fetch_manifest() + except ManifestError as exc: + print(f"(._.) Couldn't reach the petdex gallery: {exc}") + return + + installed = {p.slug for p in store.installed_pets()} + shown = entries[:limit] if limit > 0 else entries + print(f"(^o^)/ petdex gallery — first {len(shown)} of {len(entries)}:") + for entry in shown: + mark = "●" if entry.slug in installed else "○" + print(f" {mark} {entry.slug:<24} {entry.display_name}") + print(" /pet to adopt · /pet to toggle") + + +def _clear_active_if(slug: str) -> bool: + """Disable + unset the active pet iff it's ``slug`` (e.g. after removal). + + Returns whether anything changed, so callers don't write config needlessly. + """ + from hermes_cli.config import load_config, save_config + + cfg = load_config() + pet = cfg.setdefault("display", {}).setdefault("pet", {}) + if not isinstance(pet, dict) or str(pet.get("slug", "") or "") != slug: + return False + pet["slug"] = "" + pet["enabled"] = False + save_config(cfg) + return True + + +def _interactive_pick(pets) -> str: + """Minimal numbered picker (avoids curses dep for a tiny list).""" + _print("Installed pets:") + for i, pet in enumerate(pets, 1): + _print(f" {i}. {pet.slug:<24} {pet.display_name}") + try: + choice = input("Select a pet [1]: ").strip() or "1" + idx = int(choice) - 1 + except (EOFError, KeyboardInterrupt, ValueError): + _err("✗ cancelled") + return "" + if 0 <= idx < len(pets): + return pets[idx].slug + _err("✗ invalid selection") + return "" + + +# ───────────────────────────────────────────────────────────────────────── +# argparse wiring +# ───────────────────────────────────────────────────────────────────────── + +def register_cli(parent: argparse.ArgumentParser) -> None: + """Attach ``pets`` subcommands to *parent* (called by main.py).""" + parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1]) + subs = parent.add_subparsers(dest="pets_command") + + p_list = subs.add_parser("list", help="Browse the petdex gallery") + p_list.add_argument("query", nargs="?", default="", help="Filter by slug/name substring") + p_list.add_argument("--installed", action="store_true", help="Only show installed pets") + p_list.add_argument("--limit", type=int, default=40, help="Max rows (0 = all)") + p_list.set_defaults(func=_cmd_list) + + p_install = subs.add_parser("install", help="Install a pet from the gallery") + p_install.add_argument("slug", help="Pet slug (e.g. boba)") + p_install.add_argument("--force", action="store_true", help="Re-download even if present") + p_install.add_argument("--select", action="store_true", help="Make it the active pet") + p_install.set_defaults(func=_cmd_install) + + p_select = subs.add_parser("select", help="Set the active pet (writes display.pet.*)") + p_select.add_argument("slug", nargs="?", default="", help="Pet slug (omit for picker)") + p_select.set_defaults(func=_cmd_select) + + p_show = subs.add_parser("show", help="Animate the active pet in the terminal") + p_show.add_argument("slug", nargs="?", default="", help="Pet slug (default: active)") + p_show.add_argument("--state", default="", help="Single state: idle/run/review/failed/wave/jump") + p_show.add_argument("--cycle", action="store_true", help="Cycle through all states") + p_show.add_argument("--once", action="store_true", help="Play once instead of looping") + p_show.add_argument("--mode", default=None, help="Override render mode (kitty/iterm/sixel/unicode/auto)") + p_show.add_argument("--scale", type=float, default=0, help="Override scale (0 = config)") + p_show.set_defaults(func=_cmd_show) + + subs.add_parser("off", help="Disable the pet display").set_defaults(func=_cmd_off) + + p_scale = subs.add_parser("scale", help="Resize the pet everywhere (display.pet.scale)") + p_scale.add_argument("factor", help="Scale factor, e.g. 0.5 (clamped 0.1–3.0)") + p_scale.set_defaults(func=_cmd_scale) + + p_remove = subs.add_parser("remove", help="Delete an installed pet") + p_remove.add_argument("slug", help="Pet slug") + p_remove.set_defaults(func=_cmd_remove) + + subs.add_parser("doctor", help="Check pet setup + terminal graphics support").set_defaults( + func=_cmd_doctor + ) diff --git a/tests/cli/test_cli_pet_pane.py b/tests/cli/test_cli_pet_pane.py new file mode 100644 index 00000000000..f848971d85a --- /dev/null +++ b/tests/cli/test_cli_pet_pane.py @@ -0,0 +1,136 @@ +"""The base-CLI petdex pane: reactive half-block sprite above the prompt. + +Mirrors the TUI's PetPane. The methods are tested in isolation via __new__ so +we don't pay the full HermesCLI.__init__ cost; a synthetic spritesheet exercises +the real engine decode + half-block fragment building. +""" + +from __future__ import annotations + +import threading + +import pytest + +from agent.pet import store +from agent.pet.constants import FRAME_H, FRAME_W +from agent.pet.render import PetRenderer +from cli import HermesCLI + + +@pytest.fixture +def boba_like(tmp_path, monkeypatch): + """Install a synthetic pet into a temp HERMES_HOME and return its slug.""" + from PIL import Image + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + cols, rows = 8, 9 + sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0)) + for r in range(rows): + color = (20 + r * 25, 60, 120, 255) + for c in range(cols): + block = Image.new("RGBA", (FRAME_W, FRAME_H), color) + sheet.paste(block, (c * FRAME_W, r * FRAME_H)) + + pet_dir = store.pets_dir() / "boba" + pet_dir.mkdir(parents=True, exist_ok=True) + sheet.save(pet_dir / "spritesheet.webp") + (pet_dir / "pet.json").write_text( + '{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}' + ) + return "boba" + + +def _make_cli(): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj._app = None + cli_obj._pet_lock = threading.Lock() + cli_obj._pet_enabled = False + cli_obj._pet_renderer = None + cli_obj._pet_slug = "" + cli_obj._pet_cols = 18 + cli_obj._pet_scale = 0.7 + cli_obj._pet_frames_cache = {} + cli_obj._pet_frame_idx = 0 + cli_obj._agent_running = False + # Transient-beat + reasoning state (set by HermesCLI.__init__ in production). + cli_obj._pet_event = "" + cli_obj._pet_event_until = 0.0 + cli_obj._pet_reasoning = False + # Blocking-modal state — a live one maps the pet to `waiting`. + cli_obj._approval_state = None + cli_obj._clarify_state = None + cli_obj._sudo_state = None + cli_obj._secret_state = None + cli_obj._slash_confirm_state = None + return cli_obj + + +def test_pet_state_tracks_agent_running(): + cli_obj = _make_cli() + assert cli_obj._derive_pet_state() == "idle" + cli_obj._agent_running = True + assert cli_obj._derive_pet_state() == "run" + + +def test_pet_state_waits_on_a_blocking_modal(): + # A live clarify/approval pauses the agent on the user → `waiting`, even + # while the turn is technically still running. + cli_obj = _make_cli() + cli_obj._agent_running = True + cli_obj._clarify_state = {"question": "?"} + assert cli_obj._derive_pet_state() == "waiting" + + +def test_pet_pane_collapsed_when_disabled(): + # No renderer resolved → the window reports zero height and no fragments, + # so it's invisible for users without a pet. + cli_obj = _make_cli() + assert cli_obj._pet_widget_height() == 0 + assert cli_obj._pet_fragments() == [] + + +def test_pet_fragments_render_half_blocks(boba_like): + cli_obj = _make_cli() + cli_obj._pet_renderer = PetRenderer( + str(store.load_pet("boba").spritesheet), mode="unicode", scale=0.4, unicode_cols=14 + ) + cli_obj._pet_cols = 14 + cli_obj._pet_enabled = True + + height = cli_obj._pet_widget_height() + assert height > 0 + + frags = cli_obj._pet_fragments() + assert frags, "expected fragments for an enabled pet" + # Each fragment is a (style, text) pair; glyphs are half-blocks or blanks. + glyphs = {text for _, text in frags} + assert glyphs <= {"▀", "▄", " ", "\n"} + # Opaque cells carry a truecolor foreground style. + assert any(text == "▀" and "fg:#" in style for style, text in frags) + # Row count in the fragment stream matches the reported window height. + assert sum(1 for _, text in frags if text == "\n") == height - 1 + + +def test_pet_resolve_config_enables_and_disables(boba_like): + from hermes_cli.config import load_config, save_config + + cli_obj = _make_cli() + + cfg = load_config() + cfg.setdefault("display", {}).setdefault("pet", {}) + cfg["display"]["pet"].update({"enabled": True, "slug": "boba"}) + save_config(cfg) + + cli_obj._pet_resolve_config() + assert cli_obj._pet_enabled is True + assert cli_obj._pet_renderer is not None + assert cli_obj._pet_slug == "boba" + + cfg["display"]["pet"]["enabled"] = False + save_config(cfg) + cli_obj._pet_resolve_config() + assert cli_obj._pet_enabled is False + assert cli_obj._pet_renderer is None diff --git a/tests/hermes_cli/test_pet_toggle.py b/tests/hermes_cli/test_pet_toggle.py new file mode 100644 index 00000000000..b423e46fab0 --- /dev/null +++ b/tests/hermes_cli/test_pet_toggle.py @@ -0,0 +1,104 @@ +"""Tests for pet slash-command config helpers.""" + +from __future__ import annotations + +import pytest + +from agent.pet import store +from agent.pet.constants import FRAME_H, FRAME_W + + +@pytest.fixture +def boba_installed(tmp_path, monkeypatch): + from PIL import Image + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + sheet = Image.new("RGBA", (FRAME_W * 8, FRAME_H * 9), (0, 0, 0, 0)) + pet_dir = store.pets_dir() / "boba" + pet_dir.mkdir(parents=True, exist_ok=True) + sheet.save(pet_dir / "spritesheet.webp") + (pet_dir / "pet.json").write_text( + '{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}' + ) + return home + + +def _write_config(home, *, enabled: bool, slug: str = "") -> None: + import yaml + + cfg = {"display": {"pet": {"enabled": enabled, "slug": slug, "scale": 0.33}}} + (home / "config.yaml").write_text(yaml.dump(cfg), encoding="utf-8") + + +def test_toggle_pet_display_turns_off_when_enabled(boba_installed): + from hermes_cli.pets import _pet_config, toggle_pet_display + + _write_config(boba_installed, enabled=True, slug="boba") + + enabled, name, err = toggle_pet_display() + + assert err is None + assert enabled is False + assert name == "Boba" + assert _pet_config()["enabled"] is False + + +def test_toggle_pet_display_turns_on_resolved_pet(boba_installed): + from hermes_cli.pets import _pet_config, toggle_pet_display + + _write_config(boba_installed, enabled=False, slug="boba") + + enabled, name, err = toggle_pet_display() + + assert err is None + assert enabled is True + assert name == "Boba" + assert _pet_config()["enabled"] is True + + +def test_toggle_pet_display_errors_with_no_installed_pets(tmp_path, monkeypatch): + from hermes_cli.pets import toggle_pet_display + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + _write_config(home, enabled=False, slug="") + + enabled, name, err = toggle_pet_display() + + assert enabled is False + assert name is None + assert err is not None + + +@pytest.fixture +def empty_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +def test_set_pet_scale_writes_clamped_value(empty_home): + from agent.pet.constants import MAX_SCALE, MIN_SCALE + from hermes_cli.pets import _pet_config, set_pet_scale + + applied, err = set_pet_scale("0.5") + assert err is None + assert applied == 0.5 + assert _pet_config()["scale"] == 0.5 + + # Out-of-range values clamp to the bounds rather than erroring. + assert set_pet_scale(99) == (MAX_SCALE, None) + assert set_pet_scale(0) == (MIN_SCALE, None) + + +def test_set_pet_scale_rejects_non_numbers(empty_home): + from hermes_cli.pets import set_pet_scale + + applied, err = set_pet_scale("huge") + assert applied == 0.0 + assert err is not None From 75b36a138f43f2201b276a3c5d59f2aae0383fef Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 14:18:36 -0500 Subject: [PATCH 003/244] feat(pets): TUI pet pane, picker + gateway RPCs Add the Ink pet sprite pane, the interactive /pet picker overlay, and live pet switching/rescale driven by new tui_gateway RPCs (pet state, pet.scale, per-state frames). Wires pet flash state and the picker into the TUI layout and slash handler. Covered by the slash-handler test. --- tui_gateway/server.py | 424 ++++++++++++++++++ .../src/__tests__/createSlashHandler.test.ts | 35 ++ ui-tui/src/app/createGatewayEventHandler.ts | 7 + ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/overlayStore.ts | 5 +- ui-tui/src/app/petFlashStore.ts | 16 + ui-tui/src/app/slash/commands/session.ts | 28 ++ ui-tui/src/app/useInputHandlers.ts | 4 + ui-tui/src/app/usePet.ts | 313 +++++++++++++ ui-tui/src/components/appLayout.tsx | 22 + ui-tui/src/components/appOverlays.tsx | 8 + ui-tui/src/components/petPicker.tsx | 183 ++++++++ ui-tui/src/components/petSprite.tsx | 93 ++++ ui-tui/src/types/hermes-ink.d.ts | 5 +- 14 files changed, 1142 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/app/petFlashStore.ts create mode 100644 ui-tui/src/app/usePet.ts create mode 100644 ui-tui/src/components/petPicker.tsx create mode 100644 ui-tui/src/components/petSprite.tsx diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 294e543c230..005b26e0cb4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -177,6 +177,14 @@ _LONG_HANDLERS = frozenset( "billing.step_up", "browser.manage", "cli.exec", + # Pet RPCs hit the network (manifest fetch / spritesheet download) or do + # per-frame PNG decode/encode (pet.cells): inline they serialize on the + # reader thread, so picker previews trickle in one at a time and the + # animation poll stutters. On the pool they run concurrently. + "pet.cells", + "pet.gallery", + "pet.select", + "pet.thumb", "plugins.manage", "session.branch", "session.compress", @@ -692,6 +700,29 @@ def _profile_home(profile: str | None) -> Path | None: return home if (home / "state.db").exists() or home.exists() else None +def _profile_scoped(handler): + """Bind ``params['profile']``'s HERMES_HOME around a pet RPC handler. + + Pets are per-profile: ``display.pet.*`` lives in the profile's config.yaml and + sprites install under its ``pets/`` dir (both resolve via ``get_hermes_home``). + The desktop sends ``profile`` on pet calls so config + pets dir resolve to the + focused profile even in app-global remote mode, where one backend serves every + profile. No-op for the launch profile (own-profile backends already resolve it). + """ + + def wrapper(rid, params): + home = _profile_home(params.get("profile") if isinstance(params, dict) else None) + if home is None: + return handler(rid, params) + token = set_hermes_home_override(home) + try: + return handler(rid, params) + finally: + reset_hermes_home_override(token) + + return wrapper + + # Placeholder ``terminal.cwd`` values that don't name a real directory — the # gateway resolves these to the home dir at runtime, so they must NOT be treated # as an explicit workspace (mirrors gateway/run.py's config bridge). @@ -5181,6 +5212,399 @@ def _(rid, params: dict) -> dict: return _ok(rid, usage) +def _pet_frame_counts(spritesheet) -> dict: + """Real (padding-trimmed) frame count per state, for the desktop canvas. + + Fail-open: a decode hiccup returns ``{}`` and the canvas falls back to its + static ``framesPerState`` rather than breaking the (cosmetic) pet. + """ + try: + from agent.pet import render + + return render.state_frame_counts(str(spritesheet)) + except Exception: # noqa: BLE001 - cosmetic, never break the surface + return {} + + +def _pet_state_rows(spritesheet) -> list[str]: + """Row taxonomy for the concrete active pet sheet. + + Hermes has to support both the legacy 8-row petdex atlas and the current + Codex/petdex 9-row atlas. The desktop canvas gets this list and indexes it + with the same `PetState` names the Python renderer uses. + """ + try: + from PIL import Image + + from agent.pet import constants + + with Image.open(spritesheet) as image: + row_count = max(1, image.height // constants.FRAME_H) + return list(constants.state_rows_for_grid(row_count)) + except Exception: # noqa: BLE001 - cosmetic, never break the surface + from agent.pet import constants + + return list(constants.STATE_ROWS) + + +@method("pet.info") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Return the active petdex pet for surfaces that render sprites. + + Shared by the desktop (canvas) and the TUI (half-block). Carries the + spritesheet bytes (base64) plus the engine's frame geometry + state-row + taxonomy so the renderer is a thin, framework-native consumer. The + activity→state decision is mirrored from ``agent.pet.state`` client-side. + + Agent-independent (reads config + disk), so it works on any session and + before the agent finishes building. Fail-open: returns ``enabled=False`` + on any error rather than erroring the surface. + """ + import base64 + + try: + from agent.pet import constants, store + + try: + from hermes_cli.config import load_config + + cfg = load_config() + display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} + pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {} + except Exception: + pet_cfg = {} + + enabled = bool(pet_cfg.get("enabled")) + configured_slug = str(pet_cfg.get("slug", "") or "") + pet = store.resolve_active_pet(configured_slug) if enabled else None + + if not enabled or pet is None or not pet.exists: + return _ok(rid, {"enabled": False}) + + raw = pet.spritesheet.read_bytes() + suffix = pet.spritesheet.suffix.lower() + mime = "image/png" if suffix == ".png" else "image/webp" + return _ok( + rid, + { + "enabled": True, + "slug": pet.slug, + "displayName": pet.display_name, + "mime": mime, + "spritesheetBase64": base64.standard_b64encode(raw).decode("ascii"), + "frameW": constants.FRAME_W, + "frameH": constants.FRAME_H, + "framesPerState": constants.FRAMES_PER_STATE, + "framesByState": _pet_frame_counts(pet.spritesheet), + "loopMs": constants.LOOP_MS, + "scale": float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE), + "stateRows": _pet_state_rows(pet.spritesheet), + }, + ) + except Exception as exc: # noqa: BLE001 - cosmetic, never break the surface + logger.debug("pet.info failed: %s", exc) + return _ok(rid, {"enabled": False}) + + +@method("pet.cells") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Return half-block cell frames for one pet state (TUI renderer). + + The TUI can't draw a canvas, so the engine downsamples the spritesheet to + a grid of half-block cells and the Ink side paints them with native color + props. Each cell is ``[tr,tg,tb,ta, br,bg,bb,ba]`` (top + bottom pixel). + + Params: ``state`` (idle/run/review/failed/wave/jump), ``cols`` (width). + Fail-open: ``enabled=False`` on any problem. + """ + try: + from agent.pet import constants, render, store + from agent.pet.render import PetRenderer + + try: + from hermes_cli.config import load_config + + cfg = load_config() + display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} + pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {} + except Exception: + pet_cfg = {} + + if not bool(pet_cfg.get("enabled")): + return _ok(rid, {"enabled": False}) + + pet = store.resolve_active_pet(str(pet_cfg.get("slug", "") or "")) + if pet is None or not pet.exists: + return _ok(rid, {"enabled": False}) + + state = str(params.get("state") or constants.PetState.IDLE.value) + scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) + cols = int(params.get("cols") or 0) or constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0)) + + # Graphics path: when the TUI is attached to a real TTY (``graphics``) + # and the terminal speaks the kitty protocol, return a Unicode- + # placeholder payload for a crisp image instead of half-blocks. Env + # detection (KITTY_WINDOW_ID / TERM / TERM_PROGRAM) is shared with the + # Ink process since it spawns us; the dashboard PTY (xterm.js) has no + # such env, so it falls through to half-blocks automatically. Only + # kitty is grid-safe in Ink — iTerm/sixel stay on the fallback. + if params.get("graphics"): + configured = str(pet_cfg.get("render_mode", "auto") or "auto").lower() + gmode = render.detect_terminal_graphics() if configured in ("", "auto") else configured + if gmode == "kitty": + image_id = render.kitty_image_id(pet.slug) + # kitty sizes from scaled pixels (_cell_box), so unicode_cols is moot here. + payload = PetRenderer( + str(pet.spritesheet), mode="kitty", scale=scale + ).kitty_payload(state, image_id=image_id) + if payload: + kcount = len(payload["frames"]) or 1 + return _ok( + rid, + { + "enabled": True, + "slug": pet.slug, + "displayName": pet.display_name, + "state": state, + "graphics": "kitty", + "imageId": image_id, + "color": render.kitty_color_hex(image_id), + "cols": payload["cols"], + "rows": payload["rows"], + "placeholder": payload["placeholder"], + "frames": payload["frames"], + "frameMs": constants.LOOP_MS / max(1, kcount), + "scale": scale, + }, + ) + + renderer = PetRenderer( + str(pet.spritesheet), + mode="unicode", + scale=scale, + unicode_cols=cols, + ) + count = renderer.frame_count(state) or 1 + frames = [] + for i in range(count): + grid = renderer.cells(state, i, cols=cols) + frames.append( + [[[*top, *bottom] for (top, bottom) in row] for row in grid] + ) + + return _ok( + rid, + { + "enabled": True, + "slug": pet.slug, + "displayName": pet.display_name, + "state": state, + "cols": cols, + "frameMs": constants.LOOP_MS / max(1, count), + "frames": frames, + "scale": scale, + }, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.cells failed: %s", exc) + return _ok(rid, {"enabled": False}) + + +@method("pet.gallery") +@_profile_scoped +def _(rid, params: dict) -> dict: + """List adoptable pets for the desktop appearance picker. + + Returns the petdex gallery merged with local install state plus the + current config (active slug + enabled). Agent-independent. Fail-open: + returns whatever is installed locally if the gallery can't be reached, so + the picker still works offline. + """ + try: + from agent.pet import store + + try: + from hermes_cli.config import load_config + + cfg = load_config() + display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} + pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {} + except Exception: + pet_cfg = {} + + installed = {p.slug: p for p in store.installed_pets()} + + gallery: list[dict] = [] + seen: set[str] = set() + try: + from agent.pet.manifest import fetch_manifest + + for entry in fetch_manifest(): + seen.add(entry.slug) + gallery.append( + { + "slug": entry.slug, + "displayName": entry.display_name, + "installed": entry.slug in installed, + "spritesheetUrl": entry.spritesheet_url, + # petdex exposes no popularity metric; "curated" (its + # hand-picked/official set, identified by the asset path) + # is the closest signal, so the picker can surface it first. + "curated": "/curated/" in entry.spritesheet_url, + } + ) + except Exception as exc: # noqa: BLE001 - offline: fall back to installed + logger.debug("pet.gallery manifest fetch failed: %s", exc) + + # Always include locally-installed pets even if the gallery is unreachable. + for slug, pet in installed.items(): + if slug not in seen: + gallery.append( + {"slug": slug, "displayName": pet.display_name, "installed": True, "spritesheetUrl": ""} + ) + + return _ok( + rid, + { + "enabled": bool(pet_cfg.get("enabled")), + "active": str(pet_cfg.get("slug", "") or ""), + "pets": gallery, + }, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.gallery failed: %s", exc) + return _ok(rid, {"enabled": False, "active": "", "pets": []}) + + +@method("pet.select") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Adopt a pet from the desktop picker: install (if needed) + activate. + + Params: ``slug`` (required). Writes ``display.pet.*`` to config and returns + ``{ok, slug, displayName}``. The surface re-pulls ``pet.info`` to render it. + """ + slug = str(params.get("slug") or "").strip() + if not slug: + return _err(rid, 4004, "missing slug") + try: + from agent.pet import store + from agent.pet.manifest import ManifestError + from hermes_cli.pets import _set_active + + try: + pet = store.install_pet(slug) + except (store.PetStoreError, ManifestError) as exc: + return _err(rid, 5031, f"could not adopt '{slug}': {exc}") + _set_active(slug) + return _ok(rid, {"ok": True, "slug": slug, "displayName": pet.display_name}) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.select failed: %s", exc) + return _err(rid, 5031, f"pet.select failed: {exc}") + + +@method("pet.remove") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Uninstall a pet from the desktop picker (delete its on-disk directory). + + Params: ``slug`` (required). If the removed pet was the active one, the + display is turned off so nothing tries to render a now-missing sprite. + Returns ``{ok, slug}`` where ``ok`` reflects whether a directory was deleted. + """ + slug = str(params.get("slug") or "").strip() + if not slug: + return _err(rid, 4004, "missing slug") + try: + from agent.pet import store + from hermes_cli.pets import _clear_active_if + + removed = store.remove_pet(slug) + + # If that was the active pet, stop surfaces pointing at a deleted sprite. + try: + _clear_active_if(slug) + except Exception as exc: # noqa: BLE001 - removal already succeeded + logger.debug("pet.remove config update failed: %s", exc) + + return _ok(rid, {"ok": removed, "slug": slug}) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.remove failed: %s", exc) + return _err(rid, 5031, f"pet.remove failed: {exc}") + + +@method("pet.thumb") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Return a small idle-frame PNG (data URI) for one pet — the picker preview. + + Cropped + cached server-side so the renderer gets a same-origin data URL + instead of a CDN ```` (which the desktop CSP / R2 hotlink rules break). + Params: ``slug`` (required), ``url`` (optional petdex spritesheet URL used + only for not-yet-installed pets). Fail-open: ``{ok: false}`` with no error. + """ + slug = str(params.get("slug") or "").strip() + if not slug: + return _err(rid, 4004, "missing slug") + try: + import base64 + + from agent.pet import store + + data = store.thumbnail_png(slug, source_url=str(params.get("url") or "")) + if not data: + return _ok(rid, {"ok": False, "slug": slug}) + + return _ok( + rid, + { + "ok": True, + "slug": slug, + "dataUri": "data:image/png;base64," + base64.standard_b64encode(data).decode("ascii"), + }, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.thumb failed: %s", exc) + return _ok(rid, {"ok": False, "slug": slug}) + + +@method("pet.disable") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Turn the pet off from the desktop picker (``display.pet.enabled=false``).""" + try: + from hermes_cli.pets import _set_enabled + + _set_enabled(False) + return _ok(rid, {"ok": True}) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.disable failed: %s", exc) + return _err(rid, 5031, f"pet.disable failed: {exc}") + + +@method("pet.scale") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Persist ``display.pet.scale`` from the desktop slider. Params: ``scale``. + + Clamped to the engine bounds. The renderer updates its own ``$petInfo`` for + instant feedback; this just makes the change durable + visible to the other + terminal surfaces on their next read. + """ + try: + from hermes_cli.pets import set_pet_scale + + scale, err = set_pet_scale(params.get("scale")) + if err: + return _err(rid, 4004, err) + return _ok(rid, {"ok": True, "scale": scale}) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.scale failed: %s", exc) + return _err(rid, 5031, f"pet.scale failed: {exc}") + + @method("credits.view") def _(rid, params: dict) -> dict: """Structured Nous credit view for the TUI /credits command. diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index a671063e5e9..7124cbfcd75 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -208,6 +208,41 @@ describe('createSlashHandler', () => { }) }) + it('opens the pet picker for /pet list only', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/pet list')).toBe(true) + expect(getOverlayState().petPicker).toBe(true) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + + resetOverlayState() + expect(createSlashHandler(ctx)('/pet')).toBe(true) + expect(getOverlayState().petPicker).toBe(false) + expect(ctx.gateway.gw.request).toHaveBeenCalledWith( + 'slash.exec', + expect.objectContaining({ command: 'pet' }) + ) + + resetOverlayState() + expect(createSlashHandler(ctx)('/pet toggle')).toBe(true) + expect(getOverlayState().petPicker).toBe(false) + expect(ctx.gateway.gw.request).toHaveBeenCalledWith( + 'slash.exec', + expect.objectContaining({ command: 'pet toggle' }) + ) + }) + + it('routes /pet to the slash worker without opening the picker', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/pet boba')).toBe(true) + expect(getOverlayState().petPicker).toBe(false) + expect(ctx.gateway.gw.request).toHaveBeenCalledWith( + 'slash.exec', + expect.objectContaining({ command: 'pet boba' }) + ) + }) + it('routes /skills inspect to skills.manage', () => { const ctx = buildCtx() diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index de2f774f149..b0b09a725c3 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -9,6 +9,7 @@ import type { GatewaySkin, SessionMostRecentResponse } from '../gatewayTypes.js' +import { isTodoDone } from '../lib/liveProgress.js' import { rpcErrorMessage } from '../lib/rpc.js' import { openExternalUrl } from '../lib/openExternalUrl.js' import { topLevelSubagents } from '../lib/subagentTree.js' @@ -19,7 +20,9 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { getOverlayState, patchOverlayState } from './overlayStore.js' +import { flashPet } from './petFlashStore.js' import { turnController } from './turnController.js' +import { getTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i @@ -908,6 +911,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }] msgs.forEach(appendMessage) + // Pet beat: celebrate a finished plan, otherwise a clean-finish wave. + flashPet(isTodoDone(getTurnState().todos) ? 'jump' : 'wave') + if (bellOnComplete && stdout?.isTTY) { stdout.write('\x07') } @@ -924,6 +930,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'error': turnController.recordError() + flashPet('failed') { const message = String(ev.payload?.message || 'unknown error') diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index f570cf2b6ab..1d1f12a3a2a 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -136,6 +136,7 @@ export interface OverlayState { confirm: ConfirmReq | null modelPicker: boolean pager: null | PagerState + petPicker: boolean pluginsHub: boolean secret: null | SecretReq sessions: boolean diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index c0290d71cab..b716b313c90 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -11,6 +11,7 @@ const buildOverlayState = (): OverlayState => ({ confirm: null, modelPicker: false, pager: null, + petPicker: false, pluginsHub: false, secret: null, sessions: false, @@ -22,7 +23,7 @@ export const $overlayState = atom(buildOverlayState()) export const $isBlocked = computed( $overlayState, - ({ agents, approval, billing, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, sudo }) => + ({ agents, approval, billing, clarify, confirm, modelPicker, pager, petPicker, pluginsHub, secret, sessions, skillsHub, sudo }) => Boolean( agents || approval || @@ -31,6 +32,7 @@ export const $isBlocked = computed( confirm || modelPicker || pager || + petPicker || pluginsHub || secret || sessions || @@ -61,6 +63,7 @@ export const resetFlowOverlays = () => agents: $overlayState.get().agents, agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex, modelPicker: $overlayState.get().modelPicker, + petPicker: $overlayState.get().petPicker, pluginsHub: $overlayState.get().pluginsHub, sessions: $overlayState.get().sessions, skillsHub: $overlayState.get().skillsHub diff --git a/ui-tui/src/app/petFlashStore.ts b/ui-tui/src/app/petFlashStore.ts new file mode 100644 index 00000000000..d4865e47a2b --- /dev/null +++ b/ui-tui/src/app/petFlashStore.ts @@ -0,0 +1,16 @@ +import { atom } from 'nanostores' + +import type { PetState } from './usePet.js' + +interface PetFlash { + state: PetState + until: number +} + +// Transient reaction beats (wave/jump/failed) the pet shows for a moment at +// turn end before falling back to its steady state. The gateway event handler +// sets these; usePet reads them with priority over the derived state. +export const $petFlash = atom(null) + +export const flashPet = (state: PetState, ms = 1600) => + $petFlash.set({ state, until: Date.now() + ms }) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index b716504b353..a8b4b3ca4e1 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -8,6 +8,7 @@ import type { SessionBranchResponse, SessionCompressResponse, SessionUsageResponse, + SlashExecResponse, VoiceToggleResponse } from '../../../gatewayTypes.js' import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js' @@ -340,6 +341,31 @@ export const sessionCommands: SlashCommand[] = [ } }, + { + help: 'toggle / adopt / resize an animated pet', + name: 'pet', + usage: '/pet [toggle | list | scale | ]', + run: (arg, ctx, cmd) => { + const sub = arg.trim().toLowerCase() + + // Gallery picker — the interactive browse surface. + if (sub === 'list') { + return patchOverlayState({ petPicker: true }) + } + + // Bare /pet and /pet toggle flip display.pet.enabled via the slash worker. + ctx.gateway.gw + .request('slash.exec', { command: cmd.slice(1), session_id: ctx.sid }) + .then( + ctx.guarded(r => { + const body = r.output || '/pet: no output' + ctx.transcript.sys(r.warning ? `warning: ${r.warning}\n${body}` : body) + }) + ) + .catch(ctx.guardedErr) + } + }, + { help: 'switch theme skin (fires skin.changed)', name: 'skin', @@ -553,6 +579,7 @@ export const sessionCommands: SlashCommand[] = [ // even with zero API calls or on a resumed session. Render it whenever // present, before the token panel. const creditsLines = r?.credits_lines ?? [] + if (creditsLines.length) { ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }]) } @@ -561,6 +588,7 @@ export const sessionCommands: SlashCommand[] = [ if (!creditsLines.length) { ctx.transcript.sys('no API calls yet') } + return } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 20d3493f547..f765322163d 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -147,6 +147,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return patchOverlayState({ modelPicker: false }) } + if (overlay.petPicker) { + return patchOverlayState({ petPicker: false }) + } + if (overlay.billing) { return patchOverlayState({ billing: null }) } diff --git a/ui-tui/src/app/usePet.ts b/ui-tui/src/app/usePet.ts new file mode 100644 index 00000000000..8943c1ae7f0 --- /dev/null +++ b/ui-tui/src/app/usePet.ts @@ -0,0 +1,313 @@ +import { useStdout } from '@hermes/ink' +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { PetGrid } from '../components/petSprite.js' + +import { useGateway } from './gatewayContext.js' +import { getOverlayState, $overlayState } from './overlayStore.js' +import { $petFlash } from './petFlashStore.js' +import { $turnState } from './turnStore.js' +import { $uiState } from './uiStore.js' + +export type PetState = 'idle' | 'wave' | 'run' | 'failed' | 'review' | 'jump' | 'waiting' + +interface PetActivity { + busy: boolean + toolRunning: boolean + reasoning: boolean + awaitingInput: boolean +} + +/** + * Resolve the animation state — mirrors `agent.pet.state.derive_pet_state` + * (and the desktop's `derivePetState`) so all surfaces agree. `awaitingInput` + * (a clarify/approval blocking on the user) outranks the in-flight signals + * because the turn is paused on you, not working. + */ +export function derivePetState({ busy, toolRunning, reasoning, awaitingInput }: PetActivity): PetState { + if (awaitingInput) { + return 'waiting' + } + + if (toolRunning) { + return 'run' + } + + if (reasoning) { + return 'review' + } + + if (busy) { + return 'run' + } + + return 'idle' +} + +// The overlays that mean "the agent is blocked on the user" (vs. user-toggled +// pickers like model/sessions, which aren't the agent waiting). +function isAwaitingInput(): boolean { + const o = getOverlayState() + + return Boolean(o.clarify || o.approval || o.sudo || o.secret || o.confirm) +} + +// A kitty Unicode-placeholder frame set: a static placeholder grid (painted by +// Ink in the image-id color) plus per-frame transmit escapes written straight +// to the terminal out-of-band. +interface KittyView { + color: string + placeholder: string[] +} + +interface PetCellsResult { + color?: string + enabled?: boolean + frameMs?: number + // unicode mode: cell grids; kitty mode: transmit-escape strings. + frames?: PetGrid[] | string[] + graphics?: string + imageId?: number + placeholder?: string[] + scale?: number + slug?: string + state?: string +} + +type CacheEntry = + | { kind: 'cells'; frameMs: number; frames: PetGrid[] } + | { kind: 'kitty'; frameMs: number; frames: string[]; placeholder: string[]; color: string } + +const FRAME_MS = 160 +const POLL_MS = 2500 + +// Only the standalone TUI owns a real terminal it can splat image escapes into; +// when piped (or running under the dashboard PTY the gateway resolves to +// half-blocks anyway) we never ask for graphics. +const IS_TTY = Boolean(process.stdout?.isTTY) + +export interface PetRender { + enabled: boolean + grid: PetGrid | null + kitty: KittyView | null +} + +/** + * Drives the TUI pet. Fetches each (slug, state)'s frames via the `pet.cells` + * RPC (cached) and animates the frame index. Two render paths: + * + * - **kitty** (Ghostty/kitty): the engine returns a static placeholder grid + + * per-frame transmit escapes. We paint the placeholder with Ink and write the + * current frame's escape to the terminal out-of-band, so the image animates + * underneath without Ink ever repainting. + * - **cells** (everywhere else): truecolor half-block grids painted by Ink. + * + * A steady poll keeps it reactive to config changes made elsewhere (`/pet`, the + * picker, `hermes pets select`) so adopting/switching/disabling takes effect + * live. The frame cache is keyed by `slug:state` so a switch re-pulls cleanly. + */ +export function usePet(): PetRender { + const { rpc } = useGateway() + const { write } = useStdout() + const [enabled, setEnabled] = useState(false) + const [grid, setGrid] = useState(null) + const [kitty, setKitty] = useState(null) + + const cache = useRef>(new Map()) + const slugRef = useRef('') + const scaleRef = useRef(0) + const imageIdRef = useRef(0) + const stateRef = useRef('idle') + const frameRef = useRef(0) + + const [petState, setPetState] = useState('idle') + + // Recompute the desired state on every turn/ui/flash change. A transient + // flash (wave/jump/failed) wins until it expires; a timer re-runs at expiry. + useEffect(() => { + let expiry: ReturnType | undefined + + const apply = (next: PetState) => { + if (next !== stateRef.current) { + stateRef.current = next + frameRef.current = 0 + setPetState(next) + } + } + + const recompute = () => { + clearTimeout(expiry) + + const flash = $petFlash.get() + const now = Date.now() + + if (flash && now < flash.until) { + apply(flash.state) + expiry = setTimeout(recompute, flash.until - now) + + return + } + + const turn = $turnState.get() + const ui = $uiState.get() + + apply( + derivePetState({ + awaitingInput: isAwaitingInput(), + busy: ui.busy, + reasoning: turn.reasoningActive, + toolRunning: turn.tools.length > 0 + }) + ) + } + + recompute() + const unsubTurn = $turnState.listen(recompute) + const unsubUi = $uiState.listen(recompute) + const unsubFlash = $petFlash.listen(recompute) + const unsubOverlay = $overlayState.listen(recompute) + + return () => { + clearTimeout(expiry) + unsubTurn() + unsubUi() + unsubFlash() + unsubOverlay() + } + }, []) + + // Free the terminal-side image when the pet goes away or the hook unmounts. + const releaseKitty = useCallback(() => { + if (imageIdRef.current) { + try { + write(`\x1b_Ga=d,d=i,i=${imageIdRef.current},q=2\x1b\\`) + } catch { + // best-effort cleanup + } + + imageIdRef.current = 0 + } + }, [write]) + + // Fetch + cache one (slug, state). `pet.cells` resolves the active pet from + // config, so its `slug`/`enabled` are the source of truth. + const sync = useCallback( + async (state: PetState) => { + try { + const res = (await rpc('pet.cells', { graphics: IS_TTY, state })) as PetCellsResult | null + + if (!res) { + return + } + + if (!res.enabled) { + releaseKitty() + slugRef.current = '' + cache.current.clear() + setGrid(null) + setKitty(null) + setEnabled(false) + + return + } + + const slug = res.slug ?? '' + const scale = res.scale ?? 0 + + // A switch OR a live `/pet scale` change invalidates the cached frames + // (they're rendered at the old size), so the steady poll repaints at the + // new scale without a restart. + if (slug !== slugRef.current || (scale > 0 && scale !== scaleRef.current)) { + releaseKitty() + slugRef.current = slug + scaleRef.current = scale + cache.current.clear() + frameRef.current = 0 + } + + if (res.graphics === 'kitty' && res.frames?.length && res.placeholder?.length) { + imageIdRef.current = res.imageId ?? 0 + cache.current.set(`${slug}:${state}`, { + color: res.color ?? '#000001', + frameMs: res.frameMs ?? FRAME_MS, + frames: res.frames as string[], + kind: 'kitty', + placeholder: res.placeholder + }) + } else if (res.frames?.length) { + cache.current.set(`${slug}:${state}`, { + frameMs: res.frameMs ?? FRAME_MS, + frames: res.frames as PetGrid[], + kind: 'cells' + }) + } + + setEnabled(true) + } catch { + // cosmetic — ignore RPC failures + } + }, + [rpc, releaseKitty] + ) + + // Pull frames whenever the state changes (if not already cached for the + // active pet), plus a steady poll that catches adopt/switch/disable. + useEffect(() => { + if (!cache.current.has(`${slugRef.current}:${petState}`)) { + void sync(petState) + } + + const timer = setInterval(() => void sync(stateRef.current), POLL_MS) + + return () => clearInterval(timer) + }, [petState, sync]) + + useEffect(() => releaseKitty, [releaseKitty]) + + // Animation timer. + useEffect(() => { + if (!enabled) { + return + } + + const tick = () => { + const entry = cache.current.get(`${slugRef.current}:${stateRef.current}`) + + if (!entry?.frames.length) { + return // keep the last frame painted while the new state loads + } + + const idx = frameRef.current % entry.frames.length + frameRef.current = idx + 1 + + if (entry.kind === 'kitty') { + // Transmit this frame's image under the shared id; the static + // placeholder cells (set below) render it. No Ink repaint needed. + try { + write(entry.frames[idx] ?? '') + } catch { + // ignore transmit failures + } + + setGrid(null) + setKitty(prev => + prev && prev.color === entry.color && prev.placeholder === entry.placeholder + ? prev + : { color: entry.color, placeholder: entry.placeholder } + ) + + return + } + + setKitty(null) + setGrid(entry.frames[idx] ?? null) + } + + tick() + const interval = setInterval(tick, FRAME_MS) + + return () => clearInterval(interval) + }, [enabled, petState, write]) + + return { enabled, grid, kitty } +} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d54f5c6da90..7fa5dec1886 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -6,6 +6,7 @@ import { useGateway } from '../app/gatewayContext.js' import type { AppLayoutProps } from '../app/interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' +import { usePet } from '../app/usePet.js' import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE } from '../config/env.js' import { PLACEHOLDER } from '../content/placeholders.js' import { prevRenderedMsg } from '../domain/blockLayout.js' @@ -25,10 +26,29 @@ import { Banner, Panel, SessionPanel } from './branding.js' import { FpsOverlay } from './fpsOverlay.js' import { HelpHint } from './helpHint.js' import { MessageLine } from './messageLine.js' +import { PetKitty, PetSprite } from './petSprite.js' import { QueuedMessages } from './queuedMessages.js' import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' import { TextInput, type TextInputMouseApi } from './textInput.js' +// Petdex mascot — sits just above the composer, right-aligned. Renders +// nothing unless a pet is installed + enabled (`hermes pets select `), +// so it's a no-op for everyone else. +const PetPane = memo(function PetPane() { + const { enabled, grid, kitty } = usePet() + + if (!enabled || (!grid && !kitty)) { + return null + } + + return ( + + {kitty ? : null} + {!kitty && grid ? : null} + + ) +}) + const PromptPrefix = memo(function PromptPrefix({ bold = false, color, @@ -420,6 +440,8 @@ export const AppLayout = memo(function AppLayout({ {!overlay.agents && ( <> + + )} + {overlay.petPicker && ( + + patchOverlayState({ petPicker: false })} t={theme} /> + + )} + {overlay.skillsHub && ( patchOverlayState({ skillsHub: false })} t={theme} /> diff --git a/ui-tui/src/components/petPicker.tsx b/ui-tui/src/components/petPicker.tsx new file mode 100644 index 00000000000..dacb553082e --- /dev/null +++ b/ui-tui/src/components/petPicker.tsx @@ -0,0 +1,183 @@ +import { Box, Text, useInput, useStdout } from '@hermes/ink' +import { useEffect, useMemo, useState } from 'react' + +import type { GatewayClient } from '../gatewayClient.js' +import { rpcErrorMessage } from '../lib/rpc.js' +import type { Theme } from '../theme.js' + +import { OverlayHint, windowItems } from './overlayControls.js' + +const VISIBLE = 10 +const MIN_WIDTH = 40 +const MAX_WIDTH = 90 + +interface GalleryPet { + slug: string + displayName: string + installed: boolean + curated?: boolean +} + +interface Gallery { + enabled: boolean + active: string + pets: GalleryPet[] +} + +/** + * Interactive petdex picker overlay. Pulls the gallery via `pet.gallery`, + * filters as you type, and adopts the highlighted pet with `pet.select` + * (install-on-demand). The mascot lights up live once `usePet` next polls — + * no restart. This is the interactive sibling of the text `/pet ` path. + */ +export function PetPicker({ gw, onClose, t }: PetPickerProps) { + const [gallery, setGallery] = useState(null) + const [query, setQuery] = useState('') + const [idx, setIdx] = useState(0) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState('') + const [loading, setLoading] = useState(true) + + const { stdout } = useStdout() + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + + useEffect(() => { + gw.request('pet.gallery') + .then(r => { + setGallery(r) + setErr('') + }) + .catch((e: unknown) => setErr(rpcErrorMessage(e))) + .finally(() => setLoading(false)) + }, [gw]) + + const enabled = gallery?.enabled ?? false + const active = gallery?.active ?? '' + + // Rank by the signals petdex gives us — active, then installed, then curated + // (its official set), then the rest — and hide the clawd placeholders. + const view = useMemo(() => { + const pets = (gallery?.pets ?? []).filter(p => !/^clawd(-|$)/i.test(p.slug)) + const needle = query.trim().toLowerCase() + + const matched = needle + ? pets.filter(p => p.slug.toLowerCase().includes(needle) || p.displayName.toLowerCase().includes(needle)) + : pets + + const rank = (p: GalleryPet) => (enabled && p.slug === active ? 4 : 0) + (p.installed ? 2 : 0) + (p.curated ? 1 : 0) + + return [...matched].sort((a, b) => rank(b) - rank(a)) + }, [gallery, query, enabled, active]) + + const adopt = (slug: string) => { + setBusy(true) + setErr('') + gw.request('pet.select', { slug }) + .then(() => onClose()) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) + setBusy(false) + }) + } + + useInput((input, key) => { + if (busy) { + return + } + + if (key.escape) { + return onClose() + } + + if (key.upArrow) { + return setIdx(i => Math.max(0, i - 1)) + } + + if (key.downArrow) { + return setIdx(i => Math.min(view.length - 1, i + 1)) + } + + if (key.return) { + const pet = view[idx] + + return pet ? adopt(pet.slug) : undefined + } + + if (key.backspace || key.delete) { + setQuery(q => q.slice(0, -1)) + + return setIdx(0) + } + + // Printable char → extend the filter (ignore control/chorded keys). + if (input && input.length === 1 && input >= ' ' && !key.ctrl && !key.meta) { + setQuery(q => q + input) + setIdx(0) + } + }) + + if (loading) { + return loading pets… + } + + if (err && !gallery) { + return ( + + error: {err} + Esc cancel + + ) + } + + const { items, offset } = windowItems(view, idx, VISIBLE) + + return ( + + + Pets + + + + {query ? `filter: ${query}` : 'type to filter'} · {view.length} pet{view.length === 1 ? '' : 's'} + + + {offset > 0 && ↑ {offset} more} + + {view.length === 0 ? ( + {query ? `no pets match "${query}"` : 'no pets available'} + ) : ( + items.map((pet, i) => { + const at = offset + i === idx + const isActive = enabled && pet.slug === active + const mark = isActive ? '●' : pet.installed ? '✓' : ' ' + const tag = pet.installed ? '' : pet.curated ? ' · official' : '' + + return ( + + {at ? '▸ ' : ' '} + {mark} {pet.displayName} + + {' '} + ({pet.slug} + {tag}) + + + ) + }) + )} + + {offset + VISIBLE < view.length && ↓ {view.length - offset - VISIBLE} more} + + {err ? error: {err} : null} + {busy ? adopting… : null} + + ↑/↓ select · Enter adopt · type to filter · Esc cancel + + ) +} + +interface PetPickerProps { + gw: GatewayClient + onClose: () => void + t: Theme +} diff --git a/ui-tui/src/components/petSprite.tsx b/ui-tui/src/components/petSprite.tsx new file mode 100644 index 00000000000..5a17f6337d4 --- /dev/null +++ b/ui-tui/src/components/petSprite.tsx @@ -0,0 +1,93 @@ +import { Box, Text } from '@hermes/ink' +import { memo } from 'react' + +// A cell is [tr,tg,tb,ta, br,bg,bb,ba] — the top + bottom pixel of one +// half-block, as produced by the `pet.cells` gateway RPC. +export type PetCell = number[] +export type PetGrid = PetCell[][] + +const UPPER_HALF = '▀' +const LOWER_HALF = '▄' + +const hex = (r: number, g: number, b: number) => + `#${[r, g, b].map(v => Math.max(0, Math.min(255, v | 0)).toString(16).padStart(2, '0')).join('')}` + +/** + * Renders one petdex frame as truecolor half-blocks using native Ink color + * props (no raw ANSI, so width measurement stays correct). The engine + * (`agent/pet/render.py`) does the decode + downscale; this is a thin painter. + */ +export const PetSprite = memo(function PetSprite({ grid }: { grid: PetGrid }) { + if (!grid.length) { + return null + } + + return ( + + {grid.map((row, y) => ( + + {row.map((cell, x) => { + const [tr, tg, tb, ta, br, bg, bb, ba] = cell + const top = (ta ?? 0) >= 32 + const bot = (ba ?? 0) >= 32 + + if (!top && !bot) { + return + } + + // Both halves opaque → fg=top over bg=bottom. One half opaque → + // draw it fg-only so the other stays the terminal bg (no black + // boxes bleeding around transparent sprite edges). + if (top && bot) { + return ( + + {UPPER_HALF} + + ) + } + + return top ? ( + + {UPPER_HALF} + + ) : ( + + {LOWER_HALF} + + ) + })} + + ))} + + ) +}) + +/** + * Renders a kitty Unicode-placeholder grid: each line is a row of U+10EEEE + * cells whose foreground color encodes the image id. The actual pixels are + * drawn by the terminal (the frame image is transmitted out-of-band by + * `usePet`); this only emits the placeholder text Ink can measure as width-1 + * cells. Truecolor-only — the color must reach the terminal verbatim for the + * id to decode, which Ghostty/kitty support. + */ +export const PetKitty = memo(function PetKitty({ + color, + placeholder +}: { + color: string + placeholder: string[] +}) { + if (!placeholder.length) { + return null + } + + return ( + + {placeholder.map((row, y) => ( + + {row} + + ))} + + ) +}) diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index ca2a05dc449..034e04cf6da 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -156,7 +156,10 @@ declare module '@hermes/ink' { readonly setSelectionBgColor: (color: string) => void } export function useHasSelection(): boolean - export function useStdout(): { readonly stdout?: NodeJS.WriteStream } + export function useStdout(): { + readonly stdout?: NodeJS.WriteStream + readonly write: (data: string) => boolean + } export function useTerminalFocus(): boolean export function useTerminalTitle(title: string | null): void export function useDeclaredCursor(args: { From 86b990fe0fac40a54294fa0b5d02c8e055bc36ec Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 14:18:40 -0500 Subject: [PATCH 004/244] feat(desktop): floating pet, pop-out overlay + Cmd+K picker Add the in-window floating pet (sprite, speech bubble, contact shadow, profile-scoped, resize-safe) and a pop-out always-on-top overlay window with gestures and notifications. Add the Cmd+K pet picker page plus the appearance gallery and size slider in settings. Includes the pet stores, electron overlay wiring, i18n strings, and store tests. --- apps/desktop/electron/main.cjs | 255 ++++++++++ apps/desktop/electron/preload.cjs | 26 ++ .../desktop/src/app/command-palette/index.tsx | 98 ++-- .../app/command-palette/pet-palette-page.tsx | 185 ++++++++ apps/desktop/src/app/desktop-controller.tsx | 50 ++ .../src/app/pet-overlay/overlay-root.tsx | 38 ++ .../src/app/pet-overlay/pet-overlay-app.tsx | 345 ++++++++++++++ .../app/session/hooks/use-message-stream.ts | 37 ++ .../app/session/hooks/use-prompt-actions.ts | 34 +- .../src/app/settings/appearance-settings.tsx | 440 ++++++++++++------ .../desktop/src/app/settings/pet-settings.tsx | 231 +++++++++ apps/desktop/src/app/shell/app-shell.tsx | 5 + .../src/components/pet/floating-pet.tsx | 313 +++++++++++++ .../desktop/src/components/pet/pet-bubble.tsx | 142 ++++++ .../desktop/src/components/pet/pet-sprite.tsx | 178 +++++++ apps/desktop/src/components/pet/pet-thumb.tsx | 79 ++++ apps/desktop/src/components/ui/command.tsx | 8 +- apps/desktop/src/global.d.ts | 21 + apps/desktop/src/i18n/en.ts | 43 +- apps/desktop/src/i18n/ja.ts | 43 +- apps/desktop/src/i18n/types.ts | 37 ++ apps/desktop/src/i18n/zh-hant.ts | 41 +- apps/desktop/src/i18n/zh.ts | 41 +- .../src/lib/desktop-slash-commands.test.ts | 10 + .../desktop/src/lib/desktop-slash-commands.ts | 4 +- apps/desktop/src/lib/icons.ts | 4 + apps/desktop/src/lib/selectable-card.ts | 31 ++ apps/desktop/src/main.tsx | 41 +- apps/desktop/src/store/command-palette.ts | 14 + apps/desktop/src/store/pet-gallery.ts | 322 +++++++++++++ apps/desktop/src/store/pet-overlay.ts | 260 +++++++++++ apps/desktop/src/store/pet.test.ts | 48 ++ apps/desktop/src/store/pet.ts | 160 +++++++ 33 files changed, 3367 insertions(+), 217 deletions(-) create mode 100644 apps/desktop/src/app/command-palette/pet-palette-page.tsx create mode 100644 apps/desktop/src/app/pet-overlay/overlay-root.tsx create mode 100644 apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx create mode 100644 apps/desktop/src/app/settings/pet-settings.tsx create mode 100644 apps/desktop/src/components/pet/floating-pet.tsx create mode 100644 apps/desktop/src/components/pet/pet-bubble.tsx create mode 100644 apps/desktop/src/components/pet/pet-sprite.tsx create mode 100644 apps/desktop/src/components/pet/pet-thumb.tsx create mode 100644 apps/desktop/src/lib/selectable-card.ts create mode 100644 apps/desktop/src/store/pet-gallery.ts create mode 100644 apps/desktop/src/store/pet-overlay.ts create mode 100644 apps/desktop/src/store/pet.test.ts create mode 100644 apps/desktop/src/store/pet.ts diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index be89c6c91cf..42f81c38123 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -5154,6 +5154,142 @@ function createNewSessionWindow() { return spawnSecondaryWindow({ newSession: true }) } +// The pet overlay: a single transparent, frameless, always-on-top window that +// hosts ONLY the floating mascot. Shift-clicking the in-window pet "pops it out" +// here so it can leave the app's bounds and stay visible while Hermes is +// minimized (Codex-style task-completion glance). It carries no gateway +// connection of its own — the main renderer is the single source of truth and +// pushes pet state over IPC (hermes:pet-overlay:state); the overlay just renders +// it. Control flows back (pop-in, composer submit) via hermes:pet-overlay:control. +let petOverlayWindow = null + +function petOverlayUrl() { + if (DEV_SERVER) { + return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/?win=overlay#/` + } + + return `${pathToFileURL(resolveRendererIndex()).toString()}?win=overlay#/` +} + +function spawnPetOverlayWindow(bounds) { + const win = new BrowserWindow({ + width: Math.max(80, Math.round(bounds?.width || 220)), + height: Math.max(80, Math.round(bounds?.height || 220)), + x: Number.isFinite(bounds?.x) ? Math.round(bounds.x) : undefined, + y: Number.isFinite(bounds?.y) ? Math.round(bounds.y) : undefined, + frame: false, + transparent: true, + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + // Windows/Linux need this so the helper window does not get its own + // taskbar/alt-tab entry. On macOS, cmd-tab is app-level and this can make + // the whole app look like it vanished when the only newly-created visible + // window is a frameless overlay. Use NSPanel + Mission Control hiding below + // instead, leaving the main Hermes app as the Dock/cmd-tab anchor. + skipTaskbar: !IS_MAC, + hasShadow: false, + alwaysOnTop: true, + // macOS panels are non-activating helper windows and can float over full + // screen spaces without becoming the app's main switcher window. + type: IS_MAC ? 'panel' : undefined, + hiddenInMissionControl: IS_MAC, + // Non-activating: the overlay must never become the app's key/main window, + // or it (a frameless, taskbar-skipping panel) becomes the app's switcher + // anchor and the Hermes icon drops out of cmd/alt-tab — especially when the + // main window is minimized. We flip this on only while the composer needs + // the keyboard (see hermes:pet-overlay:set-focusable). + focusable: false, + show: false, + // Fully transparent — the renderer paints only the sprite + bubble. + backgroundColor: '#00000000', + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + devTools: true, + // Keep the sprite animating + bubble updating while the main window is + // minimized/blurred — the whole point of the overlay. + backgroundThrottling: false + } + }) + + // Float above other apps and follow the user across desktops so the pet is + // always reachable. `floating` + `type: panel` is the macOS NSPanel path; the + // more aggressive `screen-saver` level can interfere with normal app/window + // switching semantics. + win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') + win.setHiddenInMissionControl?.(true) + try { + // Electron docs: macOS may transform process type on each + // setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true, + // which briefly hides the Dock/cmd-tab presence. Keep Hermes in the normal + // ForegroundApplication class so shift-clicking the pet never drops the app + // out of app switchers. + win.setVisibleOnAllWorkspaces( + true, + IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined + ) + } catch { + // Not supported everywhere — best effort. + } + + wireCommonWindowHandlers(win) + + win.once('ready-to-show', () => { + if (!win.isDestroyed()) win.showInactive() + }) + + win.on('closed', () => { + if (petOverlayWindow === win) { + petOverlayWindow = null + } + + // If the overlay went away on its own (e.g. ⌘W), tell the main renderer to + // pop the pet back in so it doesn't stay hidden. Harmless echo when we're + // the ones who closed it (popInPet already cleared the active flag). + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('hermes:pet-overlay:control', { type: 'pop-in' }) + } + }) + + win.loadURL(petOverlayUrl()) + + return win +} + +function openPetOverlay(bounds) { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + if (bounds) { + petOverlayWindow.setBounds({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.max(80, Math.round(bounds.width)), + height: Math.max(80, Math.round(bounds.height)) + }) + } + + petOverlayWindow.showInactive() + + return petOverlayWindow + } + + petOverlayWindow = spawnPetOverlayWindow(bounds) + + return petOverlayWindow +} + +function closePetOverlay() { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.close() + } + + petOverlayWindow = null +} + function createWindow() { const icon = getAppIconPath() mainWindow = new BrowserWindow({ @@ -5211,6 +5347,11 @@ function createWindow() { mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false)) + // The overlay rides the main window — closing the app's primary window must + // tear it down too (otherwise it strands as an orphan that blocks + // window-all-closed from quitting on Windows/Linux). + mainWindow.on('closed', () => closePetOverlay()) + wireCommonWindowHandlers(mainWindow) mainWindow.webContents.on('render-process-gone', (_event, details) => { @@ -5331,6 +5472,116 @@ ipcMain.handle('hermes:window:openNewSession', async () => { return { ok: true } }) + +// --- Pet overlay (pop-out mascot) ----------------------------------------- +// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space +// bounds (screen=false): convert to screen space by adding the main window's +// content origin so the pet lands where it sat in-window. A remembered/dragged +// spot passes screen-space bounds (screen=true) and is used as-is. We return the +// resolved screen bounds so the renderer can persist exactly where it opened. +ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => { + const bounds = request && request.bounds ? request.bounds : request + const isScreen = Boolean(request && request.screen) + let screenBounds = bounds + + try { + if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) { + const content = mainWindow.getContentBounds() + screenBounds = { + x: content.x + (bounds.x || 0), + y: content.y + (bounds.y || 0), + width: bounds.width, + height: bounds.height + } + } + } catch { + // Fall back to raw bounds if the window geometry is unavailable. + } + + openPetOverlay(screenBounds) + + return { ok: true, bounds: screenBounds } +}) +ipcMain.handle('hermes:pet-overlay:close', async () => { + closePetOverlay() + + return { ok: true } +}) +// Drag: the overlay reports a new absolute screen position (it already knows the +// pointer's screen coords), we just move the window. +ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => { + if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) { + return + } + + petOverlayWindow.setBounds({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.max(80, Math.round(bounds.width)), + height: Math.max(80, Math.round(bounds.height)) + }) +}) +// Click-through: the overlay window is a full rectangle but only the pet pixels +// should be interactive. The renderer toggles this as the cursor enters/leaves +// the sprite so transparent margins pass clicks to whatever is behind. +ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } +}) +// The overlay is a non-activating panel (focusable:false) so it never steals +// the app's cmd/alt-tab anchor from the main window. But the pop-up composer +// needs the keyboard, so the renderer asks us to flip it focusable + focus it +// while the composer is open, then back to non-activating when it closes. +ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { + if (!petOverlayWindow || petOverlayWindow.isDestroyed()) { + return + } + + petOverlayWindow.setFocusable(Boolean(focusable)) + if (focusable) { + petOverlayWindow.focus() + } +}) +// Main renderer → overlay: forward the latest pet state for the overlay to render. +ipcMain.on('hermes:pet-overlay:state', (_event, payload) => { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload) + } +}) +// Overlay → main renderer: control messages (pop back in, composer submit). +ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + // Double-click toggles the app window: hide it away if it's up front, bring it + // back if it's minimized/buried. Pure window control — nothing for the + // renderer to do, so don't forward it. + if (payload && payload.type === 'toggle-app') { + if (mainWindow.isMinimized() || !mainWindow.isVisible()) { + mainWindow.show() + mainWindow.focus() + } else { + mainWindow.minimize() + } + + return + } + + // The mail icon means "take me to the app": raise the main window (it may be + // minimized or buried) before the renderer navigates to the latest thread. + if (payload && payload.type === 'open-app') { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + + mainWindow.show() + mainWindow.focus() + } + + mainWindow.webContents.send('hermes:pet-overlay:control', payload) +}) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the @@ -6535,6 +6786,10 @@ function configureSpellChecker() { } app.on('before-quit', () => { + // The always-on-top overlay isn't a "real" app window; close it so a stray + // pet can't keep the process alive or float over a quit app. + closePetOverlay() + // Quitting mid-install should stop the installer, not orphan it. if (bootstrapAbortController) { try { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 413abd77b32..93620facdf4 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -7,6 +7,32 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'), + petOverlay: { + // Main renderer → main process: window lifecycle + drag. `request` is + // `{ bounds, screen }`; resolves with the screen bounds it actually used. + open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request), + close: () => ipcRenderer.invoke('hermes:pet-overlay:close'), + setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds), + setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore), + // Flip the overlay focusable (and focus it) while the composer needs keys. + setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable), + // Main renderer → overlay (forwarded by main): push the latest pet state. + pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload), + // Overlay → main renderer (forwarded by main): pop back in / composer submit. + control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload), + // Overlay subscribes to state pushes. + onState: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:pet-overlay:state', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener) + }, + // Main renderer subscribes to overlay control messages. + onControl: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:pet-overlay:control', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) + } + }, getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 19ea7976344..d91a6c92756 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -29,6 +29,7 @@ import { Moon, Package, Palette, + PawPrint, Plus, Settings, Settings2, @@ -39,7 +40,7 @@ import { Zap } from '@/lib/icons' import { cn } from '@/lib/utils' -import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' +import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' import { $bindings } from '@/store/keybinds' import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' @@ -62,6 +63,7 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy' import { prettyName } from '../settings/helpers' import { MarketplaceThemePage } from './marketplace-theme-page' +import { PetInlineToggle, PetPalettePage } from './pet-palette-page' interface PaletteItem { /** Keybind action id — its live combo renders as a hotkey hint. */ @@ -205,6 +207,7 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean { export function CommandPalette() { const { t } = useI18n() const open = useStore($commandPaletteOpen) + const pendingPage = useStore($commandPalettePage) const bindings = useStore($bindings) const navigate = useNavigate() const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme() @@ -250,6 +253,14 @@ export function CommandPalette() { } }, [open]) + // Deep-link into a nested page (e.g. `/pet list` → pets picker). + useEffect(() => { + if (open && pendingPage) { + setPage(pendingPage) + $commandPalettePage.set(null) + } + }, [open, pendingPage]) + const go = useCallback((path: string) => () => navigate(path), [navigate]) // Step up one nested page (or back to the root list), clearing the filter so @@ -382,6 +393,13 @@ export function CommandPalette() { keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'], label: cc.changeColorMode, to: 'color-mode' + }, + { + icon: PawPrint, + id: 'appearance-pets', + keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'], + label: cc.pets.title, + to: 'pets' } ] }, @@ -550,6 +568,12 @@ export function CommandPalette() { } ] }, + // Server-driven page: browse petdex gallery, adopt/switch, toggle off. + pets: { + title: t.commandCenter.pets.title, + placeholder: t.commandCenter.pets.placeholder, + groups: [] + }, // Server-driven page: items come from the Marketplace, rendered by // (loader + live search + per-row install). 'install-theme': { @@ -624,45 +648,51 @@ export function CommandPalette() { }} onValueChange={setSearch} placeholder={placeholder} + right={page === 'pets' ? : undefined} value={search} /> - {page === 'install-theme' ? ( + {/* Server-driven pages render their own list; the rest show groups. */} + {page === 'pets' ? ( + + ) : page === 'install-theme' ? ( ) : ( - {t.commandCenter.noResults} - )} - {visibleGroups.map((group, index) => ( - - {group.items.map(item => { - const Icon = item.icon - const combo = item.action ? bindings[item.action]?.[0] : undefined + <> + {t.commandCenter.noResults} + {visibleGroups.map((group, index) => ( + + {group.items.map(item => { + const Icon = item.icon + const combo = item.action ? bindings[item.action]?.[0] : undefined - return ( - handleSelect(item)} - value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`} - > - - {item.label} - {combo && } - {item.to && ( - - )} - - ) - })} - - ))} + return ( + handleSelect(item)} + value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`} + > + + {item.label} + {combo && } + {item.to && ( + + )} + + ) + })} + + ))} + + )} diff --git a/apps/desktop/src/app/command-palette/pet-palette-page.tsx b/apps/desktop/src/app/command-palette/pet-palette-page.tsx new file mode 100644 index 00000000000..891637c67cb --- /dev/null +++ b/apps/desktop/src/app/command-palette/pet-palette-page.tsx @@ -0,0 +1,185 @@ +/** + * Cmd-K "Pets…" page — browse the petdex gallery, adopt/switch, toggle off. + * + * A thin view over the `pet-gallery` store: it subscribes to the shared atoms + * and calls the store's actions. The store owns fetching, caching, the thumb + * cache, and optimistic mutations, so reopening this page is instant and a + * toggle never re-pulls the network gallery. + */ + +import { useStore } from '@nanostores/react' +import { useEffect, useMemo } from 'react' + +import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud' +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { PetThumb } from '@/components/pet/pet-thumb' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Check, Loader2, PawPrint } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $petBusy, + $petGallery, + $petGalleryError, + $petGalleryStatus, + adoptPet, + loadPetGallery, + loadPetThumb, + rankedGalleryPets, + setPetEnabled +} from '@/store/pet-gallery' + +interface PetPalettePageProps { + search: string +} + +export function PetPalettePage({ search }: PetPalettePageProps) { + const { t } = useI18n() + const copy = t.commandCenter.pets + const { requestGateway } = useGatewayRequest() + + const gallery = useStore($petGallery) + const status = useStore($petGalleryStatus) + const error = useStore($petGalleryError) + const busy = useStore($petBusy) + + useEffect(() => { + void loadPetGallery(requestGateway) + }, [requestGateway]) + + const enabled = gallery?.enabled ?? false + const active = gallery?.active ?? '' + + const shown = useMemo(() => rankedGalleryPets(gallery, search).slice(0, 50), [gallery, search]) + + const adopt = (slug: string) => { + void adoptPet(requestGateway, slug, copy.adoptFailed).then(ok => ok && triggerHaptic('crisp')) + } + + if (status === 'loading' && !gallery) { + return } text={copy.loading} /> + } + + if (status === 'stale') { + return + } + + if (!gallery?.pets.length && error) { + return + } + + const mutating = Boolean(busy) + + return ( +
+ {error &&

{error}

} + + {shown.length === 0 ? ( + + ) : ( + shown.map(pet => { + const isActive = enabled && pet.slug === active + const isBusy = busy === pet.slug + + return ( + + ) + }) + )} +
+ ) +} + +/** + * Single on/off toggle, rendered inline on the palette's search row (see + * `CommandInput`'s `right` slot). The paw lights up when pets are on. Reads the + * same shared gallery atoms, so it stays in sync with the list below. + */ +export function PetInlineToggle() { + const { t } = useI18n() + const copy = t.commandCenter.pets + const { requestGateway } = useGatewayRequest() + const gallery = useStore($petGallery) + const busy = useStore($petBusy) + + if (!gallery) { + return null + } + + const enabled = gallery.enabled + + const toggle = () => { + void setPetEnabled(requestGateway, !enabled, { + noneAvailable: copy.noneAvailable, + fallback: copy.toggleFailed + }).then(ok => ok && triggerHaptic('crisp')) + } + + return ( + + ) +} + +function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { + return ( +
+ {icon} + {text} +
+ ) +} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 05dfbbc764f..8ed097e29ee 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -38,6 +38,8 @@ import { unpinSession } from '../store/layout' import { respondToApprovalAction } from '../store/native-notifications' +import { setPetActivity } from '../store/pet' +import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay' import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' import { $activeGatewayProfile, @@ -49,6 +51,7 @@ import { } from '../store/profile' import { $activeSessionId, + $attentionSessionIds, $currentCwd, $freshDraftReady, $gatewayState, @@ -834,6 +837,53 @@ export function DesktopController() { updateSessionState }) + // The popped-out pet drives two actions back into the app: send a prompt, and + // open the most recent thread. Both are registered ONCE through refs that track + // the latest callbacks — re-registering on every `submitText`/`resumeSession` + // identity change left a brief window where the handler was nulled (cleanup + // before re-register), which could drop a submit fired from the overlay (e.g. + // creating a session from the new-session screen). The ref form keeps a stable, + // always-current handler. Primary window only — it owns the overlay. + const submitTextRef = useRef(submitText) + submitTextRef.current = submitText + const resumeSessionRef = useRef(resumeSession) + resumeSessionRef.current = resumeSession + + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + setPetOverlaySubmitHandler(text => void submitTextRef.current(text)) + // Mail icon: $sessions is ordered most-recent-first; the pet is global (not + // per session) so "most recent" is the right target. main.cjs already raised + // the window before forwarding this. + setPetOverlayOpenAppHandler(() => { + const recent = $sessions.get()[0] + + if (recent?.id) { + void resumeSessionRef.current(recent.id) + } + }) + + return () => { + setPetOverlaySubmitHandler(null) + setPetOverlayOpenAppHandler(null) + } + }, []) + + // Mirror "a session is blocked on the user" (clarify/approval) into the pet's + // awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so + // it rides the same atom the pop-out overlay mirrors — no session list needed + // there. Every window keeps its own in-window pet in sync. + useEffect(() => { + const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 }) + + sync() + + return $attentionSessionIds.listen(sync) + }, []) + useGatewayBoot({ handleGatewayEvent: handleDesktopGatewayEvent, onConnectionReady: c => { diff --git a/apps/desktop/src/app/pet-overlay/overlay-root.tsx b/apps/desktop/src/app/pet-overlay/overlay-root.tsx new file mode 100644 index 00000000000..de446bdb6a5 --- /dev/null +++ b/apps/desktop/src/app/pet-overlay/overlay-root.tsx @@ -0,0 +1,38 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import { ErrorBoundary } from '@/components/error-boundary' +import { ThemeProvider } from '@/themes/context' + +import { PetOverlayApp } from './pet-overlay-app' + +/** + * Boot the pet-overlay window. Loaded by the same bundle as the main app but + * via `?win=overlay`, so it shares CSS/atoms while mounting a minimal, transparent + * surface (no app shell, no gateway, no I18n — the bubble strings are inline). + * + * The index.html boot script paints an OPAQUE themed background to avoid a flash + * in normal windows; the overlay must be see-through, so we force every host + * layer transparent with a late, high-specificity style tag. + */ +export function mountPetOverlay(): void { + const style = document.createElement('style') + style.textContent = 'html,body,#root{background:transparent !important;}' + document.head.appendChild(style) + + const root = document.getElementById('root') + + if (!root) { + return + } + + createRoot(root).render( + + + + + + + + ) +} diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx new file mode 100644 index 00000000000..1fcd21169f0 --- /dev/null +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -0,0 +1,345 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef, useState } from 'react' + +import { PetBubble } from '@/components/pet/pet-bubble' +import { PetSprite } from '@/components/pet/pet-sprite' +import { Mail } from '@/lib/icons' +import { $petActivity, $petInfo, setPetInfo } from '@/store/pet' +import { setAwaitingResponse, setBusy } from '@/store/session' + +/** + * The pop-out overlay's only view: a transparent, draggable mascot with a mini + * composer. + * + * This runs in a separate, gateway-less BrowserWindow (`?win=overlay`). It is a + * pure puppet — the main renderer pushes the live pet state over IPC and we + * mirror it into the same atoms the in-window pet reads, so `PetSprite` / + * `PetBubble` render identically with zero extra logic. + * + * The window is a full rectangle but mostly transparent; we toggle OS-level + * mouse click-through so only the sprite (or the open composer) is interactive + * and the empty margins pass clicks through to whatever is behind. + * + * Gestures on the pet: drag to move it anywhere on screen (even outside the + * app), shift-click to pop it back into the window, single-click to open a small + * composer, double-click to toggle the app window (minimize ↔ restore). A mail + * icon (shown only when a turn finished while you were away) raises the app on + * the most recent thread. + */ + +// Below this much pointer travel, a press counts as a click, not a drag. +const CLICK_SLOP_PX = 3 +// A second click within this window is a double-click (raise app) and cancels +// the deferred single-click (open composer), so a double never flashes it open. +const DOUBLE_CLICK_MS = 250 + +interface DragState { + startX: number + startY: number + offX: number + offY: number + width: number + height: number + moved: boolean +} + +export function PetOverlayApp() { + const info = useStore($petInfo) + const [composerOpen, setComposerOpen] = useState(false) + const [draft, setDraft] = useState('') + // Mirrored from the main renderer: a finish landed while you were away. + const [unread, setUnread] = useState(false) + + const dragRef = useRef(null) + const petRef = useRef(null) + const inputRef = useRef(null) + const ignoreRef = useRef(true) + const composerOpenRef = useRef(false) + const clickTimerRef = useRef | undefined>(undefined) + + const setIgnore = (ignore: boolean) => { + if (ignoreRef.current !== ignore) { + ignoreRef.current = ignore + window.hermesDesktop?.petOverlay?.setIgnoreMouse(ignore) + } + } + + // Mirror pushed state into the shared atoms so PetSprite/PetBubble just work. + useEffect(() => { + const off = window.hermesDesktop?.petOverlay?.onState(payload => { + setPetInfo(payload.info) + $petActivity.set(payload.activity ?? {}) + setBusy(Boolean(payload.busy)) + setAwaitingResponse(Boolean(payload.awaiting)) + setUnread(Boolean(payload.unread)) + }) + + // Tell the main renderer we're mounted so it pushes the current frame (the + // subscribe-time pushes during open() can land before this view exists). + window.hermesDesktop?.petOverlay?.control({ type: 'ready' }) + + return off + }, []) + + // Click-through: make only the sprite (or an open composer) interactive. With + // ignore+forward, the renderer still receives mousemove so we can re-enable + // hit-testing the moment the cursor returns to the pet. + useEffect(() => { + setIgnore(true) + + const onMove = (ev: MouseEvent) => { + if (dragRef.current || composerOpenRef.current) { + setIgnore(false) + + return + } + + const el = petRef.current + + if (!el) { + return + } + + const r = el.getBoundingClientRect() + const over = ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom + setIgnore(!over) + } + + window.addEventListener('mousemove', onMove) + + return () => { + window.removeEventListener('mousemove', onMove) + clearTimeout(clickTimerRef.current) + } + }, []) + + // The whole window must stay interactive while the composer is open (so the + // input keeps focus); focus it on open. The overlay is a non-activating panel + // (so it never steals the app's cmd/alt-tab anchor) — flip it focusable while + // the composer needs the keyboard, then back to non-activating when it closes. + useEffect(() => { + composerOpenRef.current = composerOpen + + window.hermesDesktop?.petOverlay?.setFocusable(composerOpen) + + if (composerOpen) { + setIgnore(false) + // The OS window has to become key first (setFocusable + focus happen in + // the main process), so focus the input on the next frame. + requestAnimationFrame(() => inputRef.current?.focus()) + } + }, [composerOpen]) + + const onPetPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) { + return + } + + ;(e.target as Element).setPointerCapture?.(e.pointerId) + dragRef.current = { + height: window.outerHeight, + moved: false, + offX: e.screenX - window.screenX, + offY: e.screenY - window.screenY, + startX: e.screenX, + startY: e.screenY, + width: window.outerWidth + } + } + + const onPetPointerMove = (e: React.PointerEvent) => { + const drag = dragRef.current + + if (!drag) { + return + } + + if (Math.hypot(e.screenX - drag.startX, e.screenY - drag.startY) > CLICK_SLOP_PX) { + drag.moved = true + } + + window.hermesDesktop?.petOverlay?.setBounds({ + height: drag.height, + width: drag.width, + x: e.screenX - drag.offX, + y: e.screenY - drag.offY + }) + } + + const onPetPointerUp = (e: React.PointerEvent) => { + const drag = dragRef.current + dragRef.current = null + ;(e.target as Element).releasePointerCapture?.(e.pointerId) + + if (!drag) { + return + } + + if (drag.moved) { + // A drag cancels any deferred single-click so the composer can't pop open + // after you reposition the pet. + clearTimeout(clickTimerRef.current) + clickTimerRef.current = undefined + + // Remember the spot on the desktop (screen coords) so the pet reopens here + // next time / after a restart. + window.hermesDesktop?.petOverlay?.control({ + bounds: { height: drag.height, width: drag.width, x: e.screenX - drag.offX, y: e.screenY - drag.offY }, + type: 'bounds' + }) + + return + } + + // Shift-click always pops the pet back in (no double-click ambiguity). + if (e.shiftKey) { + window.hermesDesktop?.petOverlay?.control({ type: 'pop-in' }) + + return + } + + // Double-click toggles the app window (minimize ↔ restore); defer the + // single-click composer toggle so a double never flashes the composer open. + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current) + clickTimerRef.current = undefined + window.hermesDesktop?.petOverlay?.control({ type: 'toggle-app' }) + + return + } + + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = undefined + setComposerOpen(open => !open) + }, DOUBLE_CLICK_MS) + } + + const send = () => { + const text = draft.trim() + + if (text) { + window.hermesDesktop?.petOverlay?.control({ text, type: 'submit' }) + } + + setDraft('') + setComposerOpen(false) + } + + const openApp = () => { + // Hide the icon immediately; the main renderer also clears the source flag. + setUnread(false) + window.hermesDesktop?.petOverlay?.control({ type: 'open-app' }) + } + + if (!info.enabled || !info.spritesheetBase64) { + return null + } + + return ( +
{ + // Click on the transparent backdrop (not the pet/composer) dismisses + // the composer. + if (composerOpen && e.target === e.currentTarget) { + setComposerOpen(false) + } + }} + style={{ + alignItems: 'center', + background: 'transparent', + display: 'flex', + flexDirection: 'column', + height: '100vh', + justifyContent: 'flex-end', + paddingBottom: 24, + userSelect: 'none', + width: '100vw' + }} + > + {composerOpen && ( + setDraft(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + send() + } else if (e.key === 'Escape') { + setComposerOpen(false) + } + }} + placeholder="Message…" + ref={inputRef} + style={{ + background: 'var(--ui-bg-elevated)', + border: '1px solid var(--ui-stroke-secondary)', + borderRadius: 2, + boxShadow: '0 6px 18px rgba(0,0,0,0.28)', + color: 'var(--foreground)', + fontSize: 12, + marginBottom: 8, + outline: 'none', + padding: '4px 8px', + width: 184 + }} + value={draft} + /> + )} + +
+
+ +
+
+ + + {/* Mail icon: only when a finish landed while you were away. Jumps to + the app's most recent thread. Anchored to the sprite (kept inside + its box so the overlay's click-through hit-test still catches it); + stopPropagation keeps a click from starting a window drag. */} + {unread && ( + + )} +
+
+
+ ) +} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 909c1424796..9ae7e13976c 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -34,6 +34,7 @@ import { $gateway } from '@/store/gateway' import { dispatchNativeNotification } from '@/store/native-notifications' import { notify } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' +import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet' import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts' import { setCurrentBranch, @@ -870,10 +871,18 @@ export function useMessageStream({ if (sessionId) { appendReasoningDelta(sessionId, coerceThinkingText(payload?.text)) } + + if (isActiveEvent) { + setPetActivity({ reasoning: true }) + } } else if (event.type === 'reasoning.available') { if (sessionId) { appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), true) } + + if (isActiveEvent) { + setPetActivity({ reasoning: true }) + } } else if (event.type === 'message.complete') { if (!sessionId) { return @@ -895,6 +904,20 @@ export function useMessageStream({ if (isActiveEvent) { setTurnStartedAt(null) + + // Pet beat: a finished turn always celebrates — go straight to the + // jump, never linger on the run/reason pose. One atom update (clears + // toolRunning/reasoning AND sets celebrate together) so no stray "run" + // frame leaks to the sprite — including the popped-out overlay, which + // mirrors each activity change. The jump runs ~2 loops, then settles. + flashPetActivity({ celebrate: true, reasoning: false, toolRunning: false }, 2200) + + // Light up the pet's mail icon if the user wasn't looking when the turn + // finished — a glanceable "new message" hint on the popped-out overlay. + // Cleared when they open the app via the mail icon or refocus the window. + if (typeof document !== 'undefined' && !document.hasFocus()) { + markPetUnread() + } } if (payload?.usage) { @@ -907,10 +930,19 @@ export function useMessageStream({ flushQueuedDeltas(sessionId) upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type) + + if (isActiveEvent) { + setPetActivity({ reasoning: false, toolRunning: true }) + } } else if (event.type === 'tool.complete') { if (sessionId) { flushQueuedDeltas(sessionId) upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type) + + if (isActiveEvent) { + setPetActivity({ toolRunning: false }) + } + // A pending clarify blocks the turn, so the first tool.complete after // one is the clarify resolving — drop the "needs input" flag here so // the sidebar indicator clears as soon as it's answered, not only at @@ -1120,6 +1152,11 @@ export function useMessageStream({ compactedTurnRef.current.delete(sessionId) } + if (isActiveEvent) { + setPetActivity({ reasoning: false, toolRunning: false }) + flashPetActivity({ error: true }) + } + dispatchNativeNotification({ body: errorMessage, kind: 'turnError', diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 829119f65b4..f1a32771443 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -27,6 +27,7 @@ import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { setSessionYolo } from '@/lib/yolo-session' +import { openCommandPalettePage } from '@/store/command-palette' import { $composerAttachments, clearComposerAttachments, @@ -38,6 +39,7 @@ import { import { resetSessionBackground } from '@/store/composer-status' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' +import { setPetScale } from '@/store/pet-gallery' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $busy, @@ -57,8 +59,8 @@ import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' import type { - ClientSessionState, BrowserManageResponse, + ClientSessionState, FileAttachResponse, HandoffFailResponse, HandoffRequestResponse, @@ -1143,6 +1145,35 @@ export function usePromptActions({ renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) } }, + pet: async ctx => { + const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/) + const lower = sub.toLowerCase() + + if (lower === 'list' || lower === 'gallery' || lower === 'browse' || lower === 'all') { + openCommandPalettePage('pets') + + return + } + + // `/pet scale ` resizes the floating pet locally (instant) and + // persists via the store — no round-trip to the slash worker. + if (lower === 'scale') { + const value = Number(rawValue) + + if (!rawValue || Number.isNaN(value)) { + const resolved = await withSlashOutput(ctx) + resolved?.render('usage: /pet scale (e.g. /pet scale 0.5)') + + return + } + + setPetScale(requestGateway, value) + + return + } + + await runExec(ctx) + }, // /browser connect|disconnect|status manages the live CDP connection on // the gateway host, mirroring the TUI's browser.manage RPC. It mutates // BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only @@ -1359,6 +1390,7 @@ export function usePromptActions({ const cancelRun = useCallback(async () => { const sessionId = activeSessionId || activeSessionIdRef.current + const releaseBusy = () => { setMutableRef(busyRef, false) setBusy(false) diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 80b74090f33..36fb9e91687 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -1,30 +1,31 @@ import { useStore } from '@nanostores/react' -import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' import { LanguageSwitcher } from '@/components/language-switcher' import { SegmentedControl } from '@/components/ui/segmented-control' +import type { DesktopMarketplaceSearchItem } from '@/global' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons' +import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { $translucency, setTranslucency } from '@/store/translucency' -import { useTheme } from '@/themes/context' +import { getBaseColors, useTheme } from '@/themes/context' import { installVscodeThemeFromMarketplace } from '@/themes/install' -import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes' +import { isUserTheme, removeUserTheme } from '@/themes/user-themes' import { MODE_OPTIONS } from './constants' +import { PetSettings } from './pet-settings' import { ListRow, SectionHeading, SettingsContent } from './primitives' -function ThemePreview({ name }: { name: string }) { - const t = resolveTheme(name) - - if (!t) { - return null - } - - const c = t.colors +function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) { + // Preview in the *current* mode: the dark palette in Dark, and the light + // palette in Light — synthesizing one for dark-only themes — so every card + // tracks the Light/Dark toggle, exactly like the app itself does. + const c = getBaseColors(name, mode) return (
(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delayMs) + + return () => clearTimeout(handle) + }, [value, delayMs]) + + return debounced +} + +const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }) + +/** + * Live VS Code Marketplace theme search (the same backend as the Cmd-K "Install + * theme…" page). Renders below the local grid when there's a query: each row + * downloads + converts + installs via `installVscodeThemeFromMarketplace` and + * activates it. Extensions already imported locally are marked installed. + */ +function MarketplaceThemeResults({ + query, + installedExtIds, + onInstalled +}: { + query: string + installedExtIds: Set + onInstalled: (name: string) => void +}) { const { t } = useI18n() - const { setTheme } = useTheme() - const a = t.settings.appearance - const [id, setId] = useState('') - const [busy, setBusy] = useState(false) - const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null) + const copy = t.commandCenter.installTheme + const debounced = useDebounced(query.trim(), 300) + const [installingId, setInstallingId] = useState(null) + const [installedHere, setInstalledHere] = useState>({}) + const [error, setError] = useState(null) - const install = async () => { - const trimmed = id.trim() + const search = useQuery({ + enabled: debounced.length > 0, + queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debounced) ?? Promise.resolve([]), + queryKey: ['marketplace-themes-settings', debounced], + staleTime: 5 * 60 * 1000 + }) - if (!trimmed || busy) { + const install = async (item: DesktopMarketplaceSearchItem) => { + if (installingId) { return } - setBusy(true) - setStatus(null) + setInstallingId(item.extensionId) + setError(null) try { - const theme = await installVscodeThemeFromMarketplace(trimmed) + const theme = await installVscodeThemeFromMarketplace(item.extensionId) triggerHaptic('crisp') - setTheme(theme.name) - setStatus({ kind: 'success', text: a.installed(theme.label) }) - setId('') - } catch (error) { - setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError }) + setInstalledHere(prev => ({ ...prev, [item.extensionId]: true })) + onInstalled(theme.name) + } catch (e) { + setError(e instanceof Error ? e.message : copy.error) } finally { - setBusy(false) + setInstallingId(null) } } - return ( -
-
- { - setId(event.target.value) - setStatus(null) - }} - onKeyDown={event => { - if (event.key === 'Enter') { - void install() - } - }} - placeholder={a.installPlaceholder} - spellCheck={false} - value={id} - /> - -
- {status && ( -

- {status.text} + if (!debounced) { + return null + } + + const header = ( +

+ From the VS Code Marketplace +

+ ) + + if (search.isLoading) { + return ( + <> + {header} +

+ + {copy.loading}

- )} -
+ + ) + } + + if (search.isError) { + return ( + <> + {header} +

{copy.error}

+ + ) + } + + const results = search.data ?? [] + + if (results.length === 0) { + return ( + <> + {header} +

{copy.empty}

+ + ) + } + + return ( + <> + {header} + {error &&

{error}

} +
+ {results.map(item => { + const busy = installingId === item.extensionId + const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId) + + return ( + + ) + })} +
+ ) } export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() - const { themeName, mode, availableThemes, setTheme, setMode } = useTheme() + const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) const translucency = useStore($translucency) const profiles = useStore($profiles) const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile)) const a = t.settings.appearance + const [query, setQuery] = useState('') + + // One box does double duty: filter installed themes live (below), and run a + // name search against the VS Code Marketplace (the Cmd-K "Install theme…" + // backend) for anything not already installed. + const needle = query.trim().toLowerCase() + + const filteredThemes = availableThemes + .filter( + theme => + !needle || + theme.label.toLowerCase().includes(needle) || + theme.name.toLowerCase().includes(needle) || + theme.description.toLowerCase().includes(needle) + ) + // Active theme first; stable sort keeps the rest in their original order. + .sort((a, b) => Number(b.name === themeName) - Number(a.name === themeName)) + + // Marketplace imports describe themselves as "VS Code · "; + // pull those ids back out so search results already imported show as installed. + const MARKETPLACE_DESC_PREFIX = 'VS Code · ' + + const installedExtIds = new Set( + availableThemes + .map(theme => + theme.description.startsWith(MARKETPLACE_DESC_PREFIX) + ? theme.description.slice(MARKETPLACE_DESC_PREFIX.length) + : '' + ) + .filter(Boolean) + ) + // Themes save per profile. Surface that only when the user actually has more // than one profile (single-profile installs never see the distinction). const showProfileNote = profiles.length > 1 @@ -163,7 +274,7 @@ export function AppearanceSettings() { {a.intro}

-
+
} description={isSavingLocale ? t.language.saving : t.language.description} @@ -171,18 +282,107 @@ export function AppearanceSettings() { /> { - triggerHaptic('crisp') - setMode(id) - }} - options={modeOptions} - value={mode} - /> + below={ + <> + {/* One search box: filters your installed themes (the grid) + and live-searches the VS Code Marketplace below. */} +
+ setQuery(event.target.value)} + placeholder="Search your themes or the VS Code Marketplace…" + spellCheck={false} + value={query} + /> +
+ + {/* Fixed-height scroll area so the (growing) theme list never + runs the page long; the grid scrolls inside it. */} +
+ {filteredThemes.length === 0 ? ( + needle ? ( +

+ No installed themes match "{query.trim()}". +

+ ) : null + ) : ( +
+ {filteredThemes.map(theme => { + const active = themeName === theme.name + const removable = isUserTheme(theme.name) + + return ( +
+ + {removable && ( + + )} +
+ ) + })} +
+ )} + setTheme(name)} + query={query} + /> +
+ {showProfileNote && ( +

+ {a.themeProfileNote(activeProfileName)} +

+ )} + } - description={a.colorModeDesc} - title={a.colorMode} + description={a.themeDesc} + title={ +
+ {a.themeTitle} + { + triggerHaptic('crisp') + setMode(id) + }} + options={modeOptions} + value={mode} + /> +
+ } + wide /> - -
- {availableThemes.map(theme => { - const active = themeName === theme.name - const removable = isUserTheme(theme.name) - - return ( -
- - {removable && ( - - )} -
- ) - })} -
- - {showProfileNote && ( -

- {a.themeProfileNote(activeProfileName)} -

- )} - - } - description={a.themeDesc} - title={a.themeTitle} - wide - /> -
+ +
+ +
) } diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx new file mode 100644 index 00000000000..e9b0e925ce1 --- /dev/null +++ b/apps/desktop/src/app/settings/pet-settings.tsx @@ -0,0 +1,231 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { PetThumb } from '@/components/pet/pet-thumb' +import { SegmentedControl } from '@/components/ui/segmented-control' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Loader2, PawPrint, Trash2 } from '@/lib/icons' +import { selectableCardClass } from '@/lib/selectable-card' +import { cn } from '@/lib/utils' +import { $petInfo } from '@/store/pet' +import { + $petBusy, + $petGallery, + $petGalleryError, + $petGalleryStatus, + adoptPet, + loadPetGallery, + loadPetThumb, + PET_SCALE_DEFAULT, + PET_SCALE_MAX, + PET_SCALE_MIN, + rankedGalleryPets, + removePet as removePetAction, + setPetEnabled, + setPetScale +} from '@/store/pet-gallery' +import { $gatewayState } from '@/store/session' + +import { ListRow, SectionHeading } from './primitives' + +/** + * Appearance opt-in for the floating petdex mascot. A thin view over the shared + * `pet-gallery` store — it subscribes to the atoms and calls the store actions, + * so the gallery is fetched once + cached and adopt/toggle/remove patch local + * state instead of re-pulling the network gallery. The floating mascot polls + * `pet.info`, so picking a pet here lights it up within a couple seconds. + */ +export function PetSettings() { + const { t } = useI18n() + const copy = t.settings.appearance.pet + const { requestGateway } = useGatewayRequest() + const gatewayState = useStore($gatewayState) + const gallery = useStore($petGallery) + const status = useStore($petGalleryStatus) + const error = useStore($petGalleryError) + const busySlug = useStore($petBusy) + const petInfo = useStore($petInfo) + const [query, setQuery] = useState('') + const scale = petInfo.scale ?? PET_SCALE_DEFAULT + + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + void loadPetGallery(requestGateway) + }, [gatewayState, requestGateway]) + + const enabled = gallery?.enabled ?? false + const active = gallery?.active ?? '' + const pets = gallery?.pets ?? [] + const staleBackend = status === 'stale' + + const selectPet = (slug: string) => { + void adoptPet(requestGateway, slug, copy.adoptFailed(slug)).then(ok => ok && triggerHaptic('crisp')) + } + + const removePet = (slug: string) => { + void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp')) + } + + const toggle = (on: boolean) => { + void setPetEnabled(requestGateway, on, { + noneAvailable: copy.noneAvailable, + fallback: on ? copy.turnOnFailed : copy.turnOffFailed + }).then(ok => ok && triggerHaptic('crisp')) + } + + // The petdex catalog is thousands of entries, so rank + cap how many render. + const RENDER_CAP = 60 + const sorted = rankedGalleryPets(gallery, query) + const shown = sorted.slice(0, RENDER_CAP) + + return ( +
+ +

+ {copy.intro} +

+ + {staleBackend && ( +

+ {copy.restartHint} +

+ )} + +
+ + setQuery(event.target.value)} + placeholder={copy.searchPlaceholder} + spellCheck={false} + value={query} + /> + {/* Fixed-height scroll area so filtering never grows/shrinks the + page (no layout thrash); the grid scrolls inside it. */} +
+ {pets.length === 0 ? ( +

+ {copy.unreachable} +

+ ) : shown.length === 0 ? ( +

+ {copy.noMatch(query)} +

+ ) : ( +
+ {shown.map(pet => { + const isActive = enabled && active === pet.slug + const isBusy = busySlug === pet.slug + + return ( +
+ + {pet.installed && !isBusy && ( + + )} +
+ ) + })} +
+ )} +
+ {/* Always-present status line so its appearance never shifts layout. */} +

+ {error ? ( + {error} + ) : sorted.length > RENDER_CAP ? ( + copy.countCapped(RENDER_CAP, sorted.length) + ) : ( + copy.count(sorted.length) + )} +

+ + } + description={copy.chooseDesc} + title={ +
+ {copy.chooseTitle} + void toggle(id === 'on')} + options={[ + { id: 'off', label: copy.off }, + { id: 'on', label: copy.on } + ]} + value={enabled ? 'on' : 'off'} + /> +
+ } + wide + /> + + {enabled && ( + + { + triggerHaptic('selection') + setPetScale(requestGateway, Number(event.target.value)) + }} + step={0.05} + style={{ accentColor: 'var(--dt-primary)' }} + type="range" + value={scale} + /> + + {`${Math.round(scale * 100)}%`} + +
+ } + description={copy.scaleDesc} + title={copy.scaleTitle} + /> + )} +
+
+ ) +} diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index 7cbcaacfb41..b0981681c6c 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -4,6 +4,7 @@ import { useSyncExternalStore } from 'react' import { NotificationStack } from '@/components/notifications' import { PaneShell } from '@/components/pane-shell' +import { FloatingPet } from '@/components/pet/floating-pet' import { SidebarProvider } from '@/components/ui/sidebar' import { useMediaQuery } from '@/hooks/use-media-query' import { @@ -202,6 +203,10 @@ export function AppShell({ {/* Mounted at the shell root (after overlays) so success/error toasts surface above every route and overlay — not just the chat view. */} + + {/* Petdex floating mascot — in-window, always-on-top, reactive to agent + activity. Renders nothing unless a pet is installed + enabled. */} + ) } diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx new file mode 100644 index 00000000000..d69c35ab6b6 --- /dev/null +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -0,0 +1,313 @@ +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { persistString, storedString } from '@/lib/storage' +import { $petInfo, clearPetUnread, type PetInfo, petProfile, setPetInfo } from '@/store/pet' +import { resetPetGallery } from '@/store/pet-gallery' +import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay' +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { $gatewayState } from '@/store/session' +import { isSecondaryWindow } from '@/store/windows' +import { useTheme } from '@/themes/context' + +import { PetSprite } from './pet-sprite' + +// v2: positions are now top/left anchored (v1 stored bottom-anchored values, +// which dragged inverted). Bumping the key discards stale v1 coordinates. +const POSITION_KEY = 'hermes.desktop.pet-position.v2' + +interface Point { + x: number + y: number +} + +function clampToViewport({ x, y }: Point): Point { + const maxX = Math.max(0, (window.innerWidth || 800) - 80) + const maxY = Math.max(0, (window.innerHeight || 600) - 80) + + return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) } +} + +// The sprite art faces left by default, so mirror it when the pet's center sits +// on the left half of the window — it always faces inward, toward the content. +function facing(leftX: number, petW: number): string { + return leftX + petW / 2 < (window.innerWidth || 800) / 2 ? 'scaleX(-1)' : 'none' +} + +function loadPosition(): Point { + try { + const raw = storedString(POSITION_KEY) + + if (raw) { + const parsed = JSON.parse(raw) as Point + + if (typeof parsed.x === 'number' && typeof parsed.y === 'number') { + return clampToViewport(parsed) + } + } + } catch { + // fall through to default + } + + // Default: lower-left corner (top/left anchored). + return clampToViewport({ x: 24, y: (window.innerHeight || 600) - 220 }) +} + +/** + * In-window floating petdex mascot. Always-on-top within the app, draggable, + * and reactive to agent activity via `$petState`. Fetches the active pet via + * the shared `pet.info` RPC; renders nothing until a pet is installed + + * enabled. + * + * Adopting a pet is fully in-app: type `/pet boba` in the composer. That + * writes `display.pet.*` from the slash worker, so we keep polling `pet.info` + * while no pet is active and the mascot pops in within a few seconds — no + * reload, no CLI. Once a pet is live we stop polling. + * + * Promotion to a separate frameless OS-level window is a follow-up — the + * sprite + state logic here is reused as-is, only the host changes. + */ +const PET_POLL_MS = 3000 + +export function FloatingPet() { + const { requestGateway } = useGatewayRequest() + const { resolvedMode } = useTheme() + const gatewayState = useStore($gatewayState) + const info = useStore($petInfo) + const overlayActive = useStore($petOverlayActive) + + const [position, setPosition] = useState(loadPosition) + const containerRef = useRef(null) + // The facing mirror lives on the sprite wrapper, not the container, so the + // speech bubble (a container child) never renders flipped/backwards. + const spriteWrapRef = useRef(null) + const petW = (info.frameW ?? 192) * (info.scale ?? 0.33) + // Soft contact shadow, sized off the pet so every scale/species grounds the + // same way (cf. lairp's per-actor feet ellipse). Lighter on light backgrounds. + const shadowW = Math.round(petW * 0.55) + const shadowH = Math.max(3, Math.round(shadowW * 0.28)) + const shadowAlpha = resolvedMode === 'light' ? 0.2 : 0.55 + // Live drag offset (pointer → element top-left). Drag updates the DOM + // directly to avoid a React re-render (and canvas reflow) per pointermove — + // state is only committed on release. + const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null) + + // Fetch pet.info on connect, then keep polling while no pet is active so an + // in-app `/pet ` shows up live. Stops polling once a pet is enabled. + const active = info.enabled && Boolean(info.spritesheetBase64) + useEffect(() => { + if (gatewayState !== 'open' || active) { + return + } + + let cancelled = false + + const pull = async () => { + try { + const next = await requestGateway('pet.info', { profile: petProfile() }) + + if (!cancelled && next) { + setPetInfo(next) + } + } catch { + // cosmetic feature — never surface gateway errors + } + } + + void pull() + const timer = window.setInterval(() => void pull(), PET_POLL_MS) + + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [gatewayState, active, requestGateway]) + + // Pets are per-profile. When the active profile changes, drop the previous + // profile's mascot + gallery cache so the poll above refetches the new + // profile's pet (its config + pets dir resolve per-profile on the backend). + const profileRef = useRef(normalizeProfileKey($activeGatewayProfile.get())) + useEffect( + () => + $activeGatewayProfile.subscribe(next => { + const key = normalizeProfileKey(next) + + if (key === profileRef.current) { + return + } + + profileRef.current = key + setPetInfo({ enabled: false }) + resetPetGallery() + }), + [] + ) + + // Wire the overlay control channel once, only in the primary window — the + // pop-out overlay belongs to it (main.cjs positions it against the main + // window and routes control messages back to it). + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + return initPetOverlayBridge() + }, []) + + // Returning to the app (by any route, not just the mail icon) clears the pet's + // "new message" hint — you've seen it now. + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + const onFocus = () => clearPetUnread() + window.addEventListener('focus', onFocus) + + return () => window.removeEventListener('focus', onFocus) + }, []) + + // Restore a popped-out pet on boot, once the pet has loaded (so we never spawn + // an empty overlay window). Primary window only; runs at most once. + const restoredRef = useRef(false) + useEffect(() => { + if (isSecondaryWindow() || restoredRef.current || !active) { + return + } + + restoredRef.current = true + restorePetOverlay() + }, [active]) + + // A window resize must never strand the pet off-screen — re-clamp the + // committed position (and persist it) whenever the viewport shrinks. + useEffect(() => { + const onResize = () => + setPosition(prev => { + const next = clampToViewport(prev) + + if (next.x === prev.x && next.y === prev.y) { + return prev + } + + persistString(POSITION_KEY, JSON.stringify(next)) + + return next + }) + + window.addEventListener('resize', onResize) + + return () => window.removeEventListener('resize', onResize) + }, []) + + const onPointerDown = useCallback((e: React.PointerEvent) => { + const el = containerRef.current + + if (!el) { + return + } + + const rect = el.getBoundingClientRect() + + // Shift-click pops the pet out into a free-floating desktop overlay (it can + // leave the window and stays visible while Hermes is minimized) instead of + // starting an in-window drag. Primary window only — the overlay is anchored + // to it. + if (e.shiftKey && !isSecondaryWindow()) { + popOutPet({ height: rect.height, width: rect.width, x: rect.left, y: rect.top }) + + return + } + + dragRef.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, x: rect.left, y: rect.top } + el.setPointerCapture(e.pointerId) + el.style.cursor = 'grabbing' + }, []) + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + const drag = dragRef.current + const el = containerRef.current + + if (!drag || !el) { + return + } + + const next = clampToViewport({ x: e.clientX - drag.dx, y: e.clientY - drag.dy }) + drag.x = next.x + drag.y = next.y + // Mutate the DOM directly — no setState, so no re-render while dragging. The + // mirror follows the pointer across the midline for the same reason; it + // rides the sprite wrapper so the bubble stays upright. + el.style.left = `${next.x}px` + el.style.top = `${next.y}px` + + if (spriteWrapRef.current) { + spriteWrapRef.current.style.transform = facing(next.x, petW) + } + }, + [petW] + ) + + const onPointerUp = useCallback((e: React.PointerEvent) => { + const drag = dragRef.current + + if (drag) { + dragRef.current = null + const committed = { x: drag.x, y: drag.y } + setPosition(committed) + persistString(POSITION_KEY, JSON.stringify(committed)) + } + + const el = containerRef.current + + if (el) { + el.style.cursor = 'grab' + el.releasePointerCapture?.(e.pointerId) + } + }, []) + + // While popped out, the desktop overlay window owns the mascot — hide the + // in-window one so there aren't two. + if (!info.enabled || !info.spritesheetBase64 || overlayActive) { + return null + } + + return ( +
+
+
+ +
+
+ ) +} diff --git a/apps/desktop/src/components/pet/pet-bubble.tsx b/apps/desktop/src/components/pet/pet-bubble.tsx new file mode 100644 index 00000000000..3d3c8109681 --- /dev/null +++ b/apps/desktop/src/components/pet/pet-bubble.tsx @@ -0,0 +1,142 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { AlertCircle, Clock, type IconComponent } from '@/lib/icons' +import { $petActivity, $petState, type PetState } from '@/store/pet' + +/** + * Speech bubble + status glyph for the popped-out pet overlay — the + * "notification" half of the mascot. It externalizes what the agent is doing + * (Codex-style) so a glance at the desktop pet replaces switching back to the + * window. The in-window pet doesn't show it (the app itself is the surface); + * only the overlay renders it. + * + * Text is derived purely from the same `$petState` / `$petActivity` the sprite + * already reacts to, so it never drifts from the animation. The bubble is shown + * only when there's something worth saying (working / reviewing / a transient + * done/error beat / waiting on the user) and is hidden at plain idle. + */ + +type Tone = 'error' | 'wait' + +interface Spec { + lines: string[] + glyph?: IconComponent + tone?: Tone +} + +// Phrasings per mood, picked at random (no immediate repeat) for a bit of life. +// Keep them short — the bubble is tiny and never wraps. +const SPECS: Partial> = { + run: { + lines: ['working…', 'on it…', 'crunching…', 'tinkering…', 'cooking…', 'in the weeds…', 'wiring it up…', 'making moves…', 'heads down…', 'hammering away…'] + }, + review: { + lines: ['thinking…', 'reading…', 'reviewing…', 'pondering…', 'connecting dots…', 'sizing it up…', 'tracing it…', 'mulling…', 'scheming…', 'hmm…'] + }, + failed: { + glyph: AlertCircle, + lines: ['hit a snag', 'welp', 'that broke', 'oof', 'snagged'], + tone: 'error' + }, + waiting: { + glyph: Clock, + lines: ['your turn', 'all yours', 'over to you', 'ball’s in your court', 'awaiting orders'], + tone: 'wait' + } +} + +const TONE_COLOR: Record = { + error: 'var(--ui-red)', + wait: 'var(--ui-yellow)' +} + +// Random pick that avoids repeating the line we're already showing. +function pick(lines: string[], prev: string): string { + if (lines.length <= 1) { + return lines[0] ?? '' + } + + let next = prev + + while (next === prev) { + next = lines[Math.floor(Math.random() * lines.length)] + } + + return next +} + +export function PetBubble() { + const state = useStore($petState) + const activity = useStore($petActivity) + const [line, setLine] = useState('') + + // Finish beats are carried by the sprite/mail icon; idle only speaks up when + // it's actually the user's turn. Everything else maps to a mood spec. + const specKey: null | PetState = + state in SPECS ? state : state === 'idle' && activity.awaitingInput ? 'waiting' : null + const rotating = specKey === 'run' || specKey === 'review' + + // Pick a fresh line on every mood change, then keep rotating (random, no + // repeat) only while the agent is actively working/thinking. + useEffect(() => { + const spec = specKey ? SPECS[specKey] : null + + if (!spec) { + setLine('') + + return + } + + setLine(prev => pick(spec.lines, prev)) + + if (!rotating || spec.lines.length <= 1) { + return + } + + const id = window.setInterval(() => setLine(prev => pick(spec.lines, prev)), 2600) + + return () => window.clearInterval(id) + }, [specKey, rotating]) + + const spec = specKey ? SPECS[specKey] : null + + if (!spec) { + return null + } + + const Glyph = spec.glyph + const text = line || spec.lines[0] + const hasText = Boolean(text) + + return ( +
+ {Glyph && ( + + + + )} + {text} +
+ ) +} diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx new file mode 100644 index 00000000000..753c6f34af2 --- /dev/null +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -0,0 +1,178 @@ +import { memo, useEffect, useMemo, useRef } from 'react' + +import { $petState, type PetInfo, type PetState } from '@/store/pet' + +const DEFAULT_FRAME_W = 192 +const DEFAULT_FRAME_H = 208 +const DEFAULT_FRAMES = 6 +const DEFAULT_LOOP_MS = 1100 +// Mirrors agent.pet.constants.DEFAULT_SCALE — fallback only; the gateway sends +// the configured scale. +const DEFAULT_SCALE = 0.33 +// Mirrors agent.pet.constants.CODEX_STATE_ROWS (Petdex current taxonomy). +const DEFAULT_STATE_ROWS = [ + 'idle', + 'running-right', + 'running-left', + 'waving', + 'jumping', + 'failed', + 'waiting', + 'running', + 'review' +] + +const STATE_ALIASES: Record = { + idle: ['idle'], + wave: ['wave', 'waving'], + jump: ['jump', 'jumping'], + run: ['run', 'running'], + failed: ['failed'], + review: ['review'], + waiting: ['waiting'] +} + +interface PetSpriteProps { + info: PetInfo + /** On-screen scale multiplier applied on top of the pet's native scale. */ + zoom?: number +} + +/** + * Canvas renderer for a petdex spritesheet — the one piece that must be + * TypeScript (the engine's decode/encode is Python). Draws the row matching the + * live `$petState`, stepping `framesPerState` frames across a `loopMs` loop. + * + * State is read from `$petState` via a ref + subscription rather than a prop, + * so the frequent activity-driven state changes during an agent turn update the + * canvas (inside its RAF loop) WITHOUT triggering a React re-render. Combined + * with `memo`, this component effectively never re-renders after mount until + * the pet itself changes. + */ +function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) { + const canvasRef = useRef(null) + const stateRef = useRef($petState.get()) + + const frameW = info.frameW ?? DEFAULT_FRAME_W + const frameH = info.frameH ?? DEFAULT_FRAME_H + const frames = info.framesPerState ?? DEFAULT_FRAMES + const framesByState = info.framesByState + const loopMs = info.loopMs ?? DEFAULT_LOOP_MS + const scale = (info.scale ?? DEFAULT_SCALE) * zoom + const rows = info.stateRows ?? DEFAULT_STATE_ROWS + + const drawW = Math.round(frameW * scale) + const drawH = Math.round(frameH * scale) + + const image = useMemo(() => { + if (!info.spritesheetBase64) { + return null + } + + const img = new Image() + img.src = `data:${info.mime ?? 'image/webp'};base64,${info.spritesheetBase64}` + + return img + }, [info.spritesheetBase64, info.mime]) + + useEffect(() => { + const canvas = canvasRef.current + + if (!canvas || !image) { + return + } + + const ctx = canvas.getContext('2d') + + if (!ctx) { + return + } + + // Track state via subscription, not a prop — no re-render on activity ticks. + stateRef.current = $petState.get() + + const unsubState = $petState.listen(next => { + stateRef.current = next + }) + + let raf = 0 + let frame = 0 + let lastStep = performance.now() + let drawnFrame = -1 + let drawnRow = -1 + + const rowIndexForState = (s: PetState): number => { + for (const key of STATE_ALIASES[s] ?? [s]) { + const idx = rows.indexOf(key) + if (idx >= 0) { + return idx + } + } + return 0 + } + + // Resolve a state to the row it draws and its real frame count. A state + // with no real frames (ragged sheet, empty row) falls back to idle rather + // than flashing blank padding. + const resolve = (s: PetState): { row: number; count: number } => { + const real = framesByState?.[s] ?? frames + if (real > 0) { + return { row: rowIndexForState(s), count: real } + } + + return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) } + } + + const render = (now: number) => { + const { row, count } = resolve(stateRef.current) + // Per-state step keeps every state's loop ~loopMs even when frame counts + // differ; counts vary per row so derive the cadence here, not once. + const stepMs = loopMs / count + + if (now - lastStep >= stepMs) { + frame += 1 + lastStep = now + } + + frame %= count + + // Only touch the canvas when the visible cell actually changes. The RAF + // ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of + // the clear+draw work and keeps the main thread free. + if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) { + const sx = frame * frameW + const sy = row * frameH + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.imageSmoothingEnabled = false + ctx.drawImage(image, sx, sy, frameW, frameH, 0, 0, drawW, drawH) + drawnFrame = frame + drawnRow = row + } + + raf = requestAnimationFrame(render) + } + + raf = requestAnimationFrame(render) + + return () => { + cancelAnimationFrame(raf) + unsubState() + } + }, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows]) + + return ( + + ) +} + +/** + * Memoized so a parent re-render (e.g. a position commit on drag-end) doesn't + * re-run the canvas setup. Props change only when the pet itself changes. + */ +export const PetSprite = memo(PetSpriteImpl) diff --git a/apps/desktop/src/components/pet/pet-thumb.tsx b/apps/desktop/src/components/pet/pet-thumb.tsx new file mode 100644 index 00000000000..088514c0804 --- /dev/null +++ b/apps/desktop/src/components/pet/pet-thumb.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from 'react' + +import { PawPrint } from '@/lib/icons' + +// petdex frames are a fixed 192×208 grid; the box matches that aspect. +const THUMB_W = 40 +const THUMB_H = Math.round((THUMB_W * 208) / 192) + +export type PetThumbLoader = (slug: string, url?: string) => Promise + +/** + * Idle-frame preview for one pet. The backend crops + caches the frame and + * returns it as a same-origin data URI (`pet.thumb`), which dodges the renderer + * CSP / R2 hotlink rules that break a direct ``. + */ +export function PetThumb({ + slug, + url, + alt, + load, + size = THUMB_W +}: { + slug: string + url?: string + alt: string + load: PetThumbLoader + /** Width in px; height follows the petdex frame aspect. */ + size?: number +}) { + const [src, setSrc] = useState(null) + const boxRef = useRef(null) + const height = Math.round((size * 208) / 192) + + useEffect(() => { + const el = boxRef.current + + if (!el || src) { + return + } + + const observer = new IntersectionObserver( + entries => { + if (entries.some(entry => entry.isIntersecting)) { + observer.disconnect() + void load(slug, url).then(uri => { + if (uri) { + setSrc(uri) + } + }) + } + }, + { rootMargin: '120px' } + ) + + observer.observe(el) + + return () => observer.disconnect() + }, [slug, url, src, load]) + + return ( + + {src ? ( + {alt} + ) : ( + + )} + + ) +} diff --git a/apps/desktop/src/components/ui/command.tsx b/apps/desktop/src/components/ui/command.tsx index dbbc655d690..4324c8e8e68 100644 --- a/apps/desktop/src/components/ui/command.tsx +++ b/apps/desktop/src/components/ui/command.tsx @@ -17,7 +17,12 @@ function Command({ className, ...props }: React.ComponentProps) { +interface CommandInputProps extends React.ComponentProps { + /** Inline trailing slot, rendered on the right of the search row. */ + right?: React.ReactNode +} + +function CommandInput({ className, right, ...props }: CommandInputProps) { return (
@@ -29,6 +34,7 @@ function CommandInput({ className, ...props }: React.ComponentProps + {right}
) } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index c615ad2d61a..5e41d3e7423 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -1,3 +1,10 @@ +import type { + PetOverlayBounds, + PetOverlayControl, + PetOverlayOpenRequest, + PetOverlayStatePayload +} from './store/pet-overlay' + export {} declare global { @@ -26,6 +33,20 @@ declare global { openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }> // Open (or focus) a compact secondary window on the new-session draft. openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }> + // The pop-out pet overlay: a transparent always-on-top window hosting only + // the mascot. The main renderer drives it (open/close/drag + state push); + // the overlay sends control messages back (pop-in, composer submit). + petOverlay: { + open: (request: PetOverlayOpenRequest) => Promise<{ ok: boolean; bounds?: PetOverlayBounds }> + close: () => Promise<{ ok: boolean }> + setBounds: (bounds: PetOverlayBounds) => void + setIgnoreMouse: (ignore: boolean) => void + setFocusable: (focusable: boolean) => void + pushState: (payload: PetOverlayStatePayload) => void + control: (payload: PetOverlayControl) => void + onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void + onControl: (callback: (payload: PetOverlayControl) => void) => () => void + } getBootProgress: () => Promise getConnectionConfig: (profile?: null | string) => Promise saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3c1a7ec3879..e8be5a6dec8 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -366,7 +366,32 @@ export const en: Translations = { installError: 'Could not install that theme.', installed: name => `Installed “${name}”.`, removeTheme: 'Remove theme', - importedBadge: 'Imported' + importedBadge: 'Imported', + pet: { + title: 'Pet', + intro: + 'Adopt an animated petdex mascot that floats over the app and reacts to what Hermes is doing — running while tools execute, celebrating on success, sulking on errors.', + restartHint: + 'Pets need a quick restart — the running app started before this feature was added. Quit and reopen Hermes, then come back here.', + on: 'On', + off: 'Off', + scaleTitle: 'Size', + scaleDesc: 'Resize the floating mascot. Applies everywhere instantly.', + chooseTitle: 'Choose a pet', + chooseDesc: 'Picking one installs it (if needed) and makes it active.', + searchPlaceholder: 'Search pets…', + unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.", + noMatch: query => `No pets match "${query}".`, + installedTag: 'installed', + countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`, + count: n => `${n} pet${n === 1 ? '' : 's'}.`, + uninstall: name => `Uninstall ${name}`, + adoptFailed: slug => `Could not adopt ${slug}`, + uninstallFailed: slug => `Could not uninstall ${slug}`, + noneAvailable: 'No pets available to turn on right now.', + turnOnFailed: 'Could not turn the pet on.', + turnOffFailed: 'Could not turn the pet off.' + } }, fieldLabels: FIELD_LABELS, fieldDescriptions: FIELD_DESCRIPTIONS, @@ -714,8 +739,22 @@ export const en: Translations = { commandCenter: 'Command Center', appearance: 'Appearance', settings: 'Settings', - changeTheme: 'Change theme...', + changeTheme: 'Change theme', changeColorMode: 'Change color mode...', + pets: { + title: 'Pets', + placeholder: 'Search pets…', + loading: 'Loading petdex gallery…', + error: 'Could not reach the petdex gallery.', + staleBackend: 'Restart Hermes to use pets — the backend predates this feature.', + empty: 'No matching pets.', + turnOff: 'Turn off', + turnOn: 'Turn on', + installed: 'Installed', + adoptFailed: 'Could not adopt that pet.', + toggleFailed: 'Could not toggle the pet.', + noneAvailable: 'No pets available — pick one below to install.' + }, installTheme: { title: 'Install theme...', placeholder: 'Search the VS Code Marketplace...', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 904e4b25c53..3a28b50aac3 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -281,7 +281,32 @@ export const ja = defineLocale({ installError: 'そのテーマをインストールできませんでした。', installed: name => `「${name}」をインストールしました。`, removeTheme: 'テーマを削除', - importedBadge: 'インポート済み' + importedBadge: 'インポート済み', + pet: { + title: 'ペット', + intro: + 'アプリ上に浮かぶ petdex のアニメーションマスコットを採用しましょう。ツール実行中は走り、成功すると喜び、エラーでしょんぼりと、Hermes の状態に反応します。', + restartHint: + 'ペット機能には再起動が必要です。この機能が追加される前に起動したアプリが動作中です。Hermes を終了して再度開き、このページに戻ってください。', + scaleTitle: 'サイズ', + scaleDesc: '浮遊マスコットの大きさを変更します。すべての画面に即時反映されます。', + on: 'オン', + off: 'オフ', + chooseTitle: 'ペットを選ぶ', + chooseDesc: '選ぶと(必要に応じて)インストールされ、アクティブになります。', + searchPlaceholder: 'ペットを検索…', + unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。', + noMatch: query => `「${query}」に一致するペットがありません。`, + installedTag: 'インストール済み', + countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`, + count: n => `${n} 件のペット。`, + uninstall: name => `${name} をアンインストール`, + adoptFailed: slug => `${slug} を採用できませんでした`, + uninstallFailed: slug => `${slug} をアンインストールできませんでした`, + noneAvailable: 'オンにできるペットがありません。', + turnOnFailed: 'ペットをオンにできませんでした。', + turnOffFailed: 'ペットをオフにできませんでした。' + } }, fieldLabels: defineFieldCopy({ model: 'デフォルトモデル', @@ -834,8 +859,22 @@ export const ja = defineLocale({ commandCenter: 'コマンドセンター', appearance: '外観', settings: '設定', - changeTheme: 'テーマを変更...', + changeTheme: 'テーマを変更', changeColorMode: 'カラーモードを変更...', + pets: { + title: 'ペット', + placeholder: 'ペットを検索…', + loading: 'petdex ギャラリーを読み込み中…', + error: 'petdex ギャラリーに接続できません。', + staleBackend: 'ペット機能を使うには Hermes を再起動してください。', + empty: '一致するペットがありません。', + turnOff: 'オフ', + turnOn: 'オン', + installed: 'インストール済み', + adoptFailed: 'ペットを採用できませんでした。', + toggleFailed: 'ペットを切り替えできませんでした。', + noneAvailable: '利用可能なペットがありません。' + }, installTheme: { title: 'テーマをインストール...', placeholder: 'VS Code Marketplace を検索...', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index dcf1028fb4b..70807da8bf7 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -265,6 +265,29 @@ export interface Translations { installed: (name: string) => string removeTheme: string importedBadge: string + pet: { + title: string + intro: string + restartHint: string + on: string + off: string + scaleTitle: string + scaleDesc: string + chooseTitle: string + chooseDesc: string + searchPlaceholder: string + unreachable: string + noMatch: (query: string) => string + installedTag: string + countCapped: (cap: number, total: number) => string + count: (n: number) => string + uninstall: (name: string) => string + adoptFailed: (slug: string) => string + uninstallFailed: (slug: string) => string + noneAvailable: string + turnOnFailed: string + turnOffFailed: string + } } fieldLabels: Record fieldDescriptions: Record @@ -594,6 +617,20 @@ export interface Translations { settings: string changeTheme: string changeColorMode: string + pets: { + title: string + placeholder: string + loading: string + error: string + staleBackend: string + empty: string + turnOff: string + turnOn: string + installed: string + adoptFailed: string + toggleFailed: string + noneAvailable: string + } installTheme: { title: string placeholder: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 8f208aff341..3e1420d3414 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -271,7 +271,30 @@ export const zhHant = defineLocale({ installError: '無法安裝該主題。', installed: name => `已安裝「${name}」。`, removeTheme: '移除主題', - importedBadge: '已匯入' + importedBadge: '已匯入', + pet: { + title: '寵物', + intro: '領養一隻懸浮在應用上的 petdex 動畫寵物,它會根據 Hermes 的狀態做出反應——工具執行時奔跑、成功時歡呼、出錯時沮喪。', + restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes,然後回到此處。', + scaleTitle: '大小', + scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。', + on: '開啟', + off: '關閉', + chooseTitle: '選擇寵物', + chooseDesc: '選擇後會自動安裝(如需)並設為目前寵物。', + searchPlaceholder: '搜尋寵物…', + unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。', + noMatch: query => `沒有符合「${query}」的寵物。`, + installedTag: '已安裝', + countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`, + count: n => `${n} 個寵物。`, + uninstall: name => `解除安裝 ${name}`, + adoptFailed: slug => `無法領養 ${slug}`, + uninstallFailed: slug => `無法解除安裝 ${slug}`, + noneAvailable: '目前沒有可開啟的寵物。', + turnOnFailed: '無法開啟寵物。', + turnOffFailed: '無法關閉寵物。' + } }, fieldLabels: defineFieldCopy({ model: '預設模型', @@ -807,8 +830,22 @@ export const zhHant = defineLocale({ commandCenter: '命令中心', appearance: '外觀', settings: '設定', - changeTheme: '變更主題...', + changeTheme: '變更主題', changeColorMode: '變更色彩模式...', + pets: { + title: '寵物', + placeholder: '搜尋寵物…', + loading: '正在載入 petdex 畫廊…', + error: '無法連線至 petdex 畫廊。', + staleBackend: '請重新啟動 Hermes 以使用寵物功能。', + empty: '沒有符合的寵物。', + turnOff: '關閉', + turnOn: '開啟', + installed: '已安裝', + adoptFailed: '無法領養該寵物。', + toggleFailed: '無法切換寵物顯示。', + noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。' + }, installTheme: { title: '安裝主題...', placeholder: '搜尋 VS Code Marketplace...', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f368d3585ca..34ddd474359 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -359,7 +359,30 @@ export const zh: Translations = { installError: '无法安装该主题。', installed: name => `已安装「${name}」。`, removeTheme: '移除主题', - importedBadge: '已导入' + importedBadge: '已导入', + pet: { + title: '宠物', + intro: '领养一只悬浮在应用上的 petdex 动画宠物,它会根据 Hermes 的状态做出反应——工具执行时奔跑、成功时欢呼、出错时沮丧。', + restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes,然后回到此处。', + scaleTitle: '大小', + scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。', + on: '开启', + off: '关闭', + chooseTitle: '选择宠物', + chooseDesc: '选择后会自动安装(如需)并设为当前宠物。', + searchPlaceholder: '搜索宠物…', + unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。', + noMatch: query => `没有匹配「${query}」的宠物。`, + installedTag: '已安装', + countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`, + count: n => `${n} 个宠物。`, + uninstall: name => `卸载 ${name}`, + adoptFailed: slug => `无法领养 ${slug}`, + uninstallFailed: slug => `无法卸载 ${slug}`, + noneAvailable: '当前没有可开启的宠物。', + turnOnFailed: '无法开启宠物。', + turnOffFailed: '无法关闭宠物。' + } }, fieldLabels: defineFieldCopy({ model: '默认模型', @@ -904,8 +927,22 @@ export const zh: Translations = { commandCenter: '命令中心', appearance: '外观', settings: '设置', - changeTheme: '更改主题...', + changeTheme: '更改主题', changeColorMode: '更改颜色模式...', + pets: { + title: '宠物', + placeholder: '搜索宠物…', + loading: '正在加载 petdex 画廊…', + error: '无法连接到 petdex 画廊。', + staleBackend: '请重启 Hermes 以使用宠物功能——当前后端版本过旧。', + empty: '没有匹配的宠物。', + turnOff: '关闭', + turnOn: '开启', + installed: '已安装', + adoptFailed: '无法领养该宠物。', + toggleFailed: '无法切换宠物显示。', + noneAvailable: '暂无可用宠物——请在下方选择一个安装。' + }, installTheme: { title: '安装主题...', placeholder: '搜索 VS Code Marketplace...', diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index 54f5a6f89df..8e30e5bfcfb 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -52,6 +52,16 @@ describe('desktop slash command curation', () => { expect(desktopSlashUnavailableMessage('/personality')).toBeNull() }) + it('routes /pet through the desktop action handler and drops /pets', () => { + expect(resolveDesktopCommand('/pet')?.surface).toEqual({ kind: 'action', action: 'pet' }) + expect(resolveDesktopCommand('/pet')?.args).toBe(true) + expect(isDesktopSlashSuggestion('/pet')).toBe(true) + expect(isDesktopSlashCommand('/pet')).toBe(true) + expect(resolveDesktopCommand('/pets')?.surface).toEqual({ kind: 'unavailable', reason: 'settings' }) + expect(isDesktopSlashSuggestion('/pets')).toBe(false) + expect(isDesktopSlashCommand('/pets')).toBe(false) + }) + it('treats /browser as an executable action command (local-gateway connect)', () => { // /browser used to be terminal-only; it now resolves to a desktop action // handler that routes browser.manage RPC when the gateway is local. diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index f9ae934edf4..e1a0f2d773c 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -34,6 +34,7 @@ export type DesktopActionId = | 'handoff' | 'help' | 'new' + | 'pet' | 'profile' | 'skin' | 'title' @@ -128,6 +129,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ { name: '/debug', description: 'Create a debug report', surface: exec() }, { name: '/goal', description: 'Manage the standing goal for this session', surface: exec() }, { name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true }, + { name: '/pet', description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', surface: action('pet'), args: true }, { name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() }, { name: '/retry', description: 'Retry the last user message', surface: exec() }, { name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() }, @@ -155,7 +157,7 @@ const NO_DESKTOP_SURFACE: Record = '/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose' ], messaging: ['/approve', '/deny'], - settings: ['/skills'], + settings: ['/skills', '/pets'], advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice'] } diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index a2cd4ec7b0b..9e07f529ce6 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -51,6 +51,7 @@ import { IconLoader2 as Loader2Icon, IconLock as Lock, IconLogin as LogIn, + IconMail as Mail, IconMessageCircle as MessageCircle, IconMessage2 as MessageSquareText, IconMicrophone as Mic, @@ -67,6 +68,7 @@ import { IconLayoutBottombar as PanelBottom, IconLayoutSidebar as PanelLeftIcon, IconPlayerPause as Pause, + IconPaw as PawPrint, IconPencil as Pencil, IconPencil as PencilIcon, IconPencil as PencilLine, @@ -153,6 +155,7 @@ export { Loader2Icon, Lock, LogIn, + Mail, MessageCircle, MessageSquareText, Mic, @@ -169,6 +172,7 @@ export { PanelBottom, PanelLeftIcon, Pause, + PawPrint, Pencil, PencilIcon, PencilLine, diff --git a/apps/desktop/src/lib/selectable-card.ts b/apps/desktop/src/lib/selectable-card.ts new file mode 100644 index 00000000000..617898b7e23 --- /dev/null +++ b/apps/desktop/src/lib/selectable-card.ts @@ -0,0 +1,31 @@ +import { cn } from '@/lib/utils' + +export interface SelectableCardState { + /** Currently selected / active — the strongest emphasis. */ + active?: boolean + /** + * Configured / installed / "you have this" — solid surface + border. When + * false the card renders muted (transparent, dimmed) until hovered, so the + * eye lands on what you already have. Ignored when `active` is set. + */ + prominent?: boolean +} + +/** + * Shared emphasis for selectable list cards across settings surfaces (theme + * picker, pet picker, Marketplace results, provider rows…). Three tiers: + * active > prominent > muted. Keeps the "installed = solid, not-installed = + * quiet" pattern consistent everywhere instead of each picker rolling its own. + * + * Callers own layout (padding, flex, width); this owns only border + surface. + */ +export function selectableCardClass({ active, prominent }: SelectableCardState): string { + return cn( + 'rounded-lg border transition-colors', + active + ? 'border-primary bg-primary/[0.06] ring-2 ring-primary/20' + : prominent + ? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)' + : 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-bg-quinary)' + ) +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index b78c583264a..5b7621aa015 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -26,20 +26,27 @@ if (import.meta.env.MODE !== 'production') { import('./app/chat/perf-probe') } -createRoot(document.getElementById('root')!).render( - - - - - - - - - - - - - - - -) +// The pet overlay rides this same bundle (`?win=overlay`) but mounts a tiny, +// transparent, gateway-less surface instead of the full app. Branch before any +// app-shell work so the overlay window stays cheap. +if (new URLSearchParams(window.location.search).get('win') === 'overlay') { + void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay()) +} else { + createRoot(document.getElementById('root')!).render( + + + + + + + + + + + + + + + + ) +} diff --git a/apps/desktop/src/store/command-palette.ts b/apps/desktop/src/store/command-palette.ts index d214d1c2f26..490e3ee1eac 100644 --- a/apps/desktop/src/store/command-palette.ts +++ b/apps/desktop/src/store/command-palette.ts @@ -3,16 +3,30 @@ import { atom } from 'nanostores' /** Whether the global command palette (Cmd/Ctrl+K) is currently open. */ export const $commandPaletteOpen = atom(false) +/** Optional nested page to open when the palette next opens (e.g. `pets`). */ +export const $commandPalettePage = atom(null) + export function openCommandPalette(): void { $commandPaletteOpen.set(true) } +/** Open the palette directly on a nested page (`theme`, `pets`, …). */ +export function openCommandPalettePage(page: string): void { + $commandPalettePage.set(page) + $commandPaletteOpen.set(true) +} + export function closeCommandPalette(): void { $commandPaletteOpen.set(false) + $commandPalettePage.set(null) } export function setCommandPaletteOpen(open: boolean): void { $commandPaletteOpen.set(open) + + if (!open) { + $commandPalettePage.set(null) + } } export function toggleCommandPalette(): void { diff --git a/apps/desktop/src/store/pet-gallery.ts b/apps/desktop/src/store/pet-gallery.ts new file mode 100644 index 00000000000..a9a23734b8b --- /dev/null +++ b/apps/desktop/src/store/pet-gallery.ts @@ -0,0 +1,322 @@ +import { atom } from 'nanostores' + +import { $petInfo, type PetInfo, petProfile, setPetInfo } from '@/store/pet' + +/** + * Feature store for the petdex gallery picker (Cmd+K "Pets…" + Settings). + * + * Why this exists: `pet.gallery` does a *network* manifest fetch on the gateway, + * so re-pulling it after every adopt/toggle made the picker feel laggy and made + * two components (palette + settings) each carry their own copy of the same + * fetch / thumb-cache / optimistic-mutation logic. This store centralizes it: + * + * - The gallery is fetched once and cached; reopening the picker is instant. + * - Mutations (adopt / enable / remove) patch local state and only re-pull the + * cheap, local `pet.info` — never the network manifest again. + * - Thumbnails are deduped in a process-global cache (the backend disk-caches + * too, so a slug is fetched at most once per session). + * + * Consumers just `useStore($petGallery)` and call the actions; no component + * owns gallery state anymore. + */ + +export interface GalleryPet { + slug: string + displayName: string + installed: boolean + spritesheetUrl?: string + /** petdex's hand-picked set — used only to rank "popular" pets first. */ + curated?: boolean +} + +export interface PetGallery { + enabled: boolean + active: string + pets: GalleryPet[] +} + +export type PetGalleryStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error' + +/** The recovering `requestGateway` from `useGatewayRequest` — passed in so the + * store reuses the hook's reconnect/reauth handling instead of duplicating it. */ +export type GatewayRequest = (method: string, params?: Record) => Promise + +/** Profile-scoped pet RPC. Pets are per-profile, so every call carries the active + * profile (the gateway no-ops it for the launch profile). One chokepoint so no + * call site can forget it. */ +const petRpc = (request: GatewayRequest, method: string, params: Record = {}): Promise => + request(method, { ...params, profile: petProfile() }) + +/** A JSON-RPC "method not found" — the backend predates the pet RPCs. */ +function isMissingMethod(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /method not found|-32601|unknown method|no such method/i.test(message) +} + +export const $petGallery = atom(null) +export const $petGalleryStatus = atom('idle') +export const $petGalleryError = atom(null) + +// Which action is in flight, so rows/buttons can show a spinner. A slug for a +// per-pet mutation; the `TOGGLE_*` sentinels for the on/off switch. +export const TOGGLE_ON = '\u0000on' +export const TOGGLE_OFF = '\u0000off' +export const $petBusy = atom(null) + +// Process-global caches (survive component unmount → instant reopen). +const thumbCache = new Map>() +let galleryLoad: Promise | null = null + +/** + * Drop the cached gallery, thumbnails, and in-flight load so the next open + * refetches against the now-active profile's backend. Called on a profile switch + * (pets are per-profile) — the floating pet's own `pet.info` poll repaints the + * new profile's mascot, and the picker reloads its gallery on next mount. + */ +export function resetPetGallery(): void { + galleryLoad = null + thumbCache.clear() + $petGallery.set(null) + $petGalleryStatus.set('idle') + $petGalleryError.set(null) + $petBusy.set(null) +} + +export function loadPetThumb(request: GatewayRequest, slug: string, url?: string): Promise { + let pending = thumbCache.get(slug) + + if (!pending) { + pending = petRpc<{ ok: boolean; dataUri?: string }>(request, 'pet.thumb', { slug, url: url ?? '' }) + .then(result => (result?.ok && result.dataUri ? result.dataUri : null)) + .catch(() => null) + thumbCache.set(slug, pending) + } + + return pending +} + +/** + * Fetch the gallery once and cache it. Subsequent calls are no-ops while a + * ready snapshot is held; pass `{ force: true }` to bypass the cache (e.g. a + * manual refresh). Concurrent callers share a single in-flight request. + */ +export function loadPetGallery(request: GatewayRequest, options: { force?: boolean } = {}): Promise { + if (!options.force && $petGallery.get() && $petGalleryStatus.get() === 'ready') { + return Promise.resolve() + } + + if (galleryLoad) { + return galleryLoad + } + + galleryLoad = (async () => { + if (!$petGallery.get()) { + $petGalleryStatus.set('loading') + } + + try { + const [next, info] = await Promise.all([ + petRpc(request, 'pet.gallery'), + petRpc(request, 'pet.info') + ]) + + if (next) { + $petGallery.set(next) + $petGalleryStatus.set('ready') + $petGalleryError.set(null) + } + + if (info) { + setPetInfo(info) + } + } catch (e) { + if (isMissingMethod(e)) { + $petGalleryStatus.set('stale') + } else if (!$petGallery.get()) { + // Only surface a hard error when we have nothing to show; a transient + // hiccup mid-session leaves the cached gallery intact. + $petGalleryStatus.set('error') + $petGalleryError.set(e instanceof Error ? e.message : 'Could not reach the petdex gallery.') + } + } finally { + galleryLoad = null + } + })() + + return galleryLoad +} + +// Push the live mascot state (cheap, local config read) without re-pulling the +// network gallery — the floating pet repaints, the picker keeps its cache. +async function syncInfo(request: GatewayRequest): Promise { + try { + const info = await petRpc(request, 'pet.info') + + if (info) { + setPetInfo(info) + } + } catch { + // The mutation already succeeded; a stale mascot self-heals on its poll. + } +} + +/** + * Filter (drop the internal `clawd*` pets + apply a search query) and rank the + * gallery for a picker. Ranking has no popularity data, so it leans on the + * signals we do have: active pet first, then installed, then curated. Shared by + * the Cmd-K palette and the Settings grid so the two can't drift — each caller + * applies its own cap and reads `.length` for the total. + */ +export function rankedGalleryPets(gallery: PetGallery | null, query = ''): GalleryPet[] { + if (!gallery) { + return [] + } + + const needle = query.trim().toLowerCase() + + const rank = (p: GalleryPet) => + Number(gallery.enabled && p.slug === gallery.active) * 4 + Number(p.installed) * 2 + Number(p.curated) + + return gallery.pets + .filter( + p => + !/^clawd(-|$)/i.test(p.slug) && + (!needle || p.slug.toLowerCase().includes(needle) || p.displayName.toLowerCase().includes(needle)) + ) + .sort((a, b) => rank(b) - rank(a)) +} + +function patchGallery(fn: (gallery: PetGallery) => PetGallery): void { + const current = $petGallery.get() + + if (current) { + $petGallery.set(fn(current)) + } +} + +/** Shared mutation wrapper: spin, fire, patch on success, surface failures. */ +async function mutate( + busyKey: string, + fallback: string, + request: GatewayRequest, + run: () => Promise +): Promise { + $petBusy.set(busyKey) + $petGalleryError.set(null) + + try { + await run() + await syncInfo(request) + + return true + } catch (e) { + if (isMissingMethod(e)) { + $petGalleryStatus.set('stale') + } else { + $petGalleryError.set(e instanceof Error ? e.message : fallback) + } + + return false + } finally { + $petBusy.set(null) + } +} + +/** Install (if needed) + activate a pet. Optimistically marks it active. */ +export function adoptPet(request: GatewayRequest, slug: string, fallback: string): Promise { + return mutate(slug, fallback, request, async () => { + await petRpc(request, 'pet.select', { slug }) + patchGallery(g => ({ + ...g, + enabled: true, + active: slug, + pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: true } : p)) + })) + }) +} + +/** + * Turn the floating mascot on/off. On enable, activates the current pet (or the + * first installed one). Returns false without firing if there's nothing to show. + */ +export function setPetEnabled( + request: GatewayRequest, + on: boolean, + copy: { noneAvailable: string; fallback: string } +): Promise { + const gallery = $petGallery.get() + + if (!on && !(gallery?.enabled ?? false)) { + return Promise.resolve(true) + } + + let slug = gallery?.active || '' + + if (on) { + slug = slug || gallery?.pets.find(p => p.installed)?.slug || '' + + if (!slug) { + $petGalleryError.set(copy.noneAvailable) + + return Promise.resolve(false) + } + } + + return mutate(on ? TOGGLE_ON : TOGGLE_OFF, copy.fallback, request, async () => { + if (on) { + await petRpc(request, 'pet.select', { slug }) + } else { + await petRpc(request, 'pet.disable') + } + + patchGallery(g => ({ ...g, enabled: on, active: on ? slug : g.active })) + }) +} + +// Pet scale bounds — mirror `agent/pet/constants.py` (MIN_SCALE / MAX_SCALE) so +// the slider and the server clamp to the same range. +export const PET_SCALE_MIN = 0.1 +export const PET_SCALE_MAX = 3.0 +export const PET_SCALE_DEFAULT = 0.33 +export const clampPetScale = (n: number) => Math.max(PET_SCALE_MIN, Math.min(PET_SCALE_MAX, n)) + +let scalePersist: ReturnType | undefined + +/** + * Resize the floating pet. Updates `$petInfo` synchronously so the on-screen pet + * (and the slider) react on the same frame, then debounce-persists to + * `display.pet.scale` so a slider drag fires one RPC, not one per pixel. No poll + * or event needed — the pet already renders from `$petInfo.scale`. + */ +export function setPetScale(request: GatewayRequest, scale: number): void { + const next = clampPetScale(scale) + + setPetInfo({ ...$petInfo.get(), scale: next }) + + clearTimeout(scalePersist) + scalePersist = setTimeout(() => { + petRpc<{ ok: boolean; scale?: number }>(request, 'pet.scale', { scale: next }) + .then(result => { + // Reconcile with the server's clamp (cheap; only matters at the bounds). + if (typeof result?.scale === 'number' && result.scale !== $petInfo.get().scale) { + setPetInfo({ ...$petInfo.get(), scale: result.scale }) + } + }) + .catch(() => { + // Cosmetic — the pet already resized; persistence self-heals next write. + }) + }, 200) +} + +/** Uninstall a pet; turns the mascot off if it was the active one. */ +export function removePet(request: GatewayRequest, slug: string, fallback: string): Promise { + return mutate(slug, fallback, request, async () => { + await petRpc(request, 'pet.remove', { slug }) + patchGallery(g => ({ + ...g, + enabled: g.active === slug ? false : g.enabled, + pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: false } : p)) + })) + }) +} diff --git a/apps/desktop/src/store/pet-overlay.ts b/apps/desktop/src/store/pet-overlay.ts new file mode 100644 index 00000000000..3fda5e83b3c --- /dev/null +++ b/apps/desktop/src/store/pet-overlay.ts @@ -0,0 +1,260 @@ +import { atom } from 'nanostores' + +import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage' +import { $petActivity, $petInfo, $petUnread, clearPetUnread, type PetActivity, type PetInfo } from '@/store/pet' +import { $awaitingResponse, $busy } from '@/store/session' + +/** + * Controller for the pop-out pet overlay (main-renderer side). + * + * Shift-clicking the in-window pet "pops it out" into a transparent, + * always-on-top OS window (created in electron/main.cjs) that can leave the + * app's bounds and stays visible while Hermes is minimized. That window carries + * NO gateway connection — this renderer remains the single source of truth and + * pushes the live pet state to it over IPC. Control flows back (pop the pet back + * in, submit a composer message) via `onControl`. + * + * The overlay renders the same `PetSprite` / `PetBubble` as the in-window pet by + * mirroring the four reactive inputs of `$petState` (`$petInfo`, `$petActivity`, + * `$busy`, `$awaitingResponse`) into its own copies of those atoms — so the + * popped-out mascot is pixel-identical and needs zero bespoke render logic. + */ + +export interface PetOverlayBounds { + x: number + y: number + width: number + height: number +} + +/** + * Request to open the overlay window. `screen` says whether `bounds` are already + * in absolute screen coordinates (a remembered/dragged spot) or in the main + * window's viewport space (a fresh shift-click pop-out, which main.cjs converts + * by adding the content origin). + */ +export interface PetOverlayOpenRequest { + bounds: PetOverlayBounds + screen?: boolean +} + +/** Everything the overlay needs to reproduce the live mascot. */ +export interface PetOverlayStatePayload { + info: PetInfo + activity: PetActivity + busy: boolean + awaiting: boolean + /** Drives the overlay's mail icon: a finish landed while you were away. */ + unread: boolean +} + +export type PetOverlayControl = + | { type: 'pop-in' } + | { type: 'ready' } + | { type: 'submit'; text: string } + | { type: 'bounds'; bounds: PetOverlayBounds } + | { type: 'open-app' } + | { type: 'toggle-app' } + +// Persisted across restarts: was the pet popped out, and where on the desktop +// did the user leave it. Keyed v1; bump if the bounds shape ever changes. +const OVERLAY_ACTIVE_KEY = 'hermes.desktop.pet-overlay-active.v1' +const OVERLAY_BOUNDS_KEY = 'hermes.desktop.pet-overlay-bounds.v1' + +export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false)) + +// Persist the in/out choice so a popped-out pet comes back popped out. +$petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active)) + +function loadSavedBounds(): null | PetOverlayBounds { + try { + const raw = storedString(OVERLAY_BOUNDS_KEY) + + if (!raw) { + return null + } + + const parsed = JSON.parse(raw) as Partial + + if ( + typeof parsed.x === 'number' && + typeof parsed.y === 'number' && + typeof parsed.width === 'number' && + typeof parsed.height === 'number' + ) { + return { height: parsed.height, width: parsed.width, x: parsed.x, y: parsed.y } + } + } catch { + // fall through to null + } + + return null +} + +function saveBounds(bounds: PetOverlayBounds): void { + persistString(OVERLAY_BOUNDS_KEY, JSON.stringify(bounds)) +} + +// The overlay window is padded around the sprite so the bubble (above), the +// drag area, and the pop-up composer all have room; the pet sits near the +// bottom and the rest of the rectangle is transparent + click-through. +const OVERLAY_PAD_X = 100 +const OVERLAY_PAD_Y = 200 +const OVERLAY_MIN_W = 240 +const OVERLAY_MIN_H = 300 + +let stateUnsubs: Array<() => void> = [] +let controlUnsub: (() => void) | null = null +let submitHandler: ((text: string) => void) | null = null +let openAppHandler: (() => void) | null = null + +function currentPayload(): PetOverlayStatePayload { + return { + info: $petInfo.get(), + activity: $petActivity.get(), + busy: $busy.get(), + awaiting: $awaitingResponse.get(), + unread: $petUnread.get() + } +} + +function pushNow(): void { + window.hermesDesktop?.petOverlay?.pushState(currentPayload()) +} + +/** + * Open the overlay window and start mirroring live state into it. The main + * process echoes back the actual screen bounds it used, which we persist so the + * pet reopens exactly where the user left it. + */ +function openOverlay(request: PetOverlayOpenRequest): void { + const api = window.hermesDesktop?.petOverlay + + if (!api || stateUnsubs.length) { + return + } + + $petOverlayActive.set(true) + void api.open(request).then(res => { + if (res?.bounds) { + saveBounds(res.bounds) + } + + pushNow() + }) + + // Mirror live state into the overlay. subscribe() fires immediately, so the + // overlay also gets a first frame the moment it's ready (it asks via 'ready'). + stateUnsubs = [ + $petInfo.subscribe(pushNow), + $petActivity.subscribe(pushNow), + $busy.subscribe(pushNow), + $awaitingResponse.subscribe(pushNow), + $petUnread.subscribe(pushNow) + ] +} + +/** + * Pop the pet out of the window. `petRect` is the in-window sprite's viewport + * rect; we grow it to the padded overlay size and center the window on the + * pet's old spot (main.cjs adds the window's screen origin). If the user has + * popped out before, reopen at that remembered desktop spot instead. + */ +export function popOutPet(petRect: PetOverlayBounds): void { + if ($petOverlayActive.get() || stateUnsubs.length) { + return + } + + const saved = loadSavedBounds() + + if (saved) { + openOverlay({ bounds: saved, screen: true }) + + return + } + + const width = Math.max(OVERLAY_MIN_W, Math.round(petRect.width + OVERLAY_PAD_X)) + const height = Math.max(OVERLAY_MIN_H, Math.round(petRect.height + OVERLAY_PAD_Y)) + const x = Math.round(petRect.x - (width - petRect.width) / 2) + const y = Math.round(petRect.y - (height - petRect.height) / 2) + + openOverlay({ bounds: { height, width, x, y }, screen: false }) +} + +/** + * Restore the overlay on boot if the pet was popped out when the app last + * closed. Requires a remembered desktop spot — without one we fall back to the + * in-window pet rather than spawning an orphan window at the origin. + */ +export function restorePetOverlay(): void { + if (!window.hermesDesktop?.petOverlay || !$petOverlayActive.get() || stateUnsubs.length) { + return + } + + const saved = loadSavedBounds() + + if (!saved) { + $petOverlayActive.set(false) + + return + } + + openOverlay({ bounds: saved, screen: true }) +} + +/** Pop the pet back into the window (closes the overlay window). */ +export function popInPet(): void { + for (const off of stateUnsubs) { + off() + } + + stateUnsubs = [] + $petOverlayActive.set(false) + void window.hermesDesktop?.petOverlay?.close() +} + +/** Register the handler that turns an overlay composer submit into a real send. */ +export function setPetOverlaySubmitHandler(fn: ((text: string) => void) | null): void { + submitHandler = fn +} + +/** Register the handler that opens the app to the most recent thread (mail icon). */ +export function setPetOverlayOpenAppHandler(fn: (() => void) | null): void { + openAppHandler = fn +} + +/** + * Wire the overlay→renderer control channel once. Returns a disposer. Idempotent + * — a second call while already wired is a no-op. + */ +export function initPetOverlayBridge(): () => void { + const api = window.hermesDesktop?.petOverlay + + if (!api || controlUnsub) { + return () => {} + } + + controlUnsub = api.onControl(payload => { + if (payload?.type === 'pop-in') { + popInPet() + } else if (payload?.type === 'ready') { + // The overlay just mounted — hand it the current frame. + pushNow() + } else if (payload?.type === 'submit' && typeof payload.text === 'string') { + submitHandler?.(payload.text) + } else if (payload?.type === 'bounds' && payload.bounds) { + // The user dragged the overlay to a new desktop spot — remember it. + saveBounds(payload.bounds) + } else if (payload?.type === 'open-app') { + // Mail icon: surface the app on the most recent thread (main.cjs already + // focused the window before forwarding this) and mark it read. + clearPetUnread() + openAppHandler?.() + } + }) + + return () => { + controlUnsub?.() + controlUnsub = null + } +} diff --git a/apps/desktop/src/store/pet.test.ts b/apps/desktop/src/store/pet.test.ts new file mode 100644 index 00000000000..2837334ab37 --- /dev/null +++ b/apps/desktop/src/store/pet.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { $petActivity, $petState, derivePetState, flashPetActivity, setPetActivity } from './pet' + +describe('derivePetState', () => { + it('rests at idle by default and uses waiting when awaiting input', () => { + expect(derivePetState({})).toBe('idle') + expect(derivePetState({ awaitingInput: true })).toBe('waiting') + }) + + it('runs when busy or a tool is executing', () => { + expect(derivePetState({ busy: true })).toBe('run') + expect(derivePetState({ toolRunning: true })).toBe('run') + }) + + it('reviews while reasoning (below tool, above bare busy)', () => { + expect(derivePetState({ reasoning: true })).toBe('review') + expect(derivePetState({ reasoning: true, busy: true })).toBe('review') + expect(derivePetState({ reasoning: true, toolRunning: true })).toBe('run') + }) + + it('waits (blocked on the user) above the in-flight signals', () => { + expect(derivePetState({ awaitingInput: true, toolRunning: true, busy: true })).toBe('waiting') + // but a finish beat still wins over waiting + expect(derivePetState({ justCompleted: true, awaitingInput: true })).toBe('wave') + }) + + it('honors the full priority chain: error > celebrate > complete > tool', () => { + expect(derivePetState({ error: true, celebrate: true, busy: true })).toBe('failed') + expect(derivePetState({ celebrate: true, justCompleted: true, toolRunning: true })).toBe('jump') + expect(derivePetState({ justCompleted: true, toolRunning: true })).toBe('wave') + }) +}) + +describe('flashPetActivity', () => { + it('clears stale sibling beats so a completion never inherits a prior error', () => { + // A turn errors (sad), then the next turn finishes cleanly. The celebrate + // beat must win — error is highest priority, so a merge-only flash would + // keep the pet on the failed pose. + setPetActivity({ error: true }) + flashPetActivity({ celebrate: true }) + + expect($petActivity.get().error).toBe(false) + expect($petState.get()).toBe('jump') + + setPetActivity({}) + }) +}) diff --git a/apps/desktop/src/store/pet.ts b/apps/desktop/src/store/pet.ts new file mode 100644 index 00000000000..e4863f45712 --- /dev/null +++ b/apps/desktop/src/store/pet.ts @@ -0,0 +1,160 @@ +import { atom, computed } from 'nanostores' + +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { $busy } from '@/store/session' + +/** + * Petdex mascot state for the desktop floating pet. + * + * The spritesheet payload comes from the gateway `pet.info` RPC (shared with + * the TUI). The animation *state* is derived here from the same activity + * signals the chat already tracks, mirroring the priority order documented in + * `agent/pet/state.py` so the Python and TS surfaces never drift. + */ + +export type PetState = 'idle' | 'wave' | 'run' | 'failed' | 'review' | 'jump' | 'waiting' + +export interface PetInfo { + enabled: boolean + slug?: string + displayName?: string + mime?: string + spritesheetBase64?: string + frameW?: number + frameH?: number + framesPerState?: number + // Real (padding-trimmed) frame count per state row, from the engine. Lets the + // canvas step only frames that exist instead of a fixed framesPerState, which + // would animate into the transparent padding of ragged sheets (blank flash). + framesByState?: Record + loopMs?: number + scale?: number + stateRows?: string[] +} + +export interface PetActivity { + busy?: boolean + awaitingInput?: boolean + toolRunning?: boolean + reasoning?: boolean + error?: boolean + justCompleted?: boolean + celebrate?: boolean +} + +/** + * Resolve the animation state from coarse activity signals. + * + * Priority (highest first) mirrors `agent.pet.state.derive_pet_state`: + * error → celebrate → justCompleted → awaitingInput → toolRunning → reasoning → + * busy → idle. `awaitingInput` (a clarify/approval blocking on the user) outranks + * the in-flight signals because the turn is paused on you, not working. + */ +export function derivePetState(activity: PetActivity): PetState { + if (activity.error) { + return 'failed' + } + + if (activity.celebrate) { + return 'jump' + } + + if (activity.justCompleted) { + return 'wave' + } + + if (activity.awaitingInput) { + return 'waiting' + } + + if (activity.toolRunning) { + return 'run' + } + + if (activity.reasoning) { + return 'review' + } + + if (activity.busy) { + return 'run' + } + + return 'idle' +} + +export const $petInfo = atom({ enabled: false }) +export const $petActivity = atom({}) + +/** + * Profile the pet RPCs should resolve against. Pets are per-profile — the active + * pet (`display.pet.*`) and the installed sprites live under each profile's + * HERMES_HOME — so every pet RPC carries this. The gateway no-ops it for the + * launch profile (own-profile backends already resolve it) and rebinds for any + * other profile, which is what makes per-profile pets work in app-global remote + * mode (one backend serving every profile). + */ +export function petProfile(): string { + return normalizeProfileKey($activeGatewayProfile.get()) +} + +/** + * Pet-local "you have a new message" flag, surfaced as the overlay's mail icon. + * Deliberately not real unread tracking: it flips on when a turn finishes while + * the app isn't focused, and off when the user opens the app via the mail icon + * (or returns to the window). No persistence — it's a glance hint, not state. + */ +export const $petUnread = atom(false) +export const markPetUnread = () => $petUnread.set(true) +export const clearPetUnread = () => $petUnread.set(false) + +/** Steady activity flags (toolRunning / reasoning) set + cleared by the stream. */ +export const setPetActivity = (next: Partial) => + $petActivity.set({ ...$petActivity.get(), ...next }) + +let flashTimer: ReturnType | undefined + +/** Fire a transient reaction beat (error / celebrate / justCompleted) that + * decays back to the steady state after `ms`. + * + * Each beat first clears its siblings so a stale one can't win the priority + * race: without this, a completion beat (`celebrate`) would merge on top of a + * lingering `error`, and `derivePetState` checks `error` first — so a clean + * finish would render the sad/failed pose. */ +export const flashPetActivity = (next: Partial, ms = 1600) => { + setPetActivity({ celebrate: false, error: false, justCompleted: false, ...next }) + clearTimeout(flashTimer) + flashTimer = setTimeout( + () => setPetActivity({ celebrate: false, error: false, justCompleted: false }), + ms + ) +} + +export const setPetInfo = (info: PetInfo) => $petInfo.set(info) + +/** + * The live pet state. Derives from the dedicated activity atom, falling back to + * the always-present `$busy` chat signal so the pet reacts out of the box. + * + * `awaitingInput` (a clarify/approval blocking on the user) is an explicit flag + * on `$petActivity` — set by the controller from `$attentionSessionIds` and + * mirrored to the pop-out overlay through the same atom, so both surfaces agree + * without the overlay needing the session list. + */ +export const $petState = computed( + [$petActivity, $busy], + (activity, busy): PetState => { + const live = activity.busy ?? busy + + return derivePetState({ + busy: live, + awaitingInput: activity.awaitingInput, + // Steady flags only count mid-turn — ignore stale ones once at rest so an + // interrupted turn can't pin the pet on `run`/`review`. + toolRunning: live && activity.toolRunning, + reasoning: live && activity.reasoning, + error: activity.error, + justCompleted: activity.justCompleted, + celebrate: activity.celebrate + }) + } +) From 6fd839ac84d0c5032977271e8b2b57a2539dbd4f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 14:18:43 -0500 Subject: [PATCH 005/244] docs(pets): feature guide, petdex skill + catalog Add the pets feature guide and the petdex skill (SKILL.md + bundled doc), and register them in the website sidebar and skills catalog. --- skills/productivity/petdex/SKILL.md | 89 ++++++++ website/docs/reference/skills-catalog.md | 1 + website/docs/user-guide/features/pets.md | 190 ++++++++++++++++++ .../productivity/productivity-petdex.md | 105 ++++++++++ website/sidebars.ts | 2 + 5 files changed, 387 insertions(+) create mode 100644 skills/productivity/petdex/SKILL.md create mode 100644 website/docs/user-guide/features/pets.md create mode 100644 website/docs/user-guide/skills/bundled/productivity/productivity-petdex.md diff --git a/skills/productivity/petdex/SKILL.md b/skills/productivity/petdex/SKILL.md new file mode 100644 index 00000000000..416e0c6c2ca --- /dev/null +++ b/skills/productivity/petdex/SKILL.md @@ -0,0 +1,89 @@ +--- +name: petdex +description: Install and select animated petdex mascots for Hermes. +version: 1.0.0 +author: Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [petdex, mascot, display, cli, tui, desktop] + category: productivity + homepage: https://petdex.dev +--- + +# Petdex Skill + +Browse, install, and select animated "pet" mascots from the public +[petdex](https://github.com/crafter-station/petdex) gallery. An installed pet +reacts to agent activity (idle, running a tool, reviewing, error, done) across +the Hermes CLI, TUI, and desktop app. This skill drives the `hermes pets` CLI +and the `display.pet` config — it does not generate sprites. + +## When to Use + +- The user wants a desktop/terminal mascot or asks about "pets" / petdex. +- The user wants to change, preview, or disable the active pet. +- Diagnosing why a pet isn't showing (terminal graphics support, config). + +## Prerequisites + +- Network access to `petdex.dev` for the gallery/manifest (read-only, no auth). +- Pillow (a core Hermes dependency) for sprite decoding — already installed. +- For full-fidelity terminal rendering: a graphics-capable terminal (kitty, + Ghostty, WezTerm, iTerm2, or sixel). Otherwise a truecolor Unicode + half-block fallback is used automatically. + +## How to Run + +Use the `terminal` tool to run `hermes pets `. + +## Quick Reference + +| Goal | Command | +| --- | --- | +| Browse the gallery | `hermes pets list` (add a substring to filter: `hermes pets list cat`) | +| List installed pets | `hermes pets list --installed` | +| Install a pet | `hermes pets install ` (add `--select` to make it active) | +| Set the active pet | `hermes pets select ` (omit slug for a picker) | +| Resize the pet everywhere | `hermes pets scale ` (e.g. `0.5`, clamped 0.1–3.0) | +| Preview/animate in terminal | `hermes pets show [slug] [--cycle] [--state run]` | +| Disable the pet | `hermes pets off` | +| Remove a pet | `hermes pets remove ` | +| Diagnose setup | `hermes pets doctor` | + +## Procedure + +1. Find a pet: `hermes pets list ` and note its `slug`. +2. Install + activate: `hermes pets install --select`. +3. Preview it: `hermes pets show` (Ctrl+C to stop). +4. Confirm setup: `hermes pets doctor` — shows the resolved pet, configured + render mode, detected terminal graphics protocol, and effective mode. + +Pets install into `/pets//` (profile-aware). Selecting a pet +writes `display.pet.slug` + `display.pet.enabled` to `config.yaml`. + +## Configuration + +Under `display.pet` in `config.yaml`: + +- `enabled` (bool) — master on/off. +- `slug` (str) — active pet; empty = first installed. +- `render_mode` — `auto` (detect) | `kitty` | `iterm` | `sixel` | `unicode` | `off`. +- `scale` (float) — on-screen size of the native 192×208 frames (default 0.33, + clamped 0.1–3.0). One knob resizes every surface; set it with + `hermes pets scale `, the `/pet scale` slash command, or the desktop + Appearance slider. +- `unicode_cols` (int) — width in columns for the Unicode fallback. + +## Pitfalls + +- A pet only shows once one is installed AND selected (`enabled: true`). +- Inside a pipe/redirect (no TTY) terminal rendering is disabled by design. +- The petdex npm CLI installs to `~/.codex/pets`; Hermes uses its own + profile-scoped `/pets/` instead — install through `hermes pets`. + +## Verification + +- `hermes pets doctor` reports `✓ ready` when a pet is installed, selected, + enabled, and Pillow is importable. diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md index 5ccb1f5f5ca..09bb32ee472 100644 --- a/website/docs/reference/skills-catalog.md +++ b/website/docs/reference/skills-catalog.md @@ -125,6 +125,7 @@ If a skill is missing from this list but present in the repo, the catalog is reg | [`nano-pdf`](/docs/user-guide/skills/bundled/productivity/productivity-nano-pdf) | Edit PDF text/typos/titles via nano-pdf CLI (NL prompts). | `productivity/nano-pdf` | | [`notion`](/docs/user-guide/skills/bundled/productivity/productivity-notion) | Notion API + ntn CLI: pages, databases, markdown, Workers. | `productivity/notion` | | [`ocr-and-documents`](/docs/user-guide/skills/bundled/productivity/productivity-ocr-and-documents) | Extract text from PDFs/scans (pymupdf, marker-pdf). | `productivity/ocr-and-documents` | +| [`petdex`](/docs/user-guide/skills/bundled/productivity/productivity-petdex) | Install and select animated petdex mascots for Hermes. | `productivity/petdex` | | [`powerpoint`](/docs/user-guide/skills/bundled/productivity/productivity-powerpoint) | Create, read, edit .pptx decks, slides, notes, templates. | `productivity/powerpoint` | | [`teams-meeting-pipeline`](/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline) | Operate the Teams meeting summary pipeline via Hermes CLI — summarize meetings, inspect pipeline status, replay jobs, manage Microsoft Graph subscriptions. | `productivity/teams-meeting-pipeline` | diff --git a/website/docs/user-guide/features/pets.md b/website/docs/user-guide/features/pets.md new file mode 100644 index 00000000000..bd23c41f9ee --- /dev/null +++ b/website/docs/user-guide/features/pets.md @@ -0,0 +1,190 @@ +--- +sidebar_position: 11 +title: "Pets (Petdex Mascots)" +description: "Adopt an animated mascot that reacts to agent activity across the CLI, TUI, and desktop app" +--- + +# Pets + +Hermes can show an animated **pet** — a small mascot sprite that reacts to what +the agent is doing (idle, running a tool, thinking, finishing, failing) across +the **CLI**, **TUI**, and **desktop app**. Pets come from the public +[petdex](https://github.com/crafter-station/petdex) gallery. + +Pets are purely cosmetic. They have **no effect on prompt caching, tokens, or +the agent's behavior** — the sprite is a display concern only. The feature is +**off by default** and stays dormant until you install and select a pet. + +## How it works + +- Pets are installed into your profile's `pets/` directory + (`/pets//`), so each [profile](../profiles.md) keeps its + own set. +- Selecting a pet writes `display.pet.slug` and `display.pet.enabled` to + `config.yaml` — nothing is stored as a secret or env var. +- Each surface watches the activity it already tracks and maps it to one of six + animation states. The mapping lives in one place so every surface behaves the + same: + + | Agent activity | Pet state | + | --- | --- | + | A tool/turn just failed | `failed` | + | A plan finished (all todos done) | `jump` (celebrate) | + | A turn finished cleanly | `wave` | + | A tool is executing | `run` | + | The model is thinking/reading | `review` | + | Turn in flight (unspecified) | `run` | + | Blocked on you (a clarify/approval prompt is open) | `waiting` (falls back to `idle` on legacy 8-row sheets) | + | Nothing happening | `idle` | + +## Rendering + +In the terminal (CLI/TUI), Hermes renders the sprite at full fidelity when your +terminal supports a graphics protocol (**kitty**, **Ghostty**, **WezTerm**, +**iTerm2**, or **sixel**). Otherwise it falls back automatically to a truecolor +Unicode **half-block** rendering. Inside a pipe or redirect (no TTY), terminal +rendering is disabled by design. + +The desktop app draws the pet as a floating sprite on a canvas and toggles it +from **Settings → Appearance**. + +## Quick start (CLI) + +```bash +# Browse the gallery (filter by substring) +hermes pets list +hermes pets list cat + +# Install a pet and make it active in one step +hermes pets install boba --select + +# Preview / animate it in your terminal (Ctrl+C to stop) +hermes pets show + +# Check your setup +hermes pets doctor +``` + +## `hermes pets` commands + +| Goal | Command | +| --- | --- | +| Browse the gallery | `hermes pets list [query] [--limit N]` | +| List installed pets | `hermes pets list --installed` | +| Install a pet | `hermes pets install [--select] [--force]` | +| Set the active pet | `hermes pets select [slug]` (omit slug for a picker) | +| Resize the pet everywhere | `hermes pets scale ` (e.g. `0.5`, clamped 0.1–3.0) | +| Preview/animate | `hermes pets show [slug] [--state ] [--cycle] [--once] [--mode ] [--scale ]` | +| Disable the pet | `hermes pets off` | +| Remove an installed pet | `hermes pets remove ` | +| Diagnose setup | `hermes pets doctor` | + +`hermes pets show` flags: + +- `--state` — play a single state (`idle`, `wave`, `run`, `failed`, `review`, + `jump`). +- `--cycle` — cycle through every state. +- `--once` — play once instead of looping. +- `--mode` — override the render protocol (`kitty`, `iterm`, `sixel`, + `unicode`, `auto`). +- `--scale` — override the on-screen scale (`0` = use config). + +## `/pet` slash command + +Inside the CLI and TUI you can manage the pet without leaving the session: + +- `/pet` — toggle the pet on/off (adopts the first installed pet if none is + active). +- `/pet list` — browse the gallery. +- `/pet scale ` — resize the pet everywhere (e.g. `/pet scale 0.5`). +- `/pet ` — adopt a specific pet. +- `/pet off` — disable the pet. + +In the TUI, `/pet list` opens an interactive picker overlay; in the desktop app +it opens the Cmd+K pet palette. + +## Desktop app + +In the desktop app you can manage the pet two ways: + +- **Cmd+K → "Pets…"** — browse, search, adopt, and toggle pets without leaving + the keyboard (mirrors the theme picker). +- **Settings → Appearance** — the same gallery plus a **size slider** that + resizes the floating mascot live as you drag. + +Both adopt/toggle/resize the floating mascot in place — size changes apply +instantly; adopting a new pet lights it up within a moment. + +### Pop-out overlay + +**Shift-click** the floating pet to pop it out into its own transparent, +always-on-top desktop window. Out there it stays visible while Hermes is +minimized (Codex-style), so a glance tells you what the agent is doing. + +Gestures once it's popped out: + +| Gesture | Action | +| --- | --- | +| **Drag** | Move the pet anywhere on screen, even outside the app. Its spot and in/out state persist across restarts. | +| **Single-click** | Open a mini composer to send a prompt to the most recent session — without surfacing the app. | +| **Double-click** | Toggle the app window: minimize it if it's up front, restore it if it's hidden. | +| **Shift-click** | Pop the pet back into the window. | +| **Mail icon** | Appears only when a turn finished while you were away; click to raise the app on the most recent thread (and mark it read). | + +Only the popped-out pet shows a **speech bubble** (`working…`, `thinking…`, +`your turn`, …) — in-window the app itself is the surface, so the pet stays +quiet there. + +The overlay is a pure puppet of the in-app pet — it carries no separate gateway +connection and never appears in the dock or app switcher. + +## Configuration + +All settings live under `display.pet` in `config.yaml`: + +```yaml +display: + pet: + enabled: false # master on/off (true once you select a pet) + slug: "" # active pet; empty = first installed + render_mode: auto # auto | kitty | iterm | sixel | unicode | off + scale: 0.33 # master size knob (relative to native 192x208 frames) + unicode_cols: 0 # hard override for terminal width (0 = derive from scale) +``` + +- **`scale`** is the single master size knob. One number shrinks every surface: + the desktop canvas scales its pixels by it, and the CLI/TUI derive their + terminal column width from it. The half-block fallback clamps to a legibility + floor — it can't shrink as far as true-pixel kitty/GUI rendering without + turning to mush, so the same `scale` looks crisp under kitty but is floored in + half-blocks. +- **`render_mode: auto`** detects kitty/iTerm2/sixel and falls back to unicode + half-blocks. Set it explicitly to force a protocol or `off` to disable + terminal rendering while keeping the pet on the desktop. +- **`unicode_cols`** pins the terminal column width independently of `scale`; + leave it at `0` to derive width from `scale`. + +## Troubleshooting + +Run `hermes pets doctor` — it reports: + +- the pets directory and which pets are installed, +- `display.pet.enabled`, `display.pet.slug`, and the resolved active pet, +- the configured `render_mode`, the detected terminal graphics protocol, and the + effective mode for a TTY, +- whether Pillow (used for sprite decoding) is importable. + +It prints `✓ ready` once a pet is installed, selected, enabled, and Pillow is +available. + +Common gotchas: + +- A pet only shows once one is **installed AND selected** (`enabled: true`). +- Inside a pipe/redirect (no TTY), terminal rendering is disabled by design. +- The petdex npm CLI installs to `~/.codex/pets`; Hermes uses its own + profile-scoped `/pets/` instead — install through `hermes pets`. + +## See also + +- The [`petdex` skill](../skills/bundled/productivity/productivity-petdex.md) + lets the agent install and switch pets for you on request. diff --git a/website/docs/user-guide/skills/bundled/productivity/productivity-petdex.md b/website/docs/user-guide/skills/bundled/productivity/productivity-petdex.md new file mode 100644 index 00000000000..56ed48d0886 --- /dev/null +++ b/website/docs/user-guide/skills/bundled/productivity/productivity-petdex.md @@ -0,0 +1,105 @@ +--- +title: "Petdex — Install and select animated petdex mascots for Hermes" +sidebar_label: "Petdex" +description: "Install and select animated petdex mascots for Hermes" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Petdex + +Install and select animated petdex mascots for Hermes. + +## Skill metadata + +| | | +|---|---| +| Source | Bundled (installed by default) | +| Path | `skills/productivity/petdex` | +| Version | `1.0.0` | +| Author | Hermes Agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `petdex`, `mascot`, `display`, `cli`, `tui`, `desktop` | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# Petdex Skill + +Browse, install, and select animated "pet" mascots from the public +[petdex](https://github.com/crafter-station/petdex) gallery. An installed pet +reacts to agent activity (idle, running a tool, reviewing, error, done) across +the Hermes CLI, TUI, and desktop app. This skill drives the `hermes pets` CLI +and the `display.pet` config — it does not generate sprites. + +## When to Use + +- The user wants a desktop/terminal mascot or asks about "pets" / petdex. +- The user wants to change, preview, or disable the active pet. +- Diagnosing why a pet isn't showing (terminal graphics support, config). + +## Prerequisites + +- Network access to `petdex.dev` for the gallery/manifest (read-only, no auth). +- Pillow (a core Hermes dependency) for sprite decoding — already installed. +- For full-fidelity terminal rendering: a graphics-capable terminal (kitty, + Ghostty, WezTerm, iTerm2, or sixel). Otherwise a truecolor Unicode + half-block fallback is used automatically. + +## How to Run + +Use the `terminal` tool to run `hermes pets `. + +## Quick Reference + +| Goal | Command | +| --- | --- | +| Browse the gallery | `hermes pets list` (add a substring to filter: `hermes pets list cat`) | +| List installed pets | `hermes pets list --installed` | +| Install a pet | `hermes pets install ` (add `--select` to make it active) | +| Set the active pet | `hermes pets select ` (omit slug for a picker) | +| Resize the pet everywhere | `hermes pets scale ` (e.g. `0.5`, clamped 0.1–3.0) | +| Preview/animate in terminal | `hermes pets show [slug] [--cycle] [--state run]` | +| Disable the pet | `hermes pets off` | +| Remove a pet | `hermes pets remove ` | +| Diagnose setup | `hermes pets doctor` | + +## Procedure + +1. Find a pet: `hermes pets list ` and note its `slug`. +2. Install + activate: `hermes pets install --select`. +3. Preview it: `hermes pets show` (Ctrl+C to stop). +4. Confirm setup: `hermes pets doctor` — shows the resolved pet, configured + render mode, detected terminal graphics protocol, and effective mode. + +Pets install into `/pets//` (profile-aware). Selecting a pet +writes `display.pet.slug` + `display.pet.enabled` to `config.yaml`. + +## Configuration + +Under `display.pet` in `config.yaml`: + +- `enabled` (bool) — master on/off. +- `slug` (str) — active pet; empty = first installed. +- `render_mode` — `auto` (detect) | `kitty` | `iterm` | `sixel` | `unicode` | `off`. +- `scale` (float) — on-screen size of the native 192×208 frames (default 0.33, + clamped 0.1–3.0). One knob resizes every surface; set it with + `hermes pets scale `, the `/pet scale` slash command, or the desktop + Appearance slider. +- `unicode_cols` (int) — width in columns for the Unicode fallback. + +## Pitfalls + +- A pet only shows once one is installed AND selected (`enabled: true`). +- Inside a pipe/redirect (no TTY) terminal rendering is disabled by design. +- The petdex npm CLI installs to `~/.codex/pets`; Hermes uses its own + profile-scoped `/pets/` instead — install through `hermes pets`. + +## Verification + +- `hermes pets doctor` reports `✓ ready` when a pet is installed, selected, + enabled, and Pillow is importable. diff --git a/website/sidebars.ts b/website/sidebars.ts index dec160700e2..bea46c7ed56 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -102,6 +102,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/vision', 'user-guide/features/image-generation', 'user-guide/features/spotify', + 'user-guide/features/pets', 'user-guide/features/tts', 'user-guide/features/deliverable-mode', ], @@ -277,6 +278,7 @@ const sidebars: SidebarsConfig = { 'user-guide/skills/bundled/productivity/productivity-nano-pdf', 'user-guide/skills/bundled/productivity/productivity-notion', 'user-guide/skills/bundled/productivity/productivity-ocr-and-documents', + 'user-guide/skills/bundled/productivity/productivity-petdex', 'user-guide/skills/bundled/productivity/productivity-powerpoint', 'user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline', ], From 791c992b554fea2f66d8e9b2e7d56837b72ecb1a Mon Sep 17 00:00:00 2001 From: harjothkhara Date: Tue, 23 Jun 2026 01:51:35 +0530 Subject: [PATCH 006/244] fix(model_switch): route typed configured models off openai-codex (#45006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A typed `/model ` where `` is declared under `providers.` or `custom_providers` — but typed while the current provider is a soft-accepting one (e.g. `openai-codex`) — stayed on the current provider and was swallowed as an unknown hidden Codex model, instead of routing to the provider that actually declares it. Add configured-provider exact-match detection (`_configured_provider_matches`) and a new Step d.5 in `switch_model`: if the typed model is declared in user/custom provider config, route to that provider BEFORE `detect_provider_for_model()` guesses from static catalogs and BEFORE the common-path validation lets a soft-accepting current provider swallow the name. - Matching is exact (case-insensitive) against explicitly-declared model collections only (`models`, `model`, `default_model`) — never fuzzy/family. - Same-provider declarer → keep current provider (canonicalize the id). - Multiple declarers → fail clearly and ask for `--provider `. - Single declarer → route there; for `providers.` user providers, set `explicit_provider` so the credential block resolves base_url/key from config. - Step e (`detect_provider_for_model`) is gated off when `config_routed`. The deliberately-supported openai-codex / xai-oauth hidden-model soft-accept (#16172 / #19729) is left untouched: when nothing in config matches, detection is a no-op. Salvaged from #45442 by harjothkhara (authorship preserved). Tests: tests/hermes_cli/test_model_switch_configured_provider_routing.py (7 tests). Full model_switch suite: 214 passed. Fixes #45006 --- hermes_cli/model_switch.py | 135 ++++++++ ...odel_switch_configured_provider_routing.py | 310 ++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 tests/hermes_cli/test_model_switch_configured_provider_routing.py diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 7f6fe70d90a..fdb6e9f6e8a 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -662,6 +662,88 @@ def resolve_display_context_length( return None +# --------------------------------------------------------------------------- +# Configured-provider detection for typed model names +# --------------------------------------------------------------------------- + + +def _configured_provider_matches( + model_name: str, + user_providers: Optional[dict], + custom_providers: Optional[list], +) -> dict[str, str]: + """Return ``{provider_slug: canonical_model_id}`` for every configured + provider whose declared models contain an exact (case-insensitive) match + for ``model_name``. + + Used by :func:`switch_model` to route a *typed* model name to the provider + that actually declares it in user/custom provider config, instead of + leaving it on the current provider. Without this, a model declared under + ``providers.`` / ``custom_providers`` but typed while the current + provider is ``openai-codex`` stays on Codex and is soft-accepted as an + unknown hidden Codex model (#45006). + + Matching is exact (case-insensitive); the configured spelling is returned + so the downstream validation/override path sees the canonical id. Only the + explicitly-declared model collections are scanned (``models``, the singular + ``model``, and ``default_model``) — never fuzzy/family matching. + """ + if not model_name or not model_name.strip(): + return {} + target = model_name.strip().lower() + + def _match(value) -> Optional[str]: + """Canonical id if ``value`` (a model collection or scalar) declares + ``target``, else None.""" + if isinstance(value, str): + return value if value.strip().lower() == target else None + if isinstance(value, dict): + for mid in value: + if isinstance(mid, str) and mid.strip().lower() == target: + return mid + return None + if isinstance(value, (list, tuple)): + for item in value: + if isinstance(item, str) and item.strip().lower() == target: + return item + if isinstance(item, dict): + name = item.get("name") + if isinstance(name, str) and name.strip().lower() == target: + return name + return None + return None + + matches: dict[str, str] = {} + + if isinstance(user_providers, dict): + for slug, cfg in user_providers.items(): + if not isinstance(slug, str) or not isinstance(cfg, dict): + continue + for key in ("models", "model", "default_model"): + hit = _match(cfg.get(key)) + if hit: + matches[slug] = hit + break + + if isinstance(custom_providers, list): + for entry in custom_providers: + if not isinstance(entry, dict): + continue + name = entry.get("name") + if not isinstance(name, str) or not name.strip(): + continue + slug = f"custom:{name}" + if slug in matches: + continue + for key in ("models", "model", "default_model"): + hit = _match(entry.get(key)) + if hit: + matches[slug] = hit + break + + return matches + + # --------------------------------------------------------------------------- # Core model-switching pipeline # --------------------------------------------------------------------------- @@ -921,6 +1003,58 @@ def switch_model( resolved_in_current_catalog = True break + # --- Step d.5: configured-provider exact-match detection (#45006) --- + # If the typed model is declared in user/custom provider config, route + # to that provider BEFORE detect_provider_for_model() guesses from + # static catalogs and BEFORE the common-path validation can let a + # soft-accepting current provider (e.g. openai-codex) swallow the name + # as an unknown hidden model. Configured matches beat static-catalog + # detection. Unlike step e this is deliberately NOT gated on + # ``not is_custom`` — switching from a local/custom provider A to a + # configured provider B that declares the typed model is the point. + config_routed = False + if ( + not resolved_alias + and not resolved_in_current_catalog + and target_provider == current_provider + ): + cfg_matches = _configured_provider_matches( + new_model, user_providers, custom_providers + ) + if cfg_matches: + if current_provider in cfg_matches: + # The current provider itself declares it — keep current. + new_model = cfg_matches[current_provider] + config_routed = True + else: + match_slugs = sorted(cfg_matches) + if len(match_slugs) > 1: + return ModelSwitchResult( + success=False, + is_global=is_global, + error_message=( + f"'{new_model}' is declared by multiple configured " + f"providers ({', '.join(match_slugs)}). Re-run with " + f"--provider to choose which one to use." + ), + ) + target_provider = match_slugs[0] + new_model = cfg_matches[target_provider] + config_routed = True + logger.debug( + "Configured-provider detection routed '%s' to %s", + new_model, target_provider, + ) + # User-config providers (providers.) are resolved in + # the credential block via resolve_user_provider(), which is + # gated on explicit_provider. Mirror the picker so the + # rerouted user provider's base_url/key load from the passed + # config rather than a from-scratch runtime re-resolve that + # doesn't know user-config slugs. custom:* slugs resolve via + # resolve_runtime_provider() directly and need no hint. + if isinstance(user_providers, dict) and target_provider in user_providers: + explicit_provider = target_provider + # --- Step e: detect_provider_for_model() as last resort --- _base = current_base_url or "" is_custom = current_provider in {"custom", "local"} or ( @@ -932,6 +1066,7 @@ def switch_model( and not is_custom and not resolved_alias and not resolved_in_current_catalog + and not config_routed ): detected = detect_provider_for_model(new_model, current_provider) if detected: diff --git a/tests/hermes_cli/test_model_switch_configured_provider_routing.py b/tests/hermes_cli/test_model_switch_configured_provider_routing.py new file mode 100644 index 00000000000..361aa55f706 --- /dev/null +++ b/tests/hermes_cli/test_model_switch_configured_provider_routing.py @@ -0,0 +1,310 @@ +"""Regression tests for #45006: typed `/model ` resolution must route a +model declared in user/custom provider config to that provider instead of +leaving it on the current provider and soft-accepting it. + +Repro: with the current provider set to ``openai-codex``, typing +``/model qwen3.5-4b`` (a model the user declares under ``providers.`` or +``custom_providers``) showed ``Provider: OpenAI Codex`` — because typed +detection only consulted static catalogs / OpenRouter, never the user's +configured provider model lists, so the name stayed on Codex and was +soft-accepted as an unknown hidden Codex model. + +The fix adds an exact-match configured-provider detection step in +``switch_model`` that runs before ``detect_provider_for_model`` and before +common-path validation. These tests pin its precedence rules and prove the +deliberately-supported Codex hidden-model soft-accept (#16172 / #19729) is left +intact when nothing in config matches. + +Hermetic: the model-resolution chain is fully mocked (no network), mirroring +``tests/hermes_cli/test_user_providers_model_switch.py``. +""" + +from unittest.mock import patch + +from hermes_cli.model_switch import switch_model + +_ACCEPTED = {"accepted": True, "persist": True, "recognized": True, "message": None} +_REJECTED = {"accepted": False, "persist": False, "recognized": False, "message": "not found"} +# What validate_requested_model returns for an unknown id on openai-codex: it +# soft-accepts with a "may be a hidden model" note (#16172 / #19729). +_CODEX_SOFT_ACCEPT = { + "accepted": True, + "persist": True, + "recognized": False, + "message": ( + "Note: `gpt-5.9-codex-hidden` was not found in the OpenAI Codex model " + "listing. It may still work if your account has access to a newer or " + "hidden model ID." + ), +} + + +def _run_switch( + *, + raw_input, + current_provider, + user_providers=None, + custom_providers=None, + validation=_ACCEPTED, + current_model="old-model", + current_base_url="", +): + """Drive ``switch_model`` with the resolution chain mocked out. + + Every external lookup that would otherwise hit catalogs/network is patched: + alias resolution, aggregator catalog, ``detect_provider_for_model`` (so step + e is a no-op and cannot accidentally reroute), validation, credential + resolution, normalization, and model metadata. This isolates the new + configured-provider detection step. + """ + with patch("hermes_cli.model_switch.resolve_alias", return_value=None), \ + patch("hermes_cli.model_switch.list_provider_models", return_value=[]), \ + patch("hermes_cli.model_switch.normalize_model_for_provider", side_effect=lambda model, provider: model), \ + patch("hermes_cli.models.validate_requested_model", return_value=validation), \ + patch("hermes_cli.models.detect_provider_for_model", return_value=None), \ + patch("hermes_cli.model_switch.get_model_info", return_value=None), \ + patch("hermes_cli.model_switch.get_model_capabilities", return_value=None), \ + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "***", + "base_url": current_base_url or "http://resolved/v1", + "api_mode": "", + }, + ): + return switch_model( + raw_input=raw_input, + current_provider=current_provider, + current_model=current_model, + current_base_url=current_base_url, + user_providers=user_providers or {}, + custom_providers=custom_providers or [], + ) + + +def test_typed_configured_model_routes_away_from_openai_codex(): + """The core repro: a model declared under ``providers.`` typed while + on ``openai-codex`` routes to the configured provider, not Codex.""" + user_providers = { + "local-ollama": { + "name": "Local Ollama", + "base_url": "http://localhost:11434/v1", + "models": ["qwen3.5-4b", "kimi-k2.5"], + } + } + result = _run_switch( + raw_input="qwen3.5-4b", + current_provider="openai-codex", + current_model="gpt-5.4", + user_providers=user_providers, + ) + assert result.success is True, result.error_message + assert result.target_provider == "local-ollama" + assert result.new_model == "qwen3.5-4b" + + +def test_typed_configured_model_routes_to_custom_provider(): + """``custom_providers`` entries route to their ``custom:`` slug.""" + custom_providers = [ + { + "name": "mylocal", + "base_url": "http://localhost:1234/v1", + "model": "qwen3.5-4b", + "models": {"qwen3.5-4b": {}}, + } + ] + result = _run_switch( + raw_input="qwen3.5-4b", + current_provider="openai-codex", + current_model="gpt-5.4", + custom_providers=custom_providers, + ) + assert result.success is True, result.error_message + assert result.target_provider == "custom:mylocal" + assert result.new_model == "qwen3.5-4b" + + +def test_current_provider_declaring_model_is_not_rerouted(): + """Precedence rule 4: if the current provider declares the model, keep it — + even when another configured provider also declares the same id (so this + must NOT trip the ambiguity guard).""" + user_providers = { + "local-ollama": { + "name": "Local Ollama", + "base_url": "http://localhost:11434/v1", + "models": ["qwen3.5-4b"], + }, + "other-relay": { + "name": "Other Relay", + "base_url": "http://other/v1", + "models": ["qwen3.5-4b"], + }, + } + result = _run_switch( + raw_input="qwen3.5-4b", + current_provider="local-ollama", + current_model="kimi-k2.5", + current_base_url="http://localhost:11434/v1", + user_providers=user_providers, + ) + assert result.success is True, result.error_message + assert result.target_provider == "local-ollama" + + +def test_ambiguous_configured_model_fails_with_provider_hint(): + """Precedence rule 6: when two non-current providers declare the same id and + neither is current, fail clearly and point at ``--provider`` — never + silently pick the first match.""" + user_providers = { + "relay-a": { + "name": "Relay A", + "base_url": "http://a/v1", + "models": ["qwen3.5-4b"], + }, + "relay-b": { + "name": "Relay B", + "base_url": "http://b/v1", + "models": ["qwen3.5-4b"], + }, + } + result = _run_switch( + raw_input="qwen3.5-4b", + current_provider="openai-codex", + current_model="gpt-5.4", + user_providers=user_providers, + ) + assert result.success is False + assert "--provider" in result.error_message + assert "relay-a" in result.error_message + assert "relay-b" in result.error_message + + +def test_configured_model_absent_from_live_models_accepted_after_reroute(): + """End-to-end synergy: after rerouting to the configured provider, a live + ``/v1/models`` probe that does NOT list the model is still accepted via the + existing user-config override — proving the reroute lands on the right + provider for that override to match.""" + user_providers = { + "local-ollama": { + "name": "Local Ollama", + "base_url": "http://localhost:11434/v1", + "models": {"qwen3.5-4b": {"context_length": 32768}}, + } + } + result = _run_switch( + raw_input="qwen3.5-4b", + current_provider="openai-codex", + current_model="gpt-5.4", + user_providers=user_providers, + validation=_REJECTED, + ) + assert result.success is True, result.error_message + assert result.target_provider == "local-ollama" + assert result.new_model == "qwen3.5-4b" + + +def test_no_configured_match_leaves_current_provider_for_soft_accept(): + """The Codex hidden-model soft-accept (#16172 / #19729) is untouched: an + unknown id with no config match stays on the current provider and is + soft-accepted exactly as before.""" + result = _run_switch( + raw_input="gpt-5.9-codex-hidden", + current_provider="openai-codex", + current_model="gpt-5.4", + # Config is present but declares an unrelated model — detection is a no-op. + user_providers={ + "local-ollama": { + "base_url": "http://localhost:11434/v1", + "models": ["qwen3.5-4b"], + } + }, + validation=_CODEX_SOFT_ACCEPT, + ) + assert result.success is True, result.error_message + assert result.target_provider == "openai-codex" + assert result.new_model == "gpt-5.9-codex-hidden" + + +def test_configured_match_is_case_insensitive_and_returns_canonical_spelling(): + """Matching is case-insensitive but the configured spelling wins, so the + downstream validation/override path sees the canonical id.""" + user_providers = { + "local-ollama": { + "base_url": "http://localhost:11434/v1", + "models": ["Qwen3.5-4B"], + } + } + result = _run_switch( + raw_input="qwen3.5-4b", + current_provider="openai-codex", + current_model="gpt-5.4", + user_providers=user_providers, + ) + assert result.success is True, result.error_message + assert result.target_provider == "local-ollama" + assert result.new_model == "Qwen3.5-4B" + + +def test_default_model_only_declaration_routes(): + """A model declared ONLY via `default_model` (not in `models`) still routes + to that configured provider (#45006 — default_model is a declaring field).""" + user_providers = { + "local-ollama": { + "name": "Local Ollama", + "base_url": "http://localhost:11434/v1", + "default_model": "qwen3.5-4b", + } + } + result = _run_switch( + raw_input="qwen3.5-4b", + current_provider="openai-codex", + current_model="gpt-5.4", + user_providers=user_providers, + ) + assert result.success is True, result.error_message + assert result.target_provider == "local-ollama" + assert result.new_model == "qwen3.5-4b" + + +def test_malformed_provider_config_does_not_raise(): + """Garbage shapes in provider config must not crash detection — they're + skipped and the typed name falls through to the soft-accept no-op.""" + user_providers = { + "bad1": "not-a-dict", # non-dict cfg + "bad2": {"models": 12345}, # models as int + "bad3": {"models": [None, 7, {"noname": "x"}]}, # junk list items + "bad4": {"model": {"k": object()}}, # dict with non-target keys + } + custom_providers = [ + "not-a-dict", # non-dict entry + {"name": ""}, # empty name + {"models": ["unrelated-model"]}, # no name key + ] + result = _run_switch( + raw_input="gpt-5.9-codex-hidden", + current_provider="openai-codex", + current_model="gpt-5.4", + user_providers=user_providers, + custom_providers=custom_providers, + validation=_CODEX_SOFT_ACCEPT, + ) + # No match anywhere -> stays on codex, soft-accepted, no exception. + assert result.success is True, result.error_message + assert result.target_provider == "openai-codex" + + +def test_xai_oauth_soft_accept_preserved_when_no_match(): + """The xai-oauth hidden-model soft-accept (sibling of openai-codex) is also + a no-op when config declares no matching model.""" + user_providers = { + "local-ollama": {"base_url": "http://x/v1", "models": ["some-other-model"]}, + } + result = _run_switch( + raw_input="grok-hidden-preview", + current_provider="xai-oauth", + current_model="grok-4", + user_providers=user_providers, + validation=_CODEX_SOFT_ACCEPT, + ) + assert result.success is True, result.error_message + assert result.target_provider == "xai-oauth" From a4e61ddf043864b88a5a40df0e23efcf3c6334f5 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:33:13 +0530 Subject: [PATCH 007/244] fix(cron): fail closed when an unpinned job's provider drifts from creation snapshot (#44585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unpinned cron job follows the global default provider (config.yaml model.default + resolve_runtime_provider). If that global state is changed after the job is created — e.g. a temporary switch to a paid provider like nous/claude-fable-5 — the job silently inherits it on its next tick and spends real money. This is the reported $7.73 incident: a job created under a free/default provider later inherited a temporary paid switch. Fix (ask #1 only) preserves the legitimate "unpinned job should follow model.default" use case by detecting *drift* rather than freezing the model: - create_job (cron/jobs.py): for UNPINNED, agent-backed jobs (no explicit provider, not no_agent), snapshot the provider that resolution WOULD pick right now into a new optional `provider_snapshot` field, resolved via the same resolve_runtime_provider() path the ticker uses. Fail-open to None on any resolution error so job creation never breaks. - run_job (cron/scheduler.py): right after runtime resolution, if the job has a provider_snapshot AND is unpinned AND the currently-resolved provider DIFFERS from the snapshot, fail closed for that run — make no paid call and deliver a loud, actionable alert naming both providers and telling the user to pin explicitly (`cronjob action=update job_id=.. provider=..`). Back-compat: jobs with no snapshot (pre-existing jobs, no_agent jobs, or any job whose creation-time resolution failed) behave exactly as before — the guard only engages when a snapshot exists. Explicitly-pinned jobs (job.provider set) are unaffected since they don't drift with global state. Tests: tests/cron/test_cron_provider_pin.py covers snapshot-matches (runs), snapshot-differs (fail closed, no agent constructed), no-snapshot back-compat, None-snapshot back-compat, explicitly-pinned (runs regardless), plus create_job snapshot capture/skip/fail-open. The fail-closed case is load-bearing (fails without the guard). Issue #44585 asks #2-4 (hard-stop a running job, gateway-stop containment, fail-closed on provider mutation) are out of scope for this change. --- cron/jobs.py | 85 ++++++ cron/scheduler.py | 54 ++++ tests/cron/test_cron_provider_pin.py | 334 +++++++++++++++++++++++ website/docs/user-guide/features/cron.md | 2 +- 4 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 tests/cron/test_cron_provider_pin.py diff --git a/cron/jobs.py b/cron/jobs.py index 6ec6d5be123..fd7bbd8a8a2 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -755,6 +755,45 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]: return str(resolved) +def _resolve_default_model_snapshot() -> Optional[str]: + """Resolve the global default model the same way the cron ticker does. + + Mirrors the unpinned-model resolution in ``cron/scheduler.py`` ``run_job``: + read ``config.yaml`` ``model.default`` (or the ``model`` alias / bare string + form), applying the managed-scope overlay and env expansion. Used by + ``create_job`` to snapshot the default model for unpinned jobs so a later + swap of the global default is detected at fire time (#44585). + + Returns the resolved model string, or ``None`` if config is missing/empty + or resolution fails (fail-open — caller treats ``None`` as "no snapshot"). + """ + try: + import yaml + from hermes_cli.config import _expand_env_vars + + cfg_path = get_hermes_home() / "config.yaml" + if not cfg_path.exists(): + return None + with cfg_path.open(encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + try: + from hermes_cli import managed_scope + cfg = managed_scope.apply_managed_overlay(cfg) + except Exception: + pass + cfg = _expand_env_vars(cfg) + model_cfg = cfg.get("model") or {} + if isinstance(model_cfg, str): + return model_cfg.strip() or None + if isinstance(model_cfg, dict): + default = model_cfg.get("default") or model_cfg.get("model") + if isinstance(default, str): + return default.strip() or None + return None + except Exception: + return None + + def create_job( prompt: Optional[str], schedule: str, @@ -870,6 +909,47 @@ def create_job( prompt_text = _coerce_job_text(prompt) label_source = (prompt_text or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or "cron job" + + # Provider/model-drift guard (#44585). When the caller does NOT pin a + # provider and/or model, the job follows the global default — model.default + # in config.yaml and whatever resolve_runtime_provider() picks at fire time. + # That global state can change (e.g. a temporary switch to a paid provider + # OR a paid model like claude-fable-5 on the SAME provider), and an unpinned + # job would then silently inherit it and spend real money. To detect that, + # snapshot what resolution WOULD pick *right now*, at creation, for each + # axis the job leaves unpinned. The fire-time guard (run_job) fails closed + # when an unpinned job's currently-resolved provider OR model differs from + # its snapshot. + # + # Only captured for agent-backed jobs (no_agent script jobs make no paid + # inference). Each axis is snapshotted only when that axis is unpinned — + # a pinned provider/model doesn't drift with global state. Fail-open to None + # on any resolution error so job creation never breaks; a missing snapshot + # preserves the legacy no-guard behaviour for that axis. + provider_snapshot: Optional[str] = None + model_snapshot: Optional[str] = None + if not normalized_no_agent: + if normalized_provider is None: + try: + from hermes_cli.runtime_provider import resolve_runtime_provider + _runtime_kwargs = {"requested": None} + if normalized_base_url: + _runtime_kwargs["explicit_base_url"] = normalized_base_url + _snap = resolve_runtime_provider(**_runtime_kwargs) + _snap_provider = str(_snap.get("provider") or "").strip().lower() + provider_snapshot = _snap_provider or None + except Exception: + provider_snapshot = None + if normalized_model is None: + # Mirror the fire-time unpinned-model resolution (run_job reads + # config.yaml model.default / model). Capture that value so a later + # swap of the global default model is detected even when the + # provider is unchanged (e.g. a premium model on the same endpoint). + try: + model_snapshot = _resolve_default_model_snapshot() or None + except Exception: + model_snapshot = None + job = { "id": job_id, "name": name or label_source[:50].strip(), @@ -878,6 +958,11 @@ def create_job( "skill": normalized_skills[0] if normalized_skills else None, "model": normalized_model, "provider": normalized_provider, + # Provider/model resolution captured at creation for unpinned jobs + # (#44585). None for pinned axes, no_agent jobs, resolution failures, and + # any pre-existing job written before these fields existed (back-compat). + "provider_snapshot": provider_snapshot, + "model_snapshot": model_snapshot, "base_url": normalized_base_url, "script": normalized_script, "no_agent": normalized_no_agent, diff --git a/cron/scheduler.py b/cron/scheduler.py index c48935c84a6..b3c3f95c5b7 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2016,6 +2016,60 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: message = format_runtime_provider_error(exc) raise RuntimeError(message) from exc + # Provider/model-drift fail-closed guard (#44585). + # + # An UNPINNED job (no explicit job["provider"]/["model"]) follows the + # global default, which can change after the job was created — a switch + # to a paid PROVIDER (e.g. nous) OR a paid MODEL on the same provider + # (e.g. claude-fable-5 on openrouter). Without a guard the job would + # silently inherit that change and spend real money on every tick — the + # $7.73 incident named BOTH a provider and a model. + # + # create_job() snapshots whatever resolution would have picked at + # creation for each unpinned axis (job["provider_snapshot"] / + # job["model_snapshot"]). Here, for each axis that (a) has a snapshot and + # (b) is unpinned and (c) currently resolves to a DIFFERENT value, we + # fail closed: skip this run, make NO paid call, and deliver a loud, + # actionable alert telling the user to pin the axis explicitly. + # + # Back-compat: an axis with no snapshot (pre-existing jobs, no_agent, or + # any axis whose creation-time resolution failed) behaves exactly as + # before — the guard never engages for it. Pinned axes are unaffected. + _drift: list[str] = [] + _provider_snapshot = (job.get("provider_snapshot") or "").strip().lower() + if _provider_snapshot and not (job.get("provider") or "").strip(): + _current_provider = str(runtime.get("provider") or "").strip().lower() + if _current_provider and _current_provider != _provider_snapshot: + _drift.append( + f"provider '{_provider_snapshot}' -> '{_current_provider}'" + ) + _model_snapshot = (job.get("model_snapshot") or "").strip().lower() + if _model_snapshot and not (job.get("model") or "").strip(): + _current_model = str(model or "").strip().lower() + if _current_model and _current_model != _model_snapshot: + _drift.append( + f"model '{_model_snapshot}' -> '{_current_model}'" + ) + if _drift: + _changes = "; ".join(_drift) + logger.warning( + "Job '%s': SKIPPED — global inference config drifted since " + "creation (%s) and this job is unpinned. Skipped to prevent " + "unintended spend. Pin explicitly to proceed: " + "`cronjob action=update job_id=%s provider=

model=`.", + job_id, + _changes, + job_id, + ) + raise RuntimeError( + f"Skipped to prevent unintended spend: global inference config " + f"drifted since this job was created ({_changes}), and this job " + f"is unpinned. No inference call was made. To run on the new " + f"config, pin it explicitly: `cronjob action=update " + f"job_id={job_id} provider= model=` " + f"(or pin the original values to keep them). See #44585." + ) + fallback_model = _cfg.get("fallback_providers") or _cfg.get("fallback_model") or None credential_pool = None runtime_provider = str(runtime.get("provider") or "").strip().lower() diff --git a/tests/cron/test_cron_provider_pin.py b/tests/cron/test_cron_provider_pin.py new file mode 100644 index 00000000000..e5d06cc212d --- /dev/null +++ b/tests/cron/test_cron_provider_pin.py @@ -0,0 +1,334 @@ +"""Provider-drift fail-closed guard for cron jobs (#44585). + +Background: an UNPINNED cron job follows the global default provider. If that +global state is changed (e.g. a temporary switch to a paid provider like +nous/claude-fable-5), the job would silently inherit it on its next tick and +spend real money — the $7.73 incident. + +The fix has two halves: + - create_job() snapshots the provider resolution WOULD pick at creation into + job["provider_snapshot"] (only for unpinned, agent-backed jobs). + - run_job() fails closed when an unpinned job's CURRENTLY-resolved provider + differs from that snapshot: it skips the run, makes no paid call, and + delivers a loud actionable error. + +These tests exercise the full run_job path (real imports, mocked AIAgent + +resolve_runtime_provider against a temp HERMES_HOME) and the create_job +snapshot capture. They are load-bearing: without the guard, cases (b) call the +agent and "succeed" instead of failing closed. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Ensure project root is importable. +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from cron.scheduler import run_job + + +def _base_job(**overrides): + job = { + "id": "pin-test", + "name": "pin test", + "prompt": "hello", + "model": None, + "provider": None, + "provider_snapshot": None, + "base_url": None, + } + job.update(overrides) + return job + + +def _run_with_current_provider(job, current_provider, tmp_path): + """Drive run_job with resolve_runtime_provider pinned to ``current_provider``. + + Returns (success, output, final_response, error, agent_constructed). + """ + fake_db = MagicMock() + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": current_provider, + "api_mode": "chat_completions", + }, + ), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + + success, output, final_response, error = run_job(job) + agent_constructed = mock_agent_cls.called + + return success, output, final_response, error, agent_constructed + + +class TestProviderDriftGuard: + def test_a_unpinned_snapshot_matches_runs_normally(self, tmp_path): + """(a) Unpinned job whose snapshot == current provider → runs normally.""" + job = _base_job(provider_snapshot="openrouter") + success, output, final_response, error, agent_constructed = \ + _run_with_current_provider(job, "openrouter", tmp_path) + + assert success is True + assert error is None + assert final_response == "ok" + assert agent_constructed is True + + def test_b_unpinned_snapshot_differs_fails_closed(self, tmp_path): + """(b) Unpinned job whose snapshot != current provider → fail closed. + + The paid call must NOT be made (AIAgent never constructed) and the + delivered error must name both providers and tell the user to pin. + """ + job = _base_job(provider_snapshot="openrouter") + success, output, final_response, error, agent_constructed = \ + _run_with_current_provider(job, "nous", tmp_path) + + # Fail closed: no agent constructed, no inference call. + assert agent_constructed is False + assert success is False + assert error is not None + + # Loud + actionable: names both providers, mentions spend + pinning. + blob = f"{error}\n{output}".lower() + assert "openrouter" in blob + assert "nous" in blob + assert "spend" in blob + assert "cronjob action=update" in blob + assert "44585" in blob + + def test_c_no_snapshot_runs_backcompat(self, tmp_path): + """(c) Pre-existing job with NO provider_snapshot → runs (back-compat). + + Even though the current provider differs from anything, a job without a + snapshot must behave exactly as before this fix: the guard never engages. + """ + # A job dict that predates the field entirely (key absent, not None). + job = _base_job() + job.pop("provider_snapshot", None) + success, output, final_response, error, agent_constructed = \ + _run_with_current_provider(job, "nous", tmp_path) + + assert success is True + assert error is None + assert agent_constructed is True + + def test_c2_snapshot_none_runs_backcompat(self, tmp_path): + """(c') Job with provider_snapshot explicitly None → runs (back-compat).""" + job = _base_job(provider_snapshot=None) + success, output, final_response, error, agent_constructed = \ + _run_with_current_provider(job, "nous", tmp_path) + + assert success is True + assert error is None + assert agent_constructed is True + + def test_d_explicitly_pinned_runs_regardless_of_drift(self, tmp_path): + """(d) Explicitly-pinned job (job["provider"] set) → runs regardless. + + A pinned job does not follow global state, so even a snapshot/current + mismatch must not skip it. (Snapshot would normally be None for pinned + jobs, but we set a mismatching one to prove the pin wins.) + """ + job = _base_job(provider="openrouter", provider_snapshot="anthropic") + # Current resolution differs from the (stale) snapshot, but the job is + # pinned, so the guard must not engage. + success, output, final_response, error, agent_constructed = \ + _run_with_current_provider(job, "nous", tmp_path) + + assert success is True + assert error is None + assert agent_constructed is True + + +class TestCreateJobSnapshot: + """create_job captures provider_snapshot for unpinned agent jobs only.""" + + @staticmethod + def _isolate_storage(monkeypatch): + """Patch cron.jobs storage so create_job never touches the real store.""" + import contextlib + import cron.jobs as jobs + + @contextlib.contextmanager + def _noop_lock(): + yield + + monkeypatch.setattr(jobs, "_jobs_lock", _noop_lock, raising=True) + monkeypatch.setattr(jobs, "load_jobs", lambda: [], raising=True) + monkeypatch.setattr(jobs, "save_jobs", lambda j: None, raising=True) + return jobs + + def test_unpinned_job_captures_snapshot(self, monkeypatch): + jobs = self._isolate_storage(monkeypatch) + + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={"provider": "openrouter"}, + ): + job = jobs.create_job(prompt="do a thing", schedule="every 1 hour") + + assert job["provider"] is None + assert job["provider_snapshot"] == "openrouter" + + def test_pinned_job_skips_snapshot(self, monkeypatch): + jobs = self._isolate_storage(monkeypatch) + + resolver = MagicMock(return_value={"provider": "openrouter"}) + with patch("hermes_cli.runtime_provider.resolve_runtime_provider", resolver): + job = jobs.create_job( + prompt="do a thing", schedule="every 1 hour", provider="nous" + ) + + # Explicit provider → pinned → no snapshot needed, and resolution skipped. + assert job["provider"] == "nous" + assert job["provider_snapshot"] is None + resolver.assert_not_called() + + def test_snapshot_resolution_error_fails_open_to_none(self, monkeypatch): + """If resolution raises at creation, snapshot is None — creation never breaks.""" + jobs = self._isolate_storage(monkeypatch) + + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=RuntimeError("no creds"), + ): + job = jobs.create_job(prompt="do a thing", schedule="every 1 hour") + + assert job["provider_snapshot"] is None + + def test_unpinned_model_captures_model_snapshot(self, monkeypatch, tmp_path): + """An unpinned model captures config.yaml model.default into model_snapshot.""" + jobs = self._isolate_storage(monkeypatch) + (tmp_path / "config.yaml").write_text("model:\n default: llama-3.3-70b:free\n") + monkeypatch.setattr( + "cron.jobs.get_hermes_home", lambda: tmp_path, raising=True + ) + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={"provider": "openrouter"}, + ): + job = jobs.create_job(prompt="do a thing", schedule="every 1 hour") + assert job["model"] is None + assert job["model_snapshot"] == "llama-3.3-70b:free" + + def test_pinned_model_skips_model_snapshot(self, monkeypatch, tmp_path): + """An explicit model → pinned → no model_snapshot captured.""" + jobs = self._isolate_storage(monkeypatch) + (tmp_path / "config.yaml").write_text("model:\n default: llama-3.3-70b:free\n") + monkeypatch.setattr( + "cron.jobs.get_hermes_home", lambda: tmp_path, raising=True + ) + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={"provider": "openrouter"}, + ): + job = jobs.create_job( + prompt="do a thing", schedule="every 1 hour", model="my-model" + ) + assert job["model"] == "my-model" + assert job["model_snapshot"] is None + + +def _run_with_current_provider_and_model(job, current_provider, current_model, tmp_path): + """Drive run_job with resolved provider pinned and config.yaml model.default + set to ``current_model`` (the unpinned-model fire-time source).""" + (tmp_path / "config.yaml").write_text( + f"model:\n default: {current_model}\n" + ) + fake_db = MagicMock() + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._get_hermes_home", return_value=tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": current_provider, + "api_mode": "chat_completions", + }, + ), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + success, output, final_response, error = run_job(job) + agent_constructed = mock_agent_cls.called + return success, output, final_response, error, agent_constructed + + +class TestModelDriftGuard: + """#44585 C1: model drift on the SAME provider must also fail closed — + the incident named a model (claude-fable-5), and an unpinned job reads + config.yaml model.default fresh every tick independently of provider.""" + + def test_model_drift_same_provider_fails_closed(self, tmp_path): + # Provider unchanged (openrouter==openrouter), but the global default + # MODEL swapped to a premium model since creation → must fail closed. + job = _base_job( + provider_snapshot="openrouter", + model_snapshot="llama-3.3-70b-instruct:free", + ) + success, output, final_response, error, agent_constructed = \ + _run_with_current_provider_and_model( + job, "openrouter", "claude-fable-5", tmp_path + ) + assert agent_constructed is False, "paid call must not be made on model drift" + assert success is False + blob = f"{error}\n{output}".lower() + assert "claude-fable-5" in blob + assert "llama-3.3-70b-instruct:free" in blob + assert "44585" in blob + + def test_model_snapshot_matches_runs(self, tmp_path): + # Default model unchanged → runs normally. + job = _base_job( + provider_snapshot="openrouter", + model_snapshot="llama-3.3-70b-instruct:free", + ) + success, output, final_response, error, agent_constructed = \ + _run_with_current_provider_and_model( + job, "openrouter", "llama-3.3-70b-instruct:free", tmp_path + ) + assert agent_constructed is True + assert success is True + + def test_pinned_model_bypasses_guard(self, tmp_path): + # Explicit job["model"] → not unpinned → no model-drift skip even if the + # global default differs from any snapshot. + job = _base_job( + provider_snapshot="openrouter", + model_snapshot="old-model", + model="my-pinned-model", + ) + success, output, final_response, error, agent_constructed = \ + _run_with_current_provider_and_model( + job, "openrouter", "claude-fable-5", tmp_path + ) + assert agent_constructed is True + assert success is True + + def test_no_model_snapshot_backcompat(self, tmp_path): + # Pre-existing job without model_snapshot → no model-drift skip. + job = _base_job(provider_snapshot="openrouter") # no model_snapshot key set to a value + success, output, final_response, error, agent_constructed = \ + _run_with_current_provider_and_model( + job, "openrouter", "claude-fable-5", tmp_path + ) + assert agent_constructed is True + assert success is True diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 89357043d70..c3444f98d38 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -22,7 +22,7 @@ Cron jobs can: All of this is available to Hermes itself through the `cronjob` tool, so you can create, pause, edit, and remove jobs by asking in plain language — no CLI required. :::tip -Cron jobs use whatever provider `hermes model` selected. `hermes setup --portal` is the lowest-friction option for unattended runs since OAuth refresh is automatic. See [Nous Portal](/integrations/nous-portal). +At creation, an unpinned job (one you don't give an explicit `provider`/`model`) follows the global default selected by `hermes model` — and Hermes **snapshots** that provider and model on the job. If the global default later changes, the job **fails closed**: it skips the run, makes no inference call, and sends an alert telling you to pin the provider/model explicitly (`cronjob action=update job_id=… provider=… model=…`) to proceed. This prevents an unattended job from silently inheriting a switch to a paid provider/model and spending money you didn't intend (#44585). To make a job deliberately track your global default, pin it to the new values after changing them. `hermes setup --portal` is the lowest-friction option for unattended runs since OAuth refresh is automatic. See [Nous Portal](/integrations/nous-portal). ::: :::warning From 23c47371d23f7ff727c9430b5295ff8dd8622a13 Mon Sep 17 00:00:00 2001 From: kyssta-exe Date: Tue, 16 Jun 2026 12:21:27 +0000 Subject: [PATCH 008/244] fix(mcp): skip killpg when child shares gateway's process group (#47134) /reload-mcp -> shutdown_mcp_servers -> _kill_orphaned_mcp_children(include_active=True) -> _send_signal -> killpg(pgid, SIGTERM). When a tracked MCP stdio child shares the gateway's OWN process group, killpg delivers SIGTERM to the gateway itself, firing its SIGTERM handler -> os._exit(0): /reload-mcp crashes the gateway. Pre-compute the gateway's own pgid (os.getpgrp(), None on Windows/restricted) and, in _send_signal, skip killpg when pgid == own pgid, falling through to the per-pid os.kill path so the child is still reaped without self-signaling. Adds a regression test (folded in) that pins the guard: with a tracked pgid equal to the gateway's own pgid, killpg is never called for that pgid and the per-pid kill fallback is used. Mutation-checked. Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> --- tests/tools/test_mcp_stability.py | 50 +++++++++++++++++++++++++++++++ tools/mcp_tool.py | 39 ++++++++++++++++++------ 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/tests/tools/test_mcp_stability.py b/tests/tools/test_mcp_stability.py index feb0d7a5aff..494ebbbe024 100644 --- a/tests/tools/test_mcp_stability.py +++ b/tests/tools/test_mcp_stability.py @@ -260,6 +260,56 @@ class TestStdioPgroupReaping: assert fake_pid not in _orphan_stdio_pids assert fake_pid not in _stdio_pgids + def test_killpg_skipped_when_pgid_matches_gateway_own_pgroup(self, monkeypatch): + """#47134: when a tracked MCP child shares the gateway's OWN process + group, killpg(pgid) would signal the gateway itself and crash it. + The guard must skip killpg for that pgid and fall through to per-pid + os.kill instead.""" + from tools.mcp_tool import ( + _kill_orphaned_mcp_children, + _orphan_stdio_pids, + _stdio_pgids, + _lock, + ) + + if not hasattr(os, "killpg") or not hasattr(os, "getpgrp"): + pytest.skip("os.killpg/os.getpgrp not available on this platform") + + self._reset_state() + gateway_pgid = 424242 + fake_pid = 717171 # a child pid that resolves to the gateway's pgid + other_pid = 818181 # a normal child in its OWN (non-gateway) group + other_pgid = 818181 + with _lock: + _orphan_stdio_pids.add(fake_pid) + _stdio_pgids[fake_pid] = gateway_pgid # == gateway's own pgid + _orphan_stdio_pids.add(other_pid) + _stdio_pgids[other_pid] = other_pgid # distinct group → killpg OK + + fake_sigkill = 9 + monkeypatch.setattr(signal, "SIGKILL", fake_sigkill, raising=False) + + with patch("tools.mcp_tool.os.getpgrp", return_value=gateway_pgid), \ + patch("tools.mcp_tool.os.killpg") as mock_killpg, \ + patch("tools.mcp_tool.os.kill") as mock_kill, \ + patch("gateway.status._pid_exists", return_value=True), \ + patch("time.sleep"): + _kill_orphaned_mcp_children() + + # killpg must NEVER be called for the gateway's own pgid (would self-kill). + killpg_pgids = [call.args[0] for call in mock_killpg.call_args_list] + assert gateway_pgid not in killpg_pgids, ( + "killpg was called with the gateway's own pgid — self-kill (#47134)" + ) + # The shared-pgid child must be reaped via per-pid kill instead. + mock_kill.assert_any_call(fake_pid, signal.SIGTERM) + mock_kill.assert_any_call(fake_pid, fake_sigkill) + # NEGATIVE CONTROL: a child in a DISTINCT group must STILL use killpg — + # the guard must skip only the gateway's own group, not all pgids. + assert other_pgid in killpg_pgids, ( + "killpg must still be used for a non-gateway pgid (guard too broad)" + ) + def test_killpg_failure_falls_back_to_kill(self, monkeypatch): """If killpg raises ProcessLookupError (pgroup gone), try os.kill.""" from tools.mcp_tool import ( diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index e4448bacd25..c31215ae09a 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -4643,21 +4643,42 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None: if not pids: return + # Pre-compute the gateway's own pgid so _send_signal can avoid killing it. + try: + _my_pgid = os.getpgrp() + except (AttributeError, OSError): + _my_pgid = None # Windows or restricted environment + def _send_signal(pid: int, sig: int, server_name: str) -> None: """SIGTERM/SIGKILL via pgroup on POSIX, fall back to pid signal.""" pgid = pgids.get(pid) killpg = getattr(os, "killpg", None) if pgid is not None and killpg is not None: - try: - killpg(pgid, sig) - return - except (ProcessLookupError, PermissionError, OSError) as exc: - # Pgroup gone (all members exited) or refused — fall back to - # the per-pid path so we still try the direct child if alive. - logger.debug( - "killpg(%d, %d) failed for MCP server '%s': %s; falling back to kill(pid)", - pgid, sig, server_name, exc, + if _my_pgid is not None and pgid == _my_pgid: + # The MCP child shares the gateway's own process group. + # Using killpg would deliver the signal to the gateway as + # well, crashing it (see #47134). Fall through to the + # per-pid kill() path instead. Warn because per-pid kill + # cannot reach grandchildren in this shared group — if the + # direct child has already exited, they may leak (inherent: + # group-killing them would also kill the gateway). + logger.warning( + "MCP server '%s' pgid %d matches gateway pgid; skipping " + "killpg to avoid self-kill and using per-pid kill — any " + "grandchildren in this group may not be reaped", + server_name, pgid, ) + else: + try: + killpg(pgid, sig) + return + except (ProcessLookupError, PermissionError, OSError) as exc: + # Pgroup gone (all members exited) or refused — fall back to + # the per-pid path so we still try the direct child if alive. + logger.debug( + "killpg(%d, %d) failed for MCP server '%s': %s; falling back to kill(pid)", + pgid, sig, server_name, exc, + ) try: os.kill(pid, sig) except (ProcessLookupError, PermissionError, OSError): From 02050859f31604c56336077a2dfacc2e7c9990fb Mon Sep 17 00:00:00 2001 From: konsisumer Date: Fri, 19 Jun 2026 19:54:14 +0200 Subject: [PATCH 009/244] fix(tui): preserve live session identity across compression (#49041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session rotates id on compression, _sync_session_key_after_compress() re-anchored the session_key, approval-notify routing, yolo state, and slash worker — but never moved the active-session lease, which stayed keyed to the pre-compression id. And _find_live_session_by_key() matched live sessions on the stale session_key, not the live agent's current agent.session_id. After compression a resume/create path failed to recognize the existing live agent and could build a SECOND live agent against the same DB continuation -> forked lineage / cross-session message mixing. - active_sessions.transfer_active_session(): move a lease in place to the new id under the exclusive file lock (no slot drop). - gateway _transfer_active_session_slot(): call it inside _sync_session_key_after_compress(); on the rare fallback (entry pruned) RESERVE the new slot before releasing the old lease (reserve-before-release), so a concurrent gateway at the session cap cannot grab the freed slot in a release-then-reacquire window and leave this session with no lease; if the reserve fails, keep the existing lease (review fix). - _session_lookup_key(): make live-session lookup authoritative on agent.session_id, wired into all stale-session_key consumers (_find_live_session_by_key, _session_live_item, _live_session_payload) — fixes the whole lookup class. Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> --- hermes_cli/active_sessions.py | 39 +++++++++- tests/hermes_cli/test_active_sessions.py | 27 +++++++ tests/tui_gateway/test_protocol.py | 94 ++++++++++++++++++++++++ tui_gateway/server.py | 82 ++++++++++++++++++++- 4 files changed, 238 insertions(+), 4 deletions(-) diff --git a/hermes_cli/active_sessions.py b/hermes_cli/active_sessions.py index 7fdb9c2d729..7eba80e5024 100644 --- a/hermes_cli/active_sessions.py +++ b/hermes_cli/active_sessions.py @@ -78,7 +78,7 @@ def active_session_limit_message(active_count: int, max_sessions: int) -> str: def _state_dir() -> Path: - return get_hermes_home() / "runtime" + return Path(get_hermes_home()) / "runtime" def _state_path() -> Path: @@ -311,6 +311,43 @@ def release_active_session(lease: ActiveSessionLease) -> None: lease.released = True +def transfer_active_session( + lease: ActiveSessionLease, + *, + session_id: str, + metadata: Optional[dict[str, Any]] = None, +) -> bool: + """Move an existing lease to a new session id without dropping the slot.""" + new_session_id = str(session_id or "") + if not new_session_id: + return False + if lease.released: + return False + if not lease.enabled: + lease.session_id = new_session_id + return True + + state_path = _state_path() + with _FileLock(_lock_path()): + entries = _prune_dead(_read_entries(state_path)) + updated = False + for entry in entries: + if str(entry.get("lease_id") or "") != lease.lease_id: + continue + entry["session_id"] = new_session_id + entry["updated_at"] = time.time() + if metadata: + entry["metadata"] = { + str(k): v for k, v in metadata.items() if isinstance(k, str) + } + updated = True + break + if updated: + _write_entries(state_path, entries) + lease.session_id = new_session_id + return updated + + def active_session_registry_snapshot() -> list[dict[str, Any]]: """Return the pruned active-session registry for diagnostics/tests.""" state_path = _state_path() diff --git a/tests/hermes_cli/test_active_sessions.py b/tests/hermes_cli/test_active_sessions.py index 7988f3a0b02..dda461d686b 100644 --- a/tests/hermes_cli/test_active_sessions.py +++ b/tests/hermes_cli/test_active_sessions.py @@ -113,6 +113,33 @@ def test_active_session_registry_prunes_dead_pids(tmp_path, monkeypatch): lease.release() +def test_transfer_active_session_reanchors_existing_lease(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + monkeypatch.setenv("HERMES_HOME", str(home)) + + lease, message = active_sessions.try_acquire_active_session( + session_id="session-old", + surface="tui", + config={"max_concurrent_sessions": 1}, + metadata={"live_session_id": "ui-1"}, + ) + + assert message is None + assert lease is not None + assert active_sessions.transfer_active_session( + lease, + session_id="session-new", + metadata={"live_session_id": "ui-1"}, + ) + + snapshot = active_sessions.active_session_registry_snapshot() + assert lease.session_id == "session-new" + assert len(snapshot) == 1 + assert snapshot[0]["session_id"] == "session-new" + assert snapshot[0]["metadata"] == {"live_session_id": "ui-1"} + lease.release() + + def test_pid_alive_uses_safe_pid_exists_without_signalling(monkeypatch): checked: list[int] = [] diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 775a07cb317..054fc4df09f 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -734,6 +734,100 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch): assert all(sid == winner for sid in server._sessions) +def test_session_resume_reuses_live_agent_after_compression_rotation(server, monkeypatch): + """Resume must match the live agent's current session_id, not stale session_key.""" + + target = "20260409_020202_child" + stale_parent = "20260409_010101_parent" + sid = "live-rotated" + server._sessions[sid] = { + "agent": types.SimpleNamespace(model="test/model", session_id=target), + "created_at": 123.0, + "display_history_prefix": [], + "history": [{"role": "assistant", "content": "live child"}], + "history_lock": threading.RLock(), + "last_active": 123.0, + "running": False, + "session_key": stale_parent, + "transport": server._stdio_transport, + } + + class _DB: + def get_session(self, _sid): + return {"id": target} + + def get_session_by_title(self, _title): + return None + + def resolve_resume_session_id(self, _target): + return target + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + server, + "_session_info", + lambda _agent, _session=None: {"model": "test/model"}, + ) + + result = server.handle_request( + { + "id": "r1", + "method": "session.resume", + "params": {"session_id": target, "cols": 100}, + } + ) + + assert "error" not in result + assert result["result"]["session_id"] == sid + assert result["result"]["session_key"] == target + assert len(server._sessions) == 1 + + +def test_sync_session_key_after_compress_reanchors_active_session_lease( + server, monkeypatch, tmp_path +): + home = tmp_path / ".hermes" + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli.active_sessions import ( + active_session_registry_snapshot, + try_acquire_active_session, + ) + + lease, message = try_acquire_active_session( + session_id="session-old", + surface="tui", + config={"max_concurrent_sessions": 1}, + metadata={"live_session_id": "ui-1"}, + ) + assert message is None + assert lease is not None + + session = { + "active_session_lease": lease, + "agent": types.SimpleNamespace(session_id="session-new"), + "session_key": "session-old", + } + fake_approval = types.SimpleNamespace( + disable_session_yolo=lambda *_args, **_kwargs: None, + enable_session_yolo=lambda *_args, **_kwargs: None, + is_session_yolo_enabled=lambda *_args, **_kwargs: False, + register_gateway_notify=lambda *_args, **_kwargs: None, + unregister_gateway_notify=lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr(server, "_restart_slash_worker", lambda *_args, **_kwargs: None) + + with patch.dict(sys.modules, {"tools.approval": fake_approval}): + server._sync_session_key_after_compress("ui-1", session) + + snapshot = active_session_registry_snapshot() + assert session["session_key"] == "session-new" + assert lease.session_id == "session-new" + assert [entry["session_id"] for entry in snapshot] == ["session-new"] + lease.release() + + def test_session_resume_live_payload_uses_current_history_with_ancestors(server, monkeypatch): """Live resume should not reuse a stale ancestor-inclusive snapshot.""" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e4bcf1b0bfc..a7e1ba18b75 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -381,6 +381,59 @@ def _release_active_session_slot(session: dict | None) -> None: logger.debug("Failed to release active session slot", exc_info=True) +def _transfer_active_session_slot( + sid: str, + session: dict, + *, + new_session_id: str, +) -> bool: + if not new_session_id: + return False + lease = session.get("active_session_lease") + if lease is None: + return True + try: + from hermes_cli.active_sessions import transfer_active_session + + if transfer_active_session( + lease, + session_id=new_session_id, + metadata={"live_session_id": sid}, + ): + return True + except Exception: + logger.debug("Failed to transfer active session slot", exc_info=True) + + # Fallback: the in-place transfer could not move the lease (entry pruned / + # pid-check transiently failed). Reserve the new slot BEFORE releasing the + # old one, so a concurrent gateway at the session cap cannot grab the freed + # slot in a release-then-reacquire window and leave this session with no + # lease at all (#49041 review). If the reserve fails, KEEP the old lease. + new_lease, limit_message = _claim_active_session_slot( + new_session_id, + live_session_id=sid, + ) + if new_lease is not None: + old_lease = session.pop("active_session_lease", None) + if old_lease is not None: + try: + old_lease.release() + except Exception: + logger.debug("Failed to release stale active session slot", exc_info=True) + session["active_session_lease"] = new_lease + return True + # Reserve failed — retain the existing lease rather than dropping it. + if limit_message: + logger.warning( + "Compression session lease re-anchor failed (kept old lease): " + "sid=%s new_session_id=%s reason=%s", + sid, + new_session_id, + limit_message, + ) + return False + + def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> None: """Best-effort finalize hook + memory commit for a session. @@ -2543,6 +2596,19 @@ def _sync_session_key_after_compress( if not new_session_id or new_session_id == old_key: return + lease_reanchored = _transfer_active_session_slot( + sid, + session, + new_session_id=new_session_id, + ) + if not lease_reanchored: + logger.warning( + "Compression session lease did not re-anchor: sid=%s old_session_id=%s new_session_id=%s", + sid, + old_key, + new_session_id, + ) + try: from tools.approval import ( disable_session_yolo, @@ -4940,7 +5006,7 @@ def _session_live_title(session: dict, key: str) -> str: def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict: - key = str(session.get("session_key") or sid) + key = _session_lookup_key(session, fallback=sid) agent = session.get("agent") history = list(session.get("history") or []) status = _session_live_status(sid, session) @@ -4964,11 +5030,21 @@ def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict: } +def _session_lookup_key(session: dict, *, fallback: str = "") -> str: + agent = session.get("agent") + return str( + getattr(agent, "session_id", None) + or session.get("session_key") + or fallback + or "" + ) + + def _find_live_session_by_key(session_key: str) -> tuple[str, dict] | None: for sid, session in list(_sessions.items()): if session.get("_finalized"): continue - if str(session.get("session_key") or "") == session_key: + if _session_lookup_key(session, fallback=sid) == session_key: return sid, session return None @@ -5012,7 +5088,7 @@ def _live_session_payload( "messages": _history_to_messages(history), "running": running, "session_id": sid, - "session_key": session.get("session_key") or sid, + "session_key": _session_lookup_key(session, fallback=sid), "started_at": float(session.get("created_at") or time.time()), "status": _session_live_status(sid, session), } From 4b7f3826c2ced4d1243b64b15785c6781f836639 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:15:47 +0530 Subject: [PATCH 010/244] fix(telegram): wire platform_httpx_limits into general-pool HTTPXRequest (#31599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PTB's HTTPXRequest builds its httpx.AsyncClient with `limits = httpx.Limits(max_connections=connection_pool_size)` and no keepalive tuning, so httpx's default keepalive_expiry=5.0 applies. Behind an HTTP proxy (Cloudflare Warp etc.) a peer-initiated FIN can sit in CLOSE_WAIT longer than that, leaking fds in the general request pool (_request[1], which routes bot.send_message/set_my_commands) — the pool _drain_polling_connections never resets. Telegram was the lone holdout adapter not using the shared #18451 CLOSE_WAIT helper. Wire gateway.platforms._http_client_limits.platform_httpx_limits() into the httpx client across ALL THREE request-construction branches — fallback-transport, proxy, and plain — via httpx_kwargs["limits"], which PTB spreads last into its client kwargs so our tuned limits win. PTB's connection_pool_size (max_connections) is preserved; only keepalive behaviour is tightened (max_keepalive_connections + keepalive_expiry<5.0). The fix is macOS-import-safe: no Linux-only socket TCP_KEEPIDLE/INTVL/CNT constants at module scope (unlike the broken candidate which crashed on import on the reporter's OS), and it patches the actual proxy path the repro hits rather than TelegramFallbackTransport, which the proxy repro never instantiates. Adds a mutation-survivable behavior-contract test asserting every HTTPXRequest built by connect() receives httpx_kwargs["limits"] with keepalive_expiry < httpx's 5.0 default, across both the proxy and plain branches. Reverting the limits wiring fails the test. Co-authored-by: indigokarasu --- plugins/platforms/telegram/adapter.py | 59 +++++- .../test_telegram_closewait_limits_31599.py | 177 ++++++++++++++++++ 2 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 tests/gateway/test_telegram_closewait_limits_31599.py diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 2de169ee092..b4458d0d7d5 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -2204,6 +2204,43 @@ class TelegramAdapter(BasePlatformAdapter): "write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0), } + # CLOSE_WAIT fd leak (#31599, same class as #18451): PTB's + # HTTPXRequest builds the underlying httpx.AsyncClient with + # `limits = httpx.Limits(max_connections=connection_pool_size)` + # and *no* keepalive tuning, so httpx's default + # keepalive_expiry=5.0 applies. Behind an HTTP proxy (Cloudflare + # Warp etc.) a peer-initiated FIN can sit in CLOSE_WAIT longer + # than that, leaking fds in the general request pool (_request[1]) + # which _drain_polling_connections never resets. Wire the shared + # platform_httpx_limits() helper into the httpx client so idle + # keepalive sockets drain aggressively, while preserving PTB's + # max_connections (= connection_pool_size). httpx_kwargs is spread + # last into PTB's client kwargs, so `limits` here wins. + from gateway.platforms._http_client_limits import platform_httpx_limits + + _base_limits = platform_httpx_limits() + if _base_limits is not None: + import httpx as _httpx + + _pool_limits = _httpx.Limits( + max_connections=request_kwargs["connection_pool_size"], + max_keepalive_connections=_base_limits.max_keepalive_connections, + keepalive_expiry=_base_limits.keepalive_expiry, + ) + else: # pragma: no cover — httpx always present alongside PTB + _pool_limits = None + + def _with_limits(httpx_kwargs: Optional[dict] = None) -> dict: + """Merge tuned keepalive limits into httpx client kwargs. + + A caller-supplied ``limits`` (none today) is left untouched; + otherwise the CLOSE_WAIT-safe limits are injected. + """ + kwargs = dict(httpx_kwargs or {}) + if _pool_limits is not None and "limits" not in kwargs: + kwargs["limits"] = _pool_limits + return kwargs + disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in {"1", "true", "yes", "on"}) fallback_ips = self._fallback_ips() if not fallback_ips: @@ -2226,21 +2263,31 @@ class TelegramAdapter(BasePlatformAdapter): # polling reconnect + bot API bootstrap/delete_webhook calls. request = HTTPXRequest( **request_kwargs, - httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)}, + httpx_kwargs=_with_limits( + {"transport": TelegramFallbackTransport(fallback_ips)} + ), ) get_updates_request = HTTPXRequest( **request_kwargs, - httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)}, + httpx_kwargs=_with_limits( + {"transport": TelegramFallbackTransport(fallback_ips)} + ), ) elif proxy_url: logger.info("[%s] Proxy detected; passing explicitly to HTTPXRequest: %s", self.name, proxy_url) - request = HTTPXRequest(**request_kwargs, proxy=proxy_url) - get_updates_request = HTTPXRequest(**request_kwargs, proxy=proxy_url) + request = HTTPXRequest( + **request_kwargs, proxy=proxy_url, httpx_kwargs=_with_limits() + ) + get_updates_request = HTTPXRequest( + **request_kwargs, proxy=proxy_url, httpx_kwargs=_with_limits() + ) else: if disable_fallback: logger.info("[%s] Telegram fallback-IP transport disabled via env", self.name) - request = HTTPXRequest(**request_kwargs) - get_updates_request = HTTPXRequest(**request_kwargs) + request = HTTPXRequest(**request_kwargs, httpx_kwargs=_with_limits()) + get_updates_request = HTTPXRequest( + **request_kwargs, httpx_kwargs=_with_limits() + ) builder = builder.request(request).get_updates_request(get_updates_request) self._app = builder.build() diff --git a/tests/gateway/test_telegram_closewait_limits_31599.py b/tests/gateway/test_telegram_closewait_limits_31599.py new file mode 100644 index 00000000000..1cef73a120b --- /dev/null +++ b/tests/gateway/test_telegram_closewait_limits_31599.py @@ -0,0 +1,177 @@ +"""Regression test for #31599 — Telegram general-pool CLOSE_WAIT fd leak. + +Background +---------- +PTB's ``telegram.request.HTTPXRequest`` builds the underlying +``httpx.AsyncClient`` with ``limits = httpx.Limits(max_connections=...)`` +and *no* keepalive tuning, so httpx's default ``keepalive_expiry=5.0`` +applies. Behind an HTTP proxy (Cloudflare Warp etc.) a peer-initiated +FIN can sit in ``CLOSE_WAIT`` longer than that, leaking fds in the +general request pool (``_request[1]`` — the pool that routes +``bot.send_message`` / ``set_my_commands``), which +``_drain_polling_connections`` never resets. + +The fix wires the shared ``gateway.platforms._http_client_limits`` +``platform_httpx_limits()`` helper into *every* HTTPXRequest the adapter +builds — the fallback-transport branch, the proxy branch, and the plain +branch — so idle keepalive sockets drain aggressively. + +Contract asserted here (mutation-survivable) +--------------------------------------------- +Every ``HTTPXRequest`` constructed by ``TelegramAdapter.connect()`` must +receive ``httpx_kwargs["limits"]`` that is an ``httpx.Limits`` with a +``keepalive_expiry`` strictly below httpx's 5.0 default and a positive, +bounded ``max_keepalive_connections``. Reverting the limits wiring (so +HTTPXRequest falls back to PTB's default 5.0s keepalive) fails this test. +""" + +import asyncio +import sys +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + telegram_mod = MagicMock() + telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + telegram_mod.constants.ChatType.GROUP = "group" + telegram_mod.constants.ChatType.SUPERGROUP = "supergroup" + telegram_mod.constants.ChatType.CHANNEL = "channel" + telegram_mod.constants.ChatType.PRIVATE = "private" + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, telegram_mod) + + +_ensure_telegram_mock() + +from plugins.platforms.telegram import adapter as tg_adapter # noqa: E402 +from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402 + + +class _StopConnect(Exception): + """Sentinel raised to abort connect() once requests are built.""" + + +class _RecordingHTTPXRequest: + """Stand-in for PTB's HTTPXRequest that records constructor kwargs.""" + + instances: list = [] + + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + _RecordingHTTPXRequest.instances.append(self) + + +def _make_adapter() -> TelegramAdapter: + return TelegramAdapter(PlatformConfig(enabled=True, token="test-token")) + + +def _drive_connect(monkeypatch, *, proxy_url): + """Run connect() far enough to build the HTTPXRequests, then abort. + + Returns the list of recorded _RecordingHTTPXRequest instances. + """ + _RecordingHTTPXRequest.instances = [] + + # No DoH auto-discovery → exercise the proxy / plain branches, not fallback. + async def _no_fallback(): + return [] + + monkeypatch.setattr(tg_adapter, "discover_fallback_ips", _no_fallback) + monkeypatch.setattr( + tg_adapter, "resolve_proxy_url", lambda *a, **k: proxy_url + ) + # Replace the real HTTPXRequest with our recorder. + monkeypatch.setattr(tg_adapter, "HTTPXRequest", _RecordingHTTPXRequest) + + adapter = _make_adapter() + # Skip the cross-process token lock. + monkeypatch.setattr(adapter, "_acquire_platform_lock", lambda *a, **k: True) + # Ensure the adapter reports no statically-configured fallback IPs. + monkeypatch.setattr(adapter, "_fallback_ips", lambda: []) + + # builder.request(...).get_updates_request(...).build() must be harmless; + # make build() raise our sentinel so connect() stops right after the + # HTTPXRequests are constructed (before any real network/init). + fake_built_app = MagicMock() + fake_built_app.initialize = MagicMock(side_effect=_StopConnect) + + chainable = MagicMock() + chainable.token.return_value = chainable + chainable.base_url.return_value = chainable + chainable.base_file_url.return_value = chainable + chainable.local_mode.return_value = chainable + chainable.request.return_value = chainable + chainable.get_updates_request.return_value = chainable + chainable.build.side_effect = _StopConnect + + builder_root = MagicMock() + builder_root.builder.return_value = chainable + monkeypatch.setattr(tg_adapter, "Application", builder_root) + + try: + asyncio.run(adapter.connect()) + except _StopConnect: + pass + except Exception: + # connect() wraps work in a try; if it swallows the sentinel and + # continues to real init, the recorded instances are still valid. + pass + + return list(_RecordingHTTPXRequest.instances) + + +def _assert_keepalive_tight(instances): + assert instances, "connect() built no HTTPXRequest — test setup is wrong" + for inst in instances: + limits = inst.kwargs.get("httpx_kwargs", {}).get("limits") + assert isinstance(limits, httpx.Limits), ( + "HTTPXRequest must receive httpx_kwargs['limits'] = httpx.Limits " + "wired from platform_httpx_limits() (#31599). Missing → PTB falls " + "back to default keepalive_expiry=5.0 and leaks CLOSE_WAIT fds." + ) + # The whole point: keepalive must be tighter than httpx's 5.0 default. + assert limits.keepalive_expiry is not None + assert limits.keepalive_expiry < 5.0, ( + "keepalive_expiry must be < httpx default 5.0 so idle/CLOSE_WAIT " + "sockets drain promptly behind a proxy (#31599)." + ) + assert limits.max_keepalive_connections is not None + assert 1 <= limits.max_keepalive_connections <= 50 + # PTB's connection_pool_size (max_connections) must be preserved. + assert limits.max_connections is not None and limits.max_connections > 0 + + +def test_proxy_branch_general_pool_has_tight_keepalive(monkeypatch): + """The proxy path the #31599 reporter hit must wire tuned limits.""" + instances = _drive_connect(monkeypatch, proxy_url="http://127.0.0.1:9/") + # Both the general request pool and the get_updates pool are built here. + assert len(instances) >= 2 + _assert_keepalive_tight(instances) + # Sanity: the proxy was actually threaded through (we're on the proxy branch). + assert any(inst.kwargs.get("proxy") == "http://127.0.0.1:9/" for inst in instances) + + +def test_plain_branch_general_pool_has_tight_keepalive(monkeypatch): + """No proxy / no fallback IPs → plain branch must also wire tuned limits.""" + instances = _drive_connect(monkeypatch, proxy_url=None) + assert len(instances) >= 2 + _assert_keepalive_tight(instances) + + +def test_limits_keepalive_below_ptb_default_is_the_contract(): + """Document the invariant independent of adapter wiring: the shared + helper itself must tighten keepalive below httpx's 5.0 default.""" + from gateway.platforms._http_client_limits import platform_httpx_limits + + limits = platform_httpx_limits() + assert isinstance(limits, httpx.Limits) + assert limits.keepalive_expiry is not None and limits.keepalive_expiry < 5.0 From 190b01c5531e37547ffdd96b2bc1094308a0756c Mon Sep 17 00:00:00 2001 From: konsisumer Date: Sat, 20 Jun 2026 10:10:30 +0200 Subject: [PATCH 011/244] fix(agent): persist tool calls before turn-end flush Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> --- agent/conversation_loop.py | 13 + agent/tool_executor.py | 56 +++- .../test_tool_call_incremental_persistence.py | 252 ++++++++++++++++++ 3 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 tests/run_agent/test_tool_call_incremental_persistence.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index bbc379adf25..303752aa427 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -4050,6 +4050,19 @@ def run_conversation( messages.append(assistant_msg) agent._emit_interim_assistant_message(assistant_msg) + try: + # Persist the assistant tool-call turn before any tool + # side effects run. If a destructive tool restarts or + # terminates Hermes mid-turn, resume logic still sees the + # exact tool-call block that already executed. + agent._flush_messages_to_session_db(messages, conversation_history) + except Exception as exc: + logger.warning( + "Incremental tool-call persistence failed before execution " + "(session=%s): %s", + agent.session_id or "none", + exc, + ) # Close any open streaming display (response box, reasoning # box) before tool execution begins. Intermediate turns may diff --git a/agent/tool_executor.py b/agent/tool_executor.py index c11453cef10..1d2e96d6e0e 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -69,6 +69,25 @@ def _budget_for_agent(agent) -> BudgetConfig: _MAX_TOOL_WORKERS = 8 +def _flush_session_db_after_tool_progress( + agent, + messages: list, + *, + stage: str, +) -> None: + """Best-effort incremental SessionDB flush for tool-call progress. + + Tool execution can perform side effects that terminate or restart the + current Hermes process before the normal turn-end persistence path runs. + Flush the already-appended assistant/tool messages immediately so the + transcript survives destructive-but-valid tool calls. + """ + try: + agent._flush_messages_to_session_db(messages) + except Exception as exc: + logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc) + + def _ra(): """Lazy reference to ``run_agent`` so patches like ``run_agent._set_interrupt`` work.""" import run_agent @@ -279,6 +298,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]", tc.id, )) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"cancelled tool result {tc.function.name}", + ) return # ── Parse args + pre-execution bookkeeping ─────────────────────── @@ -768,6 +792,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # String results pass through unchanged. _tool_content = agent._tool_result_content_for_active_model(name, function_result) messages.append(make_tool_result_message(name, _tool_content, tc.id)) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"tool result {name}", + ) # ── Per-tool /steer drain ─────────────────────────────────── # Same as the sequential path: drain between each collected @@ -803,13 +832,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe agent._vprint(f"{agent.log_prefix}⚡ Interrupt: skipping {len(remaining_calls)} tool call(s)", force=True) for skipped_tc in remaining_calls: skipped_name = skipped_tc.function.name - skip_msg = { - "role": "tool", - "name": skipped_name, - "content": f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]", - "tool_call_id": skipped_tc.id, - } - messages.append(skip_msg) + messages.append(make_tool_result_message( + skipped_name, + f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]", + skipped_tc.id, + )) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"cancelled tool result {skipped_name}", + ) break function_name = tool_call.function.name @@ -1402,6 +1434,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe # (see parallel path for rationale). String results pass through. _tool_content = agent._tool_result_content_for_active_model(function_name, function_result) messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id)) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"tool result {function_name}", + ) # ── Per-tool /steer drain ─────────────────────────────────── # Drain pending steer BETWEEN individual tool calls so the @@ -1428,6 +1465,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]", skipped_tc.id, )) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"skipped tool result {skipped_name}", + ) break if agent.tool_delay > 0 and i < len(assistant_message.tool_calls): diff --git a/tests/run_agent/test_tool_call_incremental_persistence.py b/tests/run_agent/test_tool_call_incremental_persistence.py new file mode 100644 index 00000000000..34d4d79141d --- /dev/null +++ b/tests/run_agent/test_tool_call_incremental_persistence.py @@ -0,0 +1,252 @@ +"""Behavior contracts for incremental tool-call persistence (#49045). + +A destructive or process-terminating tool that runs during tool execution +must not lose the just-executed assistant(tool_calls) block or the tool +results that were produced before it fired. These tests pin the contract: + + 1. run_conversation flushes the assistant tool-call turn to the session + DB BEFORE handing control to _execute_tool_calls (so a tool that + restarts/kills the process never orphans the tool-call block). + 2. The SEQUENTIAL tool path flushes each tool result to the session DB + immediately after appending it — BEFORE the next tool dispatches. + 3. The CONCURRENT tool path flushes each tool result in append order. + +These exercise the REAL production dispatch surfaces: + + * sequential -> ``run_agent.handle_function_call`` (tool_executor ~1256/1298) + * concurrent -> ``agent._invoke_tool`` (tool_executor ~539) + +Mocking the genuine dispatch surface keeps the tests deterministic (no real +``web_search`` / network) AND mutation-survivable: the ordering assertions +read snapshots captured at flush time, so removing any production flush call +makes the corresponding assertion fail. +""" + +import copy +from types import SimpleNamespace +from pathlib import Path +import tempfile +from unittest.mock import MagicMock, patch + +from agent.tool_dispatch_helpers import make_tool_result_message +from run_agent import AIAgent + + +def _make_tool_defs(*names: str) -> list: + return [ + { + "type": "function", + "function": { + "name": name, + "description": f"{name} tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + for name in names + ] + + +def _make_agent(): + hermes_home = Path(tempfile.mkdtemp(prefix="hermes-test-home-")) + (hermes_home / "logs").mkdir(parents=True, exist_ok=True) + with ( + patch( + "run_agent.get_tool_definitions", + return_value=_make_tool_defs("web_search"), + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + patch("run_agent._hermes_home", hermes_home), + patch("agent.model_metadata.fetch_model_metadata", return_value={}), + ): + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.client = MagicMock() + agent._cached_system_prompt = "You are helpful." + agent._use_prompt_caching = False + agent.tool_delay = 0 + agent.compression_enabled = False + agent.save_trajectories = False + return agent + + +def _mock_tool_call(name="web_search", arguments="{}", call_id="call_1"): + return SimpleNamespace( + id=call_id, + type="function", + function=SimpleNamespace(name=name, arguments=arguments), + ) + + +def _mock_response(content="Hello", finish_reason="stop", tool_calls=None): + msg = SimpleNamespace(content=content, tool_calls=tool_calls) + choice = SimpleNamespace(message=msg, finish_reason=finish_reason) + return SimpleNamespace(choices=[choice], model="test/model", usage=None) + + +# --------------------------------------------------------------------------- +# Contract 1: run_conversation persists the assistant tool-call block BEFORE +# tool execution begins. +# --------------------------------------------------------------------------- +def test_run_conversation_flushes_assistant_tool_call_before_execution(): + agent = _make_agent() + tool_call = _mock_tool_call(call_id="c1") + agent.client.chat.completions.create.side_effect = [ + _mock_response(content="", finish_reason="tool_calls", tool_calls=[tool_call]), + _mock_response(content="done", finish_reason="stop"), + ] + + # Record a deep snapshot of the message list at every flush so the + # assertion does not depend on later mutations. + flush_snapshots: list[list] = [] + + def _record_flush(messages, conversation_history=None): + flush_snapshots.append(copy.deepcopy(messages)) + + agent._flush_messages_to_session_db = MagicMock(side_effect=_record_flush) + + # Capture observations at execute time into module-level lists rather than + # asserting inside _execute_tool_calls — run_conversation's outer loop + # swallows exceptions, so an in-callback assertion would never surface. + executed = {"count": 0} + snapshot_at_execute: list = [] + + def _fake_execute(assistant_message, messages, effective_task_id, api_call_count=0): + executed["count"] += 1 + # Record the DB state observed at the moment tool execution begins. + snapshot_at_execute.append( + copy.deepcopy(flush_snapshots[-1]) if flush_snapshots else None + ) + # Simulate the tool producing a result (as the real path would). + messages.append(make_tool_result_message("web_search", "search result", "c1")) + + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent, "_execute_tool_calls", side_effect=_fake_execute), + ): + result = agent.run_conversation("search something") + + assert executed["count"] == 1, "_execute_tool_calls was never reached" + # The assistant tool-call block MUST have been flushed before execution. + last = snapshot_at_execute[0] + assert last is not None, "no flush occurred before tool execution" + assert last[-1]["role"] == "assistant" + assert last[-1]["tool_calls"][0]["id"] == "c1" + assert result["final_response"] == "done" + + +# --------------------------------------------------------------------------- +# Contract 2: the SEQUENTIAL path flushes each tool result immediately, BEFORE +# the next tool dispatches. Dispatch goes through run_agent.handle_function_call +# (the real production surface), which we mock for determinism. +# --------------------------------------------------------------------------- +def test_execute_tool_calls_sequential_flushes_each_tool_result_before_next_dispatch(): + agent = _make_agent() + tool_calls = [ + _mock_tool_call(name="web_search", call_id="c1"), + _mock_tool_call(name="web_search", call_id="c2"), + ] + messages: list = [] + assistant_message = SimpleNamespace(content="", tool_calls=tool_calls) + + # Ordered event log interleaving real dispatches and DB flushes. + events: list = [] + + def _fake_dispatch(function_name, function_args, effective_task_id, **kwargs): + # The result for call N must have been flushed before call N+1 fires. + events.append(("dispatch", kwargs.get("tool_call_id"))) + return f"result-{kwargs.get('tool_call_id')}" + + def _record_flush(flush_messages, conversation_history=None): + # Snapshot the tail tool result that triggered this flush. + tail = flush_messages[-1] + events.append(("flush", tail.get("role"), tail.get("tool_call_id"))) + + agent._flush_messages_to_session_db = MagicMock(side_effect=_record_flush) + + with ( + patch("run_agent.handle_function_call", side_effect=_fake_dispatch) as disp, + patch( + "agent.tool_executor.maybe_persist_tool_result", + side_effect=lambda **kwargs: kwargs["content"], + ), + ): + agent._execute_tool_calls_sequential(assistant_message, messages, "task-1") + + # The mock proves we exercised the REAL sequential dispatch surface. + assert disp.call_count == 2, "sequential path did not dispatch via handle_function_call" + + # Both tool results landed, in order. + assert [m["role"] for m in messages] == ["tool", "tool"] + assert [m["tool_call_id"] for m in messages] == ["c1", "c2"] + + # Ordering contract: each tool result is flushed AFTER its own dispatch + # and BEFORE the next dispatch. Expected interleaving: + # dispatch c1 -> flush c1 -> dispatch c2 -> flush c2 + assert events == [ + ("dispatch", "c1"), + ("flush", "tool", "c1"), + ("dispatch", "c2"), + ("flush", "tool", "c2"), + ] + + +# --------------------------------------------------------------------------- +# Contract 3: the CONCURRENT path flushes each collected tool result in append +# order. Dispatch goes through agent._invoke_tool (the real concurrent +# surface), which we mock for determinism. +# --------------------------------------------------------------------------- +def test_execute_tool_calls_concurrent_flushes_each_tool_result_in_order(): + agent = _make_agent() + tool_calls = [ + _mock_tool_call(name="web_search", call_id="c1"), + _mock_tool_call(name="web_search", call_id="c2"), + ] + messages: list = [] + assistant_message = SimpleNamespace(content="", tool_calls=tool_calls) + + invoked_ids: list = [] + + def _fake_invoke(function_name, function_args, effective_task_id, tool_call_id, **kwargs): + invoked_ids.append(tool_call_id) + return f"result-{tool_call_id}" + + # Each flush must observe exactly one more tool result than the previous + # flush, in append order — i.e. the tail tool_call_id sequence is c1, c2. + flushed_tool_ids: list = [] + flush_lengths: list = [] + + def _record_flush(flush_messages, conversation_history=None): + flushed_tool_ids.append(flush_messages[-1]["tool_call_id"]) + flush_lengths.append(len([m for m in flush_messages if m.get("role") == "tool"])) + + agent._flush_messages_to_session_db = MagicMock(side_effect=_record_flush) + + with ( + patch.object(agent, "_invoke_tool", side_effect=_fake_invoke) as inv, + patch( + "agent.tool_executor.maybe_persist_tool_result", + side_effect=lambda **kwargs: kwargs["content"], + ), + ): + agent._execute_tool_calls_concurrent(assistant_message, messages, "task-1") + + # Proves the real concurrent dispatch surface was exercised. + assert inv.call_count == 2, "concurrent path did not dispatch via _invoke_tool" + assert sorted(invoked_ids) == ["c1", "c2"] + + # Results appended in deterministic order. + assert [m["tool_call_id"] for m in messages] == ["c1", "c2"] + + # Each tool result was flushed exactly once, in append order, with the + # running tool count growing by one each time (1 then 2). Removing either + # production flush call breaks one of these assertions. + assert flushed_tool_ids == ["c1", "c2"] + assert flush_lengths == [1, 2] From e32ebc6aa26fff446bcc7e11a254d2d4c671f3b2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:51:28 -0700 Subject: [PATCH 012/244] =?UTF-8?q?feat(skills):=20/learn=20=E2=80=94=20di?= =?UTF-8?q?still=20a=20reusable=20skill=20from=20anything=20you=20describe?= =?UTF-8?q?=20(#51506)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open-ended skill learning across every surface. /learn takes a description of any source — a directory, a URL, the workflow you just walked the agent through, or pasted notes — and the live agent gathers it with the tools it already has (read_file/search_files, web_extract, the conversation, the pasted text), then authors a SKILL.md via skill_manage following the house authoring standards (<=60-char description, the standard section order, Hermes-tool framing, no invented commands). No engine, no model-tool footprint, works on any terminal backend (local, Docker, remote): /learn builds a standards-guided prompt and hands it to the agent as a normal turn. - agent/learn_prompt.py: shared standards-guided prompt builder - /learn registry entry (both surfaces) + CLI handler (inject onto input queue) + gateway handler (rewrite turn, fall through, /blueprint pattern) - tui_gateway command.dispatch returns a send directive -> TUI + dashboard chat - dashboard Skills page 'Learn a skill' panel (dir + URL + open-ended text) composes a /learn request and runs it in chat - docs (slash-commands ref + skills feature page), 11 targeted tests Inspired by OpenAI Codex's Record & Replay and the /learn concept from #47234 (dir-distillation engine); reworked to be open-ended and engine-free per review. --- agent/learn_prompt.py | 109 +++++++++++++++++++++ cli.py | 2 + gateway/run.py | 28 ++++++ hermes_cli/cli_commands_mixin.py | 26 +++++ hermes_cli/commands.py | 2 + tests/agent/test_learn_prompt.py | 73 ++++++++++++++ tui_gateway/server.py | 9 ++ web/src/pages/ChatPage.tsx | 19 ++++ web/src/pages/SkillsPage.tsx | 98 ++++++++++++++++++ website/docs/reference/slash-commands.md | 3 +- website/docs/user-guide/features/skills.md | 36 +++++++ 11 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 agent/learn_prompt.py create mode 100644 tests/agent/test_learn_prompt.py diff --git a/agent/learn_prompt.py b/agent/learn_prompt.py new file mode 100644 index 00000000000..dc6a0bd9da6 --- /dev/null +++ b/agent/learn_prompt.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""``/learn`` — build the standards-guided prompt that turns whatever the user +described into a reusable skill. + +``/learn`` is open-ended. The user can point it at anything they can describe: +a directory of code, an API doc URL, a workflow they just walked the agent +through in this conversation, or pasted notes. This module builds ONE prompt +that instructs the live agent to: + + 1. Gather the sources the user named, using the tools it already has + (``read_file`` / ``search_files`` for dirs, ``web_extract`` for URLs, the + current conversation for "what I just did", the user's text for pasted + material). + 2. Author a single ``SKILL.md`` via ``skill_manage`` that follows the Hermes + skill-authoring standards (description <=60 chars, the modern section + order, Hermes-tool framing, no invented commands). + +There is no separate distillation engine and no model-tool footprint: the +agent does the work with its existing toolset, so this works identically on +local, Docker, and remote terminal backends. Every surface (CLI ``/learn``, +gateway ``/learn``, the dashboard "Learn a skill" panel) calls +:func:`build_learn_prompt` and feeds the result to the agent as a normal turn. +""" + +from __future__ import annotations + +# The house-style rules, distilled from AGENTS.md "Skill authoring standards +# (HARDLINE)" and the hermes-agent-dev new-skill salvage reference. Embedded in +# the prompt so the agent authors skills the way a maintainer would by hand. +_AUTHORING_STANDARDS = """\ +Follow the Hermes skill-authoring standards exactly: + +Frontmatter: +- name: lowercase-hyphenated, <=64 chars, no spaces. +- description: ONE sentence, <=60 characters, ends with a period. State the + capability, not the implementation. No marketing words (powerful, + comprehensive, seamless, advanced). Do NOT repeat the skill name. If the + description contains a colon, wrap the whole value in double quotes. +- version: 0.1.0 +- metadata.hermes.tags: a few Capitalized, Relevant, Tags. + +Body section order (omit a section only if it genuinely has no content): +1. "# " then a 2-3 sentence intro: what it does, what it does NOT + do, and the key dependency stance (e.g. "stdlib only"). +2. "## When to Use" — bullet list of concrete trigger phrases. +3. "## Prerequisites" — exact env vars, install steps, credentials. +4. "## How to Run" — the canonical invocation, framed through Hermes tools. +5. "## Quick Reference" — a flat command/endpoint list, no narration. +6. "## Procedure" — numbered steps with copy-paste-exact commands. +7. "## Pitfalls" — known limits, rate limits, things that look broken but aren't. +8. "## Verification" — a single command/check that proves the skill worked. + +Hermes-tool framing (this is what makes it a skill, not shell docs): +- Frame running scripts as "invoke through the `terminal` tool". +- Use `read_file` (not cat/head/tail), `search_files` (not grep/find/ls), + `patch` (not sed/awk), `web_extract` (not curl-to-scrape), + `vision_analyze` for images. Reference these tools by name in backticks. +- Do NOT name shell utilities the agent already has wrapped. + +Quality bar: +- Prefer exact commands, endpoint URLs, function signatures, and config keys + that appear VERBATIM in the source. NEVER invent flags, paths, or APIs — if + you didn't see it in the source, don't write it. +- Keep it tight and scannable: ~100 lines for a simple skill, ~200 for a + complex one. Don't re-paste the source docs. +- Don't write a router/index/hub skill that only points at other skills. +- Larger scripts/parsers belong in a `scripts/` file (add via + `skill_manage` write_file), referenced from SKILL.md by relative path — not + inlined for the agent to re-type every run.""" + + +def build_learn_prompt(user_request: str) -> str: + """Build the agent prompt for an open-ended ``/learn`` request. + + Args: + user_request: the free-text the user gave after ``/learn`` — a + description of the workflow, paths, URLs, or "what I just did". + + Returns: + A complete instruction the agent runs as a normal turn. The agent + gathers the described sources with its existing tools and authors the + skill via ``skill_manage``. + """ + req = (user_request or "").strip() + if not req: + req = ( + "the workflow we just went through in this conversation — review " + "the steps taken and distill them into a reusable skill" + ) + + return ( + "[/learn] The user wants you to learn a reusable skill from the " + "source(s) they described below, and save it.\n\n" + f"WHAT TO LEARN FROM:\n{req}\n\n" + "Do this:\n" + "1. Gather the material. Resolve whatever the user named using the " + "tools you already have — `read_file`/`search_files` for local files " + "or directories, `web_extract` for URLs, the current conversation " + "history if they referred to something you just did, and the text " + "they pasted as-is. If the request is ambiguous about scope, make a " + "reasonable choice and note it; do not stall.\n" + "2. Author ONE SKILL.md and save it with the `skill_manage` tool " + "(action=\"create\"). Pick a sensible category. If the procedure needs " + "a non-trivial script, add it under the skill's `scripts/` with " + "`skill_manage` write_file and reference it by relative path.\n\n" + f"{_AUTHORING_STANDARDS}\n\n" + "When done, tell the user the skill name, its category, and a " + "one-line summary of what it captured." + ) diff --git a/cli.py b/cli.py index 0d6f52ac5ab..52ec6624af3 100644 --- a/cli.py +++ b/cli.py @@ -8009,6 +8009,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): elif canonical == "skills": with self._busy_command(self._slow_command_status(cmd_original)): self._handle_skills_command(cmd_original) + elif canonical == "learn": + self._handle_learn_command(cmd_original) elif canonical == "memory": self._handle_memory_command(cmd_original) elif canonical == "platforms": diff --git a/gateway/run.py b/gateway/run.py index 980f2a4e993..5ec99eddcd2 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8113,6 +8113,34 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "skills": return await self._handle_skills_command(event) + if canonical == "learn": + # Open-ended: rewrite the turn to a standards-guided prompt and fall + # through to normal agent processing. The live agent gathers the + # sources the user described (dirs via read_file, URLs via + # web_extract, this conversation, pasted text) and authors the skill + # via skill_manage. Mirrors the /blueprint fall-through so role + # alternation is preserved. No engine, works on any backend. + from agent.learn_prompt import build_learn_prompt + + _learn_req = event.get_command_args().strip() + _ack = ( + "Learning a skill from what you described…" + if _learn_req + else "Learning a skill from this conversation…" + ) + try: + adapter = self.adapters.get(source.platform) + if adapter: + _ack_meta = self._thread_metadata_for_source(source) + await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta) + except Exception: + logger.debug("learn ack send failed", exc_info=True) + try: + event.text = build_learn_prompt(_learn_req) + # fall through to agent processing + except Exception: + return "Could not start /learn — please try again." + if canonical == "fast": return await self._handle_fast_command(event) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 95292314c5a..08cdd3deb67 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1354,6 +1354,32 @@ class CLICommandsMixin: from hermes_cli.skills_hub import handle_skills_slash handle_skills_slash(cmd, ChatConsole()) + def _handle_learn_command(self, cmd: str): + """Handle /learn — distill a reusable skill from anything the user describes. + + Open-ended: the argument is free text describing the source(s) — a + directory, a URL, "what we just did", pasted notes. We build a + standards-guided prompt and inject it onto the agent's input queue; the + live agent gathers the material with the tools it already has and + authors the skill via ``skill_manage``. No engine, no model-tool + footprint, works on any terminal backend. + """ + from agent.learn_prompt import build_learn_prompt + + # Everything after the command word is the open-ended request. + parts = cmd.strip().split(None, 1) + user_request = parts[1].strip() if len(parts) > 1 else "" + + msg = build_learn_prompt(user_request) + if user_request: + print("\n⚡ Learning a skill from what you described...") + else: + print("\n⚡ Learning a skill from this conversation...") + if hasattr(self, "_pending_input"): + self._pending_input.put(msg) + else: # pragma: no cover - defensive (no live input loop) + print(" /learn needs an active chat session to run.") + def _handle_memory_command(self, cmd: str): """Handle /memory slash command — pending review + approval-gate toggle.""" from hermes_cli.write_approval_commands import handle_pending_subcommand diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 540b2865df3..77d2dd7bb75 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -179,6 +179,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ subcommands=("pending", "approve", "reject", "approval")), CommandDef("bundles", "List skill bundles (aliases / for multiple skills)", "Tools & Skills"), + CommandDef("learn", "Learn a reusable skill from anything you describe (dirs, URLs, this chat, notes)", + "Tools & Skills", args_hint=""), CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]", subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), diff --git a/tests/agent/test_learn_prompt.py b/tests/agent/test_learn_prompt.py new file mode 100644 index 00000000000..a7d92bf750a --- /dev/null +++ b/tests/agent/test_learn_prompt.py @@ -0,0 +1,73 @@ +"""Tests for /learn — open-ended skill distillation. + +Covers the shared prompt builder (agent.learn_prompt.build_learn_prompt) and +the slash-command registry wiring. /learn has no engine and no model tool: it +builds a standards-guided prompt that the live agent runs as a normal turn, so +these are the load-bearing behavior contracts. +""" + +from agent.learn_prompt import build_learn_prompt, _AUTHORING_STANDARDS + + +class TestBuildLearnPrompt: + def test_embeds_the_user_request_verbatim(self): + req = "the REST client in ~/projects/acme-sdk, focus on auth" + prompt = build_learn_prompt(req) + assert req in prompt + + def test_always_includes_the_authoring_standards(self): + # The standards are what make distilled skills match house style; + # they must travel with every prompt regardless of input. + for req in ["", "a url https://x/y", "what we just did"]: + assert _AUTHORING_STANDARDS in build_learn_prompt(req) + + def test_instructs_saving_via_skill_manage_not_a_raw_file(self): + prompt = build_learn_prompt("learn the thing") + assert "skill_manage" in prompt + + def test_references_gather_tools_for_open_ended_sourcing(self): + # Open-ended sourcing relies on the agent's own tools, named so it + # knows dirs/URLs/conversation/paste all route through existing tools. + prompt = build_learn_prompt("learn from somewhere") + for tool in ("read_file", "search_files", "web_extract"): + assert tool in prompt + + def test_empty_request_falls_back_to_the_conversation(self): + # Bare /learn should distill "what we just did", not error. + prompt = build_learn_prompt("") + assert "conversation" in prompt.lower() + # And still carries the standards + save instruction. + assert "skill_manage" in prompt + + def test_whitespace_only_request_is_treated_as_empty(self): + assert build_learn_prompt(" \n ") == build_learn_prompt("") + + def test_description_length_rule_is_in_the_standards(self): + # The single most-violated rule must be explicit in the prompt. + assert "60" in _AUTHORING_STANDARDS + + +class TestLearnRegistryWiring: + def test_learn_is_registered_and_resolves(self): + from hermes_cli.commands import resolve_command + + cmd = resolve_command("learn") + assert cmd is not None + assert cmd.name == "learn" + + def test_learn_is_in_tools_and_skills_category(self): + from hermes_cli.commands import resolve_command + + assert resolve_command("learn").category == "Tools & Skills" + + def test_learn_works_on_the_gateway(self): + # /learn must reach the gateway runner (it's a both-surfaces command), + # not be CLI-only. + from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS + + assert "learn" in GATEWAY_KNOWN_COMMANDS + + def test_learn_is_not_cli_only(self): + from hermes_cli.commands import resolve_command + + assert not resolve_command("learn").cli_only diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a7e1ba18b75..ac604f223a3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9127,6 +9127,15 @@ def _(rid, params: dict) -> dict: return _err(rid, 4004, "usage: /queue ") return _ok(rid, {"type": "send", "message": arg}) + if name == "learn": + # Open-ended: build the standards-guided prompt and submit it as a + # normal agent turn. The live agent gathers whatever the user + # described (dirs, URLs, this conversation, pasted text) with its own + # tools and authors the skill via skill_manage. Works on any backend. + from agent.learn_prompt import build_learn_prompt + + return _ok(rid, {"type": "send", "message": build_learn_prompt(arg)}) + if name == "retry": if not session: return _err(rid, 4001, "no active session to retry") diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 0820ae82d34..af889dc8765 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -671,6 +671,25 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // follow up with the authoritative measurement — at worst Ink // reflows once after the PTY boots, which is imperceptible. ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`); + // One-shot: a ?learn= param (set by the Skills page "Learn a + // skill" panel) is typed into the composer as a /learn command once the + // PTY is up. /learn resolves via command.dispatch → a normal agent turn, + // so this reuses the existing composer path — no special PTY protocol. + const learnSeed = searchParams.get("learn"); + if (learnSeed) { + const next = new URLSearchParams(searchParams); + next.delete("learn"); + setSearchParams(next, { replace: true }); + const cmd = `/learn ${learnSeed}`.trim(); + // Delay so Ink's composer has mounted and grabbed focus before input. + setTimeout(() => { + try { + wsRef.current?.send(cmd + "\r"); + } catch { + /* PTY not ready / closed — user can retype */ + } + }, 800); + } }; ws.onmessage = (ev) => { diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index cb6beef22fa..8bc4a244f16 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useLayoutEffect, useState, useMemo, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; import { Package, Search, @@ -212,6 +213,37 @@ export default function SkillsPage() { setEditorSkill(null); setEditorOpen(true); }, []); + // ── "Learn a skill" panel ────────────────────────────────────────────── + // Open-ended: dir + URL + free-text inputs are composed into a single-line + // /learn command and handed to the chat. /learn resolves to a normal agent + // turn (command.dispatch → send), so the live agent gathers the sources + // with its own tools and authors the skill via skill_manage. No backend + // distill endpoint — one code path with the CLI/TUI/gateway /learn. + const navigate = useNavigate(); + const [learnOpen, setLearnOpen] = useState(false); + const [learnDir, setLearnDir] = useState(""); + const [learnUrl, setLearnUrl] = useState(""); + const [learnText, setLearnText] = useState(""); + const openLearn = useCallback(() => { + setLearnDir(""); + setLearnUrl(""); + setLearnText(""); + setLearnOpen(true); + }, []); + const submitLearn = useCallback(() => { + const segs: string[] = []; + const dir = learnDir.trim(); + const url = learnUrl.trim(); + const text = learnText.trim(); + if (dir) segs.push(`local source: ${dir}`); + if (url) segs.push(`URL: ${url}`); + if (text) segs.push(text); + // Flatten to a single line — the chat composer submits on the first Enter. + const composed = segs.join("; ").replace(/\s*\n\s*/g, " ").trim(); + if (!composed) return; + setLearnOpen(false); + navigate(`/chat?learn=${encodeURIComponent(composed)}`); + }, [learnDir, learnUrl, learnText, navigate]); const openEditEditor = useCallback((skillName: string) => { setEditorSkill(skillName); setEditorOpen(true); @@ -492,6 +524,14 @@ export default function SkillsPage() { .replace("{count}", String(activeSkills.length)) .replace("{s}", activeSkills.length !== 1 ? "s" : "")} +