Compare commits

..

24 Commits

Author SHA1 Message Date
Brooklyn Nicholson
a210cfc33c Merge branch 'bb/pets' into bb/pets-gen
Carry forward the overlay/waiting-state updates and resolve the gateway merge conflict.
Also tighten the desktop pet-generation flow by cleaning superseded previews,
using the draft's source prompt during hatch, and previewing rows from the
returned sheet taxonomy.
2026-06-17 12:14:37 -05:00
Brooklyn Nicholson
8f3ea8a148 feat(pets): wire the waiting state across CLI, TUI, and desktop
A clarify/approval prompt means the agent is paused on the user — show the
`waiting` pose on every surface. Make awaiting_input outrank the in-flight
signals (a blocking prompt beats "a tool is technically still open"), and feed
it from each surface's real blocking-prompt signal:

- CLI: any live approval/clarify/sudo/secret/slash-confirm modal.
- TUI: clarify/approval/sudo/secret/confirm overlay.
- Desktop: $attentionSessionIds, routed through $petActivity.awaitingInput so it
  also mirrors to the pop-out overlay (which has no session list). Drops the old
  $awaitingResponse feed, which was the assistant-wait flag, not "your turn".

Legacy 8-row sheets have no waiting row, so it falls back to idle there.
2026-06-17 11:55:28 -05:00
Brooklyn Nicholson
6ce6054dfa fix(pets): drag cancels the deferred overlay single-click
A single-click arms a 250ms timer to open the composer (so a double-click can
win instead). If the user dragged within that window the timer still fired,
popping the composer open after repositioning. Clear it once a drag is confirmed.
2026-06-17 11:46:46 -05:00
Brooklyn Nicholson
e942321cd7 feat(pets): overlay gestures + livelier bubbles
- Bubbles are overlay-only and now pull from larger, randomized (no-repeat)
  phrase pools per mood (working / thinking / waiting / failed).
- Overlay gestures: single-click opens the composer, double-click toggles the
  app window (minimize <-> restore), shift-click pops back in. The single-click
  is deferred so a double never flashes the composer open.
- Trim the overlay composer padding so it's not oversized.
- Docs: gesture table for the pop-out overlay.
2026-06-17 11:38:39 -05:00
brooklyn!
365c23c554 feat(pets): pop-out desktop overlay + notifications (#47938)
* fix(pets): map sprite rows by atlas shape, not a fixed order

Petdex/Codex sheets are 8 cols x 9 rows (jump=4, failed=5, run=7,
review=8); older Hermes sheets were 8 rows in our own order. We hardcoded
the legacy order, so on a 9-row pet "celebrate" (jump) cropped the failed
row — a finished turn rendered the sad pose. Derive the row taxonomy from
the sheet's row count and ship the right list to the desktop canvas.

* feat(desktop): pop-out pet overlay with bubble, composer, and mail

Shift-click the floating pet to pop it into a transparent, always-on-top
desktop window that stays visible while Hermes is minimized. It's a pure
puppet of the in-app pet (no second gateway): the main renderer mirrors
live state over IPC and the overlay renders the same sprite + bubble.

- Speech bubble externalizes activity (working/thinking/your turn) and a
  gold star on finish; the star outlives the celebrate jump so the finish
  stays glanceable.
- Drag anywhere (even off-window); position + in/out state persist.
- Click opens a mini composer that sends to the most recent session.
- Mail icon (only when a turn finished while you were away) raises the app
  on that thread and marks it read.
- macOS: NSPanel + skipTransformProcessType so popping out never drops the
  app from cmd/alt-tab.

Also fixes the completion beat: flashPetActivity now clears sibling beats,
so a stale error can't make a clean finish render the failed pose.

* docs(pets): document the desktop pop-out overlay

* refactor(pets): drop done-star and align runtime mapping to spec rows

Remove the completion star bubble now that the overlay mail icon carries
"finished while away" signal. Keep completion feedback in the sprite animation
and unread mail only.

Tighten row mapping to the current Petdex 9-row taxonomy while preserving
legacy compatibility: we now resolve state rows through aliases
(wave/waving, jump/jumping, run/running), add a dedicated waiting state, and
map awaiting-input to waiting instead of idle.

This makes animation selection less awkward across mixed assets and keeps
Hermes aligned with the live Petdex state viewer semantics.

* fix(pets): bubbles are overlay-only, not on the in-window pet

The speech bubble is the glance/notification surface for the popped-out
overlay. In-window the app itself is the surface, so drop the bubble there.
2026-06-17 11:29:23 -05:00
Brooklyn Nicholson
8c48be90ac feat(pets): generate a custom pet from a prompt (desktop)
Add an in-app pet generator: describe a creature, get four cheap
base-look drafts, pick one, then hatch the full six-state animated atlas
and preview every frame before adopting.

- backend: agent/pet/generate/ (deterministic atlas assembly ported from
  the hatch-pet skill, prompt builders, provider wrapper, orchestration)
- store.register_local_pet so generated pets install + adopt without a
  manifest entry
- gateway pet.generate (draft variants) + pet.hatch (build preview, not
  active); adopt via existing pet.select, discard via pet.remove
- OpenAI provider: reference-image edit path + opt-in transparent
  background; imagegen retries without the flag on models that reject it
- transparency hardened with a chroma-key cutout pass on base drafts
- desktop: Cmd+K "Generate a pet" page (draft grid, retry, animated
  post-hatch preview, adopt/start-over), egg icon, i18n
2026-06-16 23:11:24 -05:00
Brooklyn Nicholson
25e78f129f perf(pets): cache the petdex manifest in-process (TTL)
fetch_manifest had zero caching, so every gallery open re-pulled the full
list and find_entry (install/select) downloaded the entire manifest just
to resolve one slug — multiple network hits per session for a static CDN
object. Add a 5-minute in-process TTL cache (force= to bypass,
clear_cache() for tests); find_entry routes through it for free.
2026-06-16 19:20:59 -05:00
Brooklyn Nicholson
b5152ec846 feat(desktop): pet contact shadow, inward facing, resize-safe; inline toggle
Floating mascot polish: a scale-proportional contact shadow (lighter in
light mode), live mid-drag mirroring so the pet always faces inward, and a
resize handler that re-clamps the saved position so a shrink can never
strand it off-screen. The Cmd+K pets toggle collapses to a single paw
button on the search row (CommandInput gains a `right` slot) instead of a
full row, and the active pet uses the model-picker's neutral check (no
green / "Active" text). Drops the ellipsis from "Pets"/"Change theme" and
the now-dead pets.active / pets.adopting i18n keys.
2026-06-16 19:15:09 -05:00
Brooklyn Nicholson
7bd3715f1d docs(pets): feature guide + skill scale; register in sidebar/catalog
Add the Pets feature guide, document the scale knob (/pet scale, hermes pets
scale, slider) and fix the stale default (0.33), and register the petdex skill
page in the sidebar + catalog.
2026-06-16 18:35:34 -05:00
Brooklyn Nicholson
9f27f159cc feat(desktop): Cmd+K pet picker + appearance gallery & size slider
Pets get a Cmd+K page (browse/search/adopt/toggle) like the theme picker, plus
an Appearance gallery with a live size slider. A shared pet-gallery store owns
fetch/cache/thumb-cache/optimistic mutations so both surfaces stay in sync and
toggles never re-pull the network gallery. Fully localized (en/ja/zh/zh-hant).
2026-06-16 18:35:25 -05:00
Brooklyn Nicholson
eceb91cffd feat(pets): gateway pet.scale RPC + per-state frames; TUI live rescale
pet.info now ships framesByState; pet.cells returns the active scale. New
pet.scale RPC persists display.pet.scale for the desktop slider. The TUI busts
its frame cache when the scale changes on its existing poll — no new polling.
2026-06-16 18:35:25 -05:00
Brooklyn Nicholson
52134078e3 refactor(pets): unify the /pet slash command; drop /pets
/pet now toggles, browses (/pet list), adopts (/pet <slug>), and resizes
(/pet scale <n>) across CLI, TUI, and desktop; the separate /pets command is
removed. CLI pet-state derivation delegates to agent.pet.state.derive_pet_state
so the surfaces can't drift. Adds set_pet_scale/toggle/gallery helpers +
`hermes pets scale`.
2026-06-16 18:35:09 -05:00
Brooklyn Nicholson
b6abd39ca5 feat(pets): scale bounds + clamp in the pet engine
Add MIN_SCALE/MAX_SCALE (0.1–3.0) and a single `clamp_scale()` validation
point, used by every surface that lets the user resize the pet (`/pet scale`,
`hermes pets scale`, the gateway `pet.scale` RPC, and the desktop slider).
2026-06-16 18:33:38 -05:00
Brooklyn Nicholson
d1b1308e2a feat(pets): trim ragged spritesheet frames; per-state frame counts
petdex sheets are left-packed: a state row with fewer than FRAMES_PER_STATE
real frames pads the trailing columns transparent, so animating into one
flashes the pet blank. The engine now stops each row at the first blank
column (`_raw_frames`) and exposes `state_frame_counts()`; the desktop canvas
steps only the frames that exist (`framesByState`) instead of a fixed count.
2026-06-16 18:33:27 -05:00
Brooklyn Nicholson
a0aa1bd11c Merge branches 'bb/pets' and 'main' of github.com:NousResearch/hermes-agent into bb/pets 2026-06-16 16:12:37 -05:00
Brooklyn Nicholson
5cd2f6bd36 Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/pets 2026-06-16 15:06:24 -05:00
Brooklyn Nicholson
4bc20abbd0 feat(pets): wire all six animation states across every surface
The pet engine defined idle/run/review/wave/jump/failed, but the live
signals only ever drove a subset. Feed the dormant beats from signals
each surface already tracks, sharing one trigger via todos_all_done().

- CLI: review while reasoning, plus a transient end-of-turn flash —
  failed on a tool error, jump on a finished plan, else wave.
- TUI: petFlashStore flashes jump/wave on message.complete and failed
  on error; usePet honors the flash over derived state, auto-expiring.
- Desktop: wake the dead setPetActivity path — reasoning/tool/complete/
  error now flash via flashPetActivity (gated to the active session);
  the $petState computed drops stale tool/reasoning flags once at rest.
2026-06-15 14:00:24 -05:00
Brooklyn Nicholson
0586c8073e feat(pets): split /pets (list) from /pet (select)
/pets is the collection — installed pets with the active one marked, plus
/pets gallery to browse the petdex catalog. /pet is selection only: /pet
<slug> to bring one out, /pet off to put it away, bare /pet for status.

Both are cli_only — a sprite can't render in a Telegram/Slack message (which
is why /pet never had a gateway handler), so they stay on the CLI/TUI/desktop
surfaces and out of the messaging menus, keeping Slack under its 50-slash cap.
2026-06-15 12:50:26 -05:00
Brooklyn Nicholson
c176054c33 feat(pets): make display.pet.scale the single master size knob
One scalar now shrinks every surface together. The desktop canvas already
multiplied native pixels by scale; the CLI/TUI now derive their terminal
width from it via constants.resolve_cols() instead of a separate pinned
unicode_cols (which is now an optional override, default 0 = auto).

Two sizing bugs fixed along the way:
- kitty placement sized its c×r cell box from a native-aspect column count,
  so small pets got upscaled ~2× to fill it. It now derives the box from the
  scaled frame pixels (_cell_box, shared with the half-block frame() path),
  so kitty tracks scale like the GUI does.
- half-blocks can't follow scale all the way down — a cell samples the sprite
  at 1 horizontal + 2 vertical taps, so a tiny width turns the pet to mush.
  cols_for_scale() clamps to a legibility floor (UNICODE_MIN_COLS) and only
  grows above it, while kitty/GUI keep shrinking on true pixels.

Default scale lowered 0.7 -> 0.33 (glanceable corner sprite); the 0.7 literal
fallbacks in pets.py and the desktop sprite now reference the shared default.
2026-06-15 01:49:27 -05:00
Brooklyn Nicholson
fdcfa44584 feat(pets): crisp Kitty images in the TUI + reactive pet pane in the base CLI
The TUI now renders pets through Kitty's Unicode-placeholder protocol on
Kitty/Ghostty: the image transmits once per frame under a stable id and the
static placeholder grid (U+10EEEE + diacritics, image id in the fg color)
animates underneath without Ink ever repainting. Only Kitty is grid-safe in
Ink, so iTerm/Sixel/tmux/dashboard keep the half-block fallback.

The base CLI gains parity with the TUI's PetPane: a right-aligned half-block
sprite above the prompt, reactive to agent activity and animated by an
invalidate timer. Half-blocks only — raw image escapes can't survive
prompt_toolkit's patch_stdout output layer.

Also: right-align the pet in the TUI (justifyContent) and in `hermes pets
show` (graphics path), and render lone-opaque half-blocks fg-only so
transparent sprite edges stop painting black boxes.
2026-06-15 01:27:40 -05:00
Brooklyn Nicholson
2572617d5a fix(pets): live pet switching in the TUI, steady-redraw + right-align in CLI
Three rendering/state bugs surfaced while testing:

- TUI never reacted to a pet adopted/switched elsewhere (picker, /pet,
  hermes pets select). usePet stopped polling once enabled and keyed its
  frame cache by state only, so a new slug couldn't take over. Poll
  pet.cells steadily, treat its slug/enabled as source of truth, and key
  the cache by slug so a switch re-pulls the new sprite live.
- `hermes pets show` climbed up the screen each frame: the cursor-up count
  was rows+2 but only rows+1 lines were drawn. Move up exactly what we
  wrote so frames overwrite in place.
- Right-align the sprite against the terminal edge (CLI) and the pane
  (TUI width=100%), per request.
2026-06-15 00:37:24 -05:00
Brooklyn Nicholson
4db86e349c feat(pets): interactive /pet picker overlay in the TUI
/pet (and /pet list) opened the text-only slash worker, so the TUI just
printed a catalog you couldn't act on. The TUI already runs interactive
overlays (sessions, model, skills) — pets just never got one.

Add a PetPicker overlay (mirrors SkillsHub): it pulls pet.gallery, filters
as you type, ranks active→installed→curated, and adopts the highlight via
pet.select — the mascot lights up live on usePet's next poll. /pet <slug>,
/pet off, /pet status keep their text behaviour through the slash worker.
2026-06-15 00:30:06 -05:00
Brooklyn Nicholson
db00cbfd56 fix(pets): render half-blocks in the VS Code/Cursor terminal
detect_terminal_graphics() mapped TERM_PROGRAM=vscode to the iTerm2 inline
image protocol, but the integrated terminal doesn't render inline images
unless terminal.integrated.enableImages is on — so `hermes pets show`
emitted image escapes xterm.js silently drops, leaving just the label.
It also inherits ITERM_SESSION_ID/KITTY_WINDOW_ID when launched from those
terminals, which false-positived the same way.

Trust the authoritative TERM_PROGRAM=vscode and fall back to truecolor
half-blocks (always renderable in its grid); users who enabled images can
still pin display.pet.render_mode explicitly.
2026-06-15 00:20:34 -05:00
Brooklyn Nicholson
6681bef707 feat(pets): petdex animated mascots across CLI, TUI, and desktop
Adopt an animated petdex pet that reacts to agent activity (running on
tool calls, celebrating on success, sulking on errors) across all three
surfaces, driven by a shared Python pet engine so the base CLI and TUI
don't duplicate logic.

- agent/pet/: shared engine — manifest fetch, on-disk store, state
  mapping, and terminal-graphics/half-block encoding.
- hermes pets CLI (list/install/select/remove/off/doctor/show) +
  display.pet config block; petdex skill.
- TUI half-block PetPane via pet.cells RPC; desktop floating mascot with
  a canvas renderer + an Appearance opt-in picker (install/select/remove,
  lazy server-cropped thumbnails).
- gateway pet.* RPCs run on the worker pool so picker previews fetch
  concurrently instead of serializing on the reader thread.
2026-06-14 23:38:41 -05:00
533 changed files with 12611 additions and 44192 deletions

View File

@@ -102,3 +102,6 @@ acp_registry/
.gitattributes
.hadolint.yaml
.mailmap
# Top-level LICENSE (not matched by *.md); not needed inside the container
LICENSE

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

1
.gitignore vendored
View File

@@ -5,7 +5,6 @@
*.pyc*
__pycache__/
.venv/
.venv
.vscode/
.env
.env.local

View File

@@ -9,11 +9,8 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df228
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
FROM debian:13.4
# Disable Python stdout buffering to ensure logs are printed immediately.
# Do not write .pyc files at runtime: /opt/hermes is immutable in the
# published container and writable state belongs under /opt/data.
# Disable Python stdout buffering to ensure logs are printed immediately
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# Store Playwright browsers outside the volume mount so the build-time
# install survives the /opt/data volume overlay at runtime.
@@ -189,38 +186,36 @@ RUN cd web && npm run build && \
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY . .
COPY --chown=hermes:hermes . .
# ---------- Permissions ----------
# Link hermes-agent itself (editable). Deps are already installed in the
# cached layer above; `--no-deps` makes this a fast egg-link creation with no
# resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
# instances must not be able to self-edit the installed source or venv; user
# data, skills, plugins, config, logs, and dashboard uploads live under
# /opt/data instead. Root can still repair the image during build/boot, but
# supervised Hermes processes drop to the non-root hermes user.
# Make install dir world-readable so any HERMES_UID can read it at runtime.
# The venv needs to be traversable too.
# node_modules trees additionally need to be writable by the hermes user
# so the runtime `npm install` triggered by _tui_need_npm_install() in
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
# not chowned here.
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
# gateway state artifacts beneath the package after services drop privileges,
# especially when the hermes UID is remapped at boot (#27221).
# The .venv MUST remain hermes-writable so lazy_deps.py can install
# remaining optional platform packages and future pin bumps at first use.
# Without this, `uv pip install` fails with EACCES and adapters silently
# fail to load. See tools/lazy_deps.py.
USER root
RUN mkdir -p /opt/hermes/bin && \
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
chmod 0755 /opt/hermes/bin/hermes && \
printf 'docker\n' > /opt/hermes/.install_method && \
chown -R root:root /opt/hermes && \
chmod -R a+rX /opt/hermes && \
chmod -R a-w /opt/hermes
# The ``.install_method`` stamp is baked next to the running code (the install
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
# volume that is commonly bind-mounted from the host and even shared with a
# host-side Desktop/CLI install; stamping it at boot used to clobber that
# host install's marker and wrongly block its ``hermes update``. A code-scoped
# stamp is read first by detect_install_method() and is immune to the share.
RUN chmod -R a+rX /opt/hermes && \
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
# the data volume. Each supervised service then drops to the hermes user via
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
# run as the default hermes user (UID 10000).
# ---------- Link hermes-agent itself (editable) ----------
# Deps are already installed in the cached layer above; `--no-deps` makes
# this a fast (~1s) egg-link creation with no resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# ---------- Bake build-time git revision ----------
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
# container always returns nothing — meaning `hermes dump` reports
@@ -240,9 +235,8 @@ RUN mkdir -p /opt/hermes/bin && \
# every published image has it.
ARG HERMES_GIT_SHA=
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
chmod u+w /opt/hermes && \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
fi
# ---------- s6-overlay service wiring ----------
@@ -288,8 +282,6 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
# check. (A separate launcher hardening is tracked independently.)
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
ENV HERMES_DISABLE_LAZY_INSTALLS=1
# `docker exec` privilege-drop shim. When operators run
# `docker exec <c> hermes ...` they default to root, and any file the
@@ -302,6 +294,7 @@ ENV HERMES_DISABLE_LAZY_INSTALLS=1
# Recursion is impossible because the shim exec's the venv binary by
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the

View File

@@ -531,14 +531,7 @@ def init_agent(
agent._last_activity_desc: str = "initializing"
agent._current_tool: str | None = None
agent._api_call_count: int = 0
# Opt-out flag for the between-turns MCP tool refresh (build_turn_context).
# Set on internal forks (e.g. background_review) that must keep ``tools[]``
# byte-identical to a parent for provider cache parity.
agent._skip_mcp_refresh = False
# Registry generation the current tool snapshot was derived from. Lets a
# late/concurrent refresh reject a stale (older-generation) rebuild instead
# of clobbering a newer one. Set adjacent to the tool snapshot below.
agent._tool_snapshot_generation = 0
# Rate limit tracking — updated from x-ratelimit-* response headers
# after each API call. Accessed by /usage slash command.
agent._rate_limit_state: Optional["RateLimitState"] = None
@@ -606,7 +599,6 @@ def init_agent(
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
# Cache anthropic image-to-text fallbacks per image payload/URL so a
# single tool loop does not repeatedly re-run auxiliary vision on the
@@ -960,14 +952,7 @@ def init_agent(
print(f"🔄 Fallback chain ({len(agent._fallback_chain)} providers): " +
"".join(f"{f['model']} ({f['provider']})" for f in agent._fallback_chain))
# Get available tools with filtering. Capture the registry generation this
# snapshot is derived from FIRST, so a later concurrent refresh can tell
# whether it holds a newer or staler view (see refresh_agent_mcp_tools).
try:
from tools.registry import registry as _snapshot_registry
agent._tool_snapshot_generation = _snapshot_registry._generation
except Exception:
agent._tool_snapshot_generation = 0
# Get available tools with filtering
agent.tools = _ra().get_tool_definitions(
enabled_toolsets=enabled_toolsets,
disabled_toolsets=disabled_toolsets,
@@ -1170,9 +1155,6 @@ def init_agent(
"hermes_home": str(get_hermes_home()),
"agent_context": "primary",
}
if _init_kwargs["platform"] == "cli":
_init_kwargs["warning_callback"] = agent._emit_warning
_init_kwargs["status_callback"] = agent._emit_status
# Thread session title for memory provider scoping
# (e.g. honcho uses this to derive chat-scoped session keys)
if agent._session_db:
@@ -1241,35 +1223,12 @@ def init_agent(
# targets.
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
# Universal parallel-tool-call guidance toggle. Default True. Separate
# flag from task_completion_guidance because a user may want one but not
# the other. Steers the model to batch independent tool calls into a
# single turn; the runtime already executes such batches concurrently.
agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True))
# Local Python toolchain probe toggle. Default True. When False,
# the probe is skipped entirely (no subprocess calls, no system-prompt
# line). Useful for users on exotic setups where the probe heuristics
# are noisy.
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
# Per-platform prompt-hint overrides (config.yaml → platform_hints).
# Lets an enterprise admin append to or replace Hermes' built-in
# platform hint for a single messaging platform (e.g. WhatsApp) without
# affecting other platforms. Shape:
# platform_hints:
# whatsapp:
# append: "When tabular output would help, invoke the ... skill."
# slack:
# replace: "Custom Slack hint that fully replaces the default."
# Stored verbatim; resolution happens in agent/system_prompt.py against
# the active platform. Invalid shapes are ignored defensively so a bad
# config entry can never break prompt assembly.
_platform_hints_cfg = _agent_cfg.get("platform_hints", {})
if not isinstance(_platform_hints_cfg, dict):
_platform_hints_cfg = {}
agent._platform_hint_overrides = _platform_hints_cfg
# App-level API retry count (wraps each model API call). Default 3,
# overridable via agent.api_max_retries in config.yaml. See #11616.
try:

View File

@@ -1839,42 +1839,28 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
elif function_name == "memory":
def _execute(next_args: dict) -> Any:
target = next_args.get("target", "memory")
operations = next_args.get("operations")
from tools.memory_tool import memory_tool as _memory_tool
result = _memory_tool(
action=next_args.get("action"),
target=target,
content=next_args.get("content"),
old_text=next_args.get("old_text"),
operations=operations,
store=agent._memory_store,
)
# Bridge: notify external memory provider of built-in memory writes.
# Covers both the single-op shape and each add/replace inside a batch.
if agent._memory_manager:
if operations:
_mem_ops = [
op for op in operations
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
]
else:
_mem_ops = (
[{"action": next_args.get("action"), "content": next_args.get("content")}]
if next_args.get("action") in {"add", "replace"} else []
# Bridge: notify external memory provider of built-in memory writes
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
try:
agent._memory_manager.on_memory_write(
next_args.get("action", ""),
target,
next_args.get("content", ""),
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=tool_call_id,
),
)
for _op in _mem_ops:
try:
agent._memory_manager.on_memory_write(
_op.get("action", ""),
target,
_op.get("content", "") or "",
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=tool_call_id,
),
)
except Exception:
pass
except Exception:
pass
return _finish_agent_tool(result, next_args)
elif agent._memory_manager and agent._memory_manager.has_tool(function_name):
def _execute(next_args: dict) -> Any:

View File

@@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
_MCP_TOOL_PREFIX = "mcp__"
_MCP_TOOL_PREFIX = "mcp_"
def _get_claude_code_version() -> str:
@@ -2349,46 +2349,25 @@ def build_anthropic_kwargs(
text = text.replace("Nous Research", "Anthropic")
block["text"] = text
# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
# single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
# billing classifier treats a single-underscore ``mcp_`` tool name as
# a third-party-app fingerprint and rejects the request with HTTP 400
# "Third-party apps now draw from extra usage, not plan limits"
# (verified empirically: a single ``mcp_foo`` tool flips a request
# from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).
#
# Two cases, both must land on the double-underscore ``mcp__`` form:
# a) bare Hermes-native tools (``read_file``) -> ``mcp__read_file``
# b) native MCP server tools registered under their full
# single-underscore ``mcp_<server>_<tool>`` name
# (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``
# Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap
# left open: those tools were *skipped* and stayed single-underscore,
# so any session with an MCP server configured still tripped the
# classifier. normalize_response reverses both forms via registry
# lookup so the dispatcher still sees the original name. GH-25255.
def _to_oauth_wire_name(name: str) -> str:
if name.startswith("mcp__"):
return name # already correct, don't double-prefix
if name.startswith("mcp_"):
# single-underscore native MCP tool -> promote to double
return "mcp__" + name[len("mcp_"):]
return _MCP_TOOL_PREFIX + name # bare name -> mcp__<name>
# 3. Prefix tool names with mcp_ (Claude Code convention)
# Skip names that already begin with the marker — native MCP server
# tools (from mcp_servers: in config.yaml) are registered under their
# full mcp_<server>_<tool> name and would double-prefix otherwise,
# breaking round-trip registry lookup in normalize_response. GH-25255.
if anthropic_tools:
for tool in anthropic_tools:
if "name" in tool:
tool["name"] = _to_oauth_wire_name(tool["name"])
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
# 4. Apply the same normalization to tool names in message history
# (tool_use blocks) so replayed turns match the wire names above.
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
for msg in anthropic_messages:
content = msg.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict):
if block.get("type") == "tool_use" and "name" in block:
block["name"] = _to_oauth_wire_name(block["name"])
if not block["name"].startswith(_MCP_TOOL_PREFIX):
block["name"] = _MCP_TOOL_PREFIX + block["name"]
elif block.get("type") == "tool_result" and "tool_use_id" in block:
pass # tool_result uses ID, not name
@@ -2535,56 +2514,3 @@ def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any:
sorted(leaked),
)
return api_kwargs
def _is_stream_unavailable_error(exc: Exception) -> bool:
"""Return True when an Anthropic stream call should fall back to create()."""
err_lower = str(exc).lower()
if "stream" in err_lower and "not supported" in err_lower:
return True
if "invokemodelwithresponsestream" in err_lower:
from agent.bedrock_adapter import is_streaming_access_denied_error
return is_streaming_access_denied_error(exc)
return False
def create_anthropic_message(
client: Any,
api_kwargs: dict,
*,
log_prefix: str = "",
prefer_stream: bool = True,
) -> Any:
"""Create an Anthropic message, aggregating via stream when available.
Some Anthropic-compatible gateways are SSE-only: they ignore non-streaming
requests and return ``text/event-stream`` even for ``messages.create()``.
The SDK can surface that as raw text, so callers that expect a Message then
crash on ``.content``. Prefer ``messages.stream().get_final_message()`` to
match the main turn path, falling back to ``create()`` only for providers
that explicitly do not support streaming, such as restricted Bedrock roles.
"""
sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix)
messages_api = getattr(client, "messages", None)
stream_fn = getattr(messages_api, "stream", None)
if prefer_stream and callable(stream_fn):
stream_kwargs = dict(api_kwargs)
stream_kwargs.pop("stream", None)
try:
with stream_fn(**stream_kwargs) as stream:
return stream.get_final_message()
except Exception as exc:
if not _is_stream_unavailable_error(exc):
raise
logger.debug(
"%sAnthropic Messages stream unavailable; falling back to "
"messages.create(): %s",
log_prefix,
exc,
)
create_kwargs = dict(api_kwargs)
create_kwargs.pop("stream", None)
return messages_api.create(**create_kwargs)

View File

@@ -997,7 +997,7 @@ class _AnthropicCompletionsAdapter:
self._is_oauth = is_oauth
def create(self, **kwargs) -> Any:
from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.transports import get_transport
messages = kwargs.get("messages", [])
@@ -1041,7 +1041,7 @@ class _AnthropicCompletionsAdapter:
if not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
response = create_anthropic_message(self._client, anthropic_kwargs)
response = self._client.messages.create(**anthropic_kwargs)
_transport = get_transport("anthropic_messages")
_nr = _transport.normalize_response(
response, strip_tool_prefix=self._is_oauth

View File

@@ -300,7 +300,6 @@ def summarize_background_review_actions(
"target": args.get("target", "memory"),
"content": args.get("content", ""),
"old_text": args.get("old_text", ""),
"operations": args.get("operations") or [],
"name": args.get("name", ""),
"old_string": args.get("old_string", ""),
"new_string": args.get("new_string", ""),
@@ -354,7 +353,6 @@ def summarize_background_review_actions(
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
operations = detail.get("operations") or []
max_preview = 120
if is_skill:
change = data.get("_change", {})
@@ -378,21 +376,6 @@ def summarize_background_review_actions(
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
else:
actions.append(f"📝 {message}" if message else f"Skill {action}")
elif operations:
for op in operations:
op = op or {}
op_act = op.get("action", "")
op_content = (op.get("content") or "")
op_old = (op.get("old_text") or "")
if op_act == "add" and op_content:
preview = op_content[:max_preview] + ("" if len(op_content) > max_preview else "")
actions.append(f"{label} {preview}")
elif op_act == "replace" and op_content:
preview = op_content[:max_preview] + ("" if len(op_content) > max_preview else "")
actions.append(f"{label} ✏️ {preview}")
elif op_act == "remove" and op_old:
preview = op_old[:60] + ("" if len(op_old) > 60 else "")
actions.append(f"{label} {preview}")
elif action == "add" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} {preview}")
@@ -408,7 +391,6 @@ def summarize_background_review_actions(
"added" in message_lower
or "replaced" in message_lower
or "removed" in message_lower
or "applied" in message_lower
or (target and "add" in message.lower())
or "Entry added" in message
):
@@ -535,13 +517,6 @@ def _run_review_in_thread(
)
review_agent._memory_write_origin = "background_review"
review_agent._memory_write_context = "background_review"
# The review fork pins the parent's cached system prompt and keeps
# ``tools[]`` byte-identical to the parent so its outbound request
# hits the same provider cache prefix (see the toolset-parity note
# above). The between-turns MCP refresh in build_turn_context would
# add late-connecting MCP tools to this fork and break that parity,
# so opt the review fork out of it.
review_agent._skip_mcp_refresh = True
review_agent._memory_store = agent._memory_store
review_agent._memory_enabled = agent._memory_enabled
review_agent._user_profile_enabled = agent._user_profile_enabled

View File

@@ -1,295 +0,0 @@
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
One fetch/parse per concern, consumed identically by the CLI handler
(``cli.py::_show_billing``), the TUI JSON-RPC methods
(``tui_gateway/server.py``), and any other surface. Mirrors the proven
``agent/account_usage.py::build_credits_view`` pattern: parse the server payload
into a frozen dataclass; **fail open** — when not logged in or the portal is
unreachable, return a struct with ``logged_in=False`` and let the surface degrade
gracefully (never crash).
Money discipline: the server emits decimal STRINGS (``"142.5"``, not fixed 2dp).
We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
from typing import Any, Optional
logger = logging.getLogger(__name__)
# =============================================================================
# Decimal money helpers
# =============================================================================
def parse_money(value: Any) -> Optional[Decimal]:
"""Parse a server money value (decimal string) into :class:`Decimal`.
Returns None for missing/invalid input. Never raises. Accepts str/int (and,
defensively, float — though the server always sends strings).
"""
if value is None:
return None
try:
# Decimal(str(...)) avoids binary-float artifacts if a float ever sneaks in.
return Decimal(str(value).strip())
except (InvalidOperation, ValueError, TypeError):
return None
def format_money(value: Optional[Decimal]) -> str:
"""Format a Decimal as ``$X`` / ``$X.YY`` for display.
Whole dollars show no decimals; any fractional amount shows exactly 2dp:
``Decimal("142.5")`` → ``"$142.50"``, ``Decimal("100")`` → ``"$100"``,
``Decimal("0.01")`` → ``"$0.01"``.
"""
if value is None:
return ""
if value == value.to_integral_value():
# Whole dollars — no decimal point. format(..., "f") avoids 1E+3 for 1000.
return f"${format(value.to_integral_value(), 'f')}"
# Fractional — always show 2dp.
return f"${format(value.quantize(Decimal('0.01')), 'f')}"
# =============================================================================
# Parsed sub-structures
# =============================================================================
@dataclass(frozen=True)
class CardInfo:
brand: str
last4: str
@property
def masked(self) -> str:
return f"{self.brand} ····{self.last4}"
@dataclass(frozen=True)
class MonthlyCap:
limit_usd: Optional[Decimal] = None
spent_this_month_usd: Optional[Decimal] = None
is_default_ceiling: bool = False
@dataclass(frozen=True)
class AutoReload:
enabled: bool = False
threshold_usd: Optional[Decimal] = None
reload_to_usd: Optional[Decimal] = None
@dataclass(frozen=True)
class BillingState:
"""Parsed ``GET /api/billing/state`` — the overview screen's data.
Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
portal is unreachable.
"""
logged_in: bool
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
balance_usd: Optional[Decimal] = None
cli_billing_enabled: bool = False
charge_presets: tuple[Decimal, ...] = ()
min_usd: Optional[Decimal] = None
max_usd: Optional[Decimal] = None
card: Optional[CardInfo] = None
monthly_cap: Optional[MonthlyCap] = None
auto_reload: Optional[AutoReload] = None
portal_url: Optional[str] = None
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
error: Optional[str] = None
@property
def is_admin(self) -> bool:
"""True for OWNER/ADMIN — the roles that can manage billing."""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_charge(self) -> bool:
"""True when the UI should offer charge/auto-reload actions.
Admin role AND the per-org kill-switch on. (The server still enforces;
this is just for graying out actions the user can't take.)
"""
return self.is_admin and self.cli_billing_enabled
def _parse_card(raw: Any) -> Optional[CardInfo]:
if not isinstance(raw, dict):
return None
brand = raw.get("brand")
last4 = raw.get("last4")
if isinstance(brand, str) and isinstance(last4, str):
return CardInfo(brand=brand, last4=last4)
return None
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
if not isinstance(raw, dict):
return None
return MonthlyCap(
limit_usd=parse_money(raw.get("limitUsd")),
spent_this_month_usd=parse_money(raw.get("spentThisMonthUsd")),
is_default_ceiling=bool(raw.get("isDefaultCeiling")),
)
def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
if not isinstance(raw, dict):
return None
return AutoReload(
enabled=bool(raw.get("enabled")),
threshold_usd=parse_money(raw.get("thresholdUsd")),
reload_to_usd=parse_money(raw.get("reloadToUsd")),
)
def billing_state_from_payload(
payload: dict[str, Any], *, portal_url: Optional[str] = None
) -> BillingState:
"""Map a raw ``/api/billing/state`` JSON dict into :class:`BillingState`."""
raw_org = payload.get("org")
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
raw_bounds = payload.get("bounds")
bounds: dict[str, Any] = raw_bounds if isinstance(raw_bounds, dict) else {}
presets: list[Decimal] = []
for item in payload.get("chargePresets") or ():
parsed = parse_money(item)
if parsed is not None:
presets.append(parsed)
return BillingState(
logged_in=True,
org_id=org.get("id"),
org_slug=org.get("slug"),
org_name=org.get("name"),
role=org.get("role"),
balance_usd=parse_money(payload.get("balanceUsd")),
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
charge_presets=tuple(presets),
min_usd=parse_money(bounds.get("minUsd")),
max_usd=parse_money(bounds.get("maxUsd")),
card=_parse_card(payload.get("card")),
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
auto_reload=_parse_auto_reload(payload.get("autoReload")),
portal_url=portal_url,
)
# =============================================================================
# Fail-open builders (the surface front doors)
# =============================================================================
def build_billing_state(*, timeout: float = 15.0) -> BillingState:
"""Fetch + parse ``/api/billing/state``. Fail-open.
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
a clear message rather than crashing.
"""
try:
from hermes_cli.nous_billing import (
BillingAuthError,
BillingError,
_absolutize_portal_url,
get_billing_state,
resolve_portal_base_url,
)
except Exception:
return BillingState(logged_in=False, error="billing client unavailable")
try:
payload = get_billing_state(timeout=timeout)
except BillingAuthError:
return BillingState(logged_in=False)
except BillingError as exc:
logger.debug("billing ▸ /state fetch failed (fail-open)", exc_info=True)
return BillingState(logged_in=False, error=str(exc))
except Exception:
logger.debug("billing ▸ /state unexpected error (fail-open)", exc_info=True)
return BillingState(logged_in=False, error="could not load billing state")
# Prefer a server-supplied portalUrl if present (resolved to absolute in case
# it's relative); else build the standard one.
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
if not portal_url:
try:
portal_url = _fallback_portal_url(resolve_portal_base_url())
except Exception:
portal_url = None
return billing_state_from_payload(payload, portal_url=portal_url)
def _fallback_portal_url(base: str) -> str:
"""Standard billing deep-link when the server omits ``portalUrl``."""
return f"{base.rstrip('/')}/billing?topup=open"
# =============================================================================
# Idempotency
# =============================================================================
def new_idempotency_key() -> str:
"""Fresh UUID for a user-confirmed purchase (reuse on retry of the SAME buy).
The ``Idempotency-Key`` header is mandatory on ``POST /charge``; generate one
per confirmed purchase and reuse it across retries so a double-submit collapses
to a single charge. Never reuse a key across different amounts (the server
returns 409 idempotency_conflict).
"""
return str(uuid.uuid4())
# =============================================================================
# Amount validation (Screen 3 custom input)
# =============================================================================
@dataclass(frozen=True)
class AmountValidation:
ok: bool
amount: Optional[Decimal] = None
error: Optional[str] = None
def validate_charge_amount(
raw: str, *, min_usd: Optional[Decimal], max_usd: Optional[Decimal]
) -> AmountValidation:
"""Validate a custom charge amount against bounds + 2dp (multipleOf 0.01).
Mirrors the server's accept/reject so the UI can give instant feedback rather
than round-tripping a sure-to-fail charge. The server is still authoritative.
"""
cleaned = (raw or "").strip().lstrip("$").strip()
amount = parse_money(cleaned)
if amount is None:
return AmountValidation(ok=False, error="Enter a dollar amount, e.g. 100")
if amount <= 0:
return AmountValidation(ok=False, error="Amount must be greater than $0")
# multipleOf 0.01 — reject sub-cent precision.
if amount != amount.quantize(Decimal("0.01")):
return AmountValidation(ok=False, error="Amount can't be smaller than a cent")
if min_usd is not None and amount < min_usd:
return AmountValidation(ok=False, error=f"Minimum is {format_money(min_usd)}")
if max_usd is not None and amount > max_usd:
return AmountValidation(ok=False, error=f"Maximum is {format_money(max_usd)}")
return AmountValidation(ok=True, amount=amount)

View File

@@ -262,26 +262,6 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
return converted or None
# Provider-executed built-in tool *declaration* types accepted on the
# Responses ``tools`` array. These are declared by ``type`` alone (no
# client-side name/parameters schema) and run server-side — the provider
# owns the implementation and reports progress via the matching ``*_call``
# output items. Hermes injects xAI's native ``web_search`` for the xAI
# transport (see agent/transports/codex.py); the rest are listed so the
# preflight validator passes them through rather than rejecting them as
# "unsupported type". Mirrors the ``*_call`` item-type set used in
# _normalize_codex_response.
_RESPONSES_BUILTIN_TOOL_TYPES = {
"web_search",
"web_search_preview",
"file_search",
"code_interpreter",
"image_generation",
"computer_use_preview",
"local_shell",
}
# ---------------------------------------------------------------------------
# Message format conversion
# ---------------------------------------------------------------------------
@@ -822,22 +802,7 @@ def _preflight_codex_api_kwargs(
for idx, tool in enumerate(tools):
if not isinstance(tool, dict):
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
tool_type = tool.get("type")
# Provider-executed built-in tools (xAI native web_search, code
# interpreter, etc.) are declared by ``type`` alone and carry no
# ``name``/``parameters`` schema — the provider owns the
# implementation. Pass them through verbatim instead of forcing
# them through the function-tool validation below (which would
# otherwise reject them with "unsupported type"). See
# agent/transports/codex.py for where xAI's native web_search is
# injected.
if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
normalized_tools.append(dict(tool))
continue
if tool_type != "function":
if tool.get("type") != "function":
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
name = tool.get("name")
@@ -1121,33 +1086,6 @@ def _normalize_codex_response(
saw_final_answer_phase = False
saw_reasoning_item = False
# Server-side built-in tool calls (xAI's native web_search, code
# interpreter, etc.) are executed by the provider and reported as
# discrete ``*_call`` output items. xAI's /v1/responses surface
# (e.g. grok-composer-2.5-fast on SuperGrok OAuth) routinely leaves
# these items at ``status="in_progress"`` even when the overall
# ``response.status == "completed"`` — the search ran to completion
# server-side, the per-item status simply isn't reconciled. These
# are NOT a signal that the model's turn is unfinished, so they must
# not flip ``has_incomplete_items``. Only the response-level status
# and genuine model output items (message/reasoning/function_call)
# govern the incomplete verdict. Without this guard, any turn where
# grok-composer invokes server-side search is misclassified as
# ``finish_reason="incomplete"`` and burns 3 fruitless continuation
# retries before failing with "Codex response remained incomplete
# after 3 continuation attempts". client-side function/custom tool
# calls keep their own in_progress handling below (they are skipped,
# not awaited).
_SERVER_SIDE_TOOL_CALL_TYPES = {
"web_search_call",
"file_search_call",
"code_interpreter_call",
"image_generation_call",
"computer_call",
"local_shell_call",
"mcp_call",
}
for item in output:
item_type = getattr(item, "type", None)
item_status = getattr(item, "status", None)
@@ -1156,10 +1094,7 @@ def _normalize_codex_response(
else:
item_status = None
if (
item_status in {"queued", "in_progress", "incomplete"}
and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES
):
if item_status in {"queued", "in_progress", "incomplete"}:
has_incomplete_items = True
saw_streaming_or_item_incomplete = True

View File

@@ -290,7 +290,6 @@ def run_codex_app_server_turn(
original_user_message=original_user_message,
final_response=turn.final_text,
interrupted=False,
messages=messages,
)
except Exception:
logger.debug("external memory sync raised", exc_info=True)

View File

@@ -512,16 +512,6 @@ def compress_context(
old_title = agent._session_db.get_session_title(agent.session_id)
# Trigger memory extraction on the old session before it rotates.
agent.commit_memory_session(messages)
# Flush any un-persisted messages from the current turn to the
# old session *before* rotating. compress_context() can be
# called mid-turn (auto-compress when context exceeds threshold)
# at a point when _flush_messages_to_session_db() has not yet
# run. Without this, messages generated during the current turn
# are silently lost on session rotation (#47202).
try:
agent._flush_messages_to_session_db(messages)
except Exception:
pass # best-effort — don't block compression on a flush error
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
@@ -712,58 +702,33 @@ def try_shrink_image_parts_in_messages(
# actually brought under the target.
unshrinkable_oversized = 0
def _decode_pixels(data_url: str) -> Optional[tuple]:
"""Return ``(width, height)`` of a base64 data URL, or None on failure.
Soft-depends on Pillow; returns None (caller falls back to a
bytes-only check) if Pillow is missing or the payload is corrupt.
"""
try:
import base64 as _b64_dim
import io as _io_dim
header_d, _, data_d = data_url.partition(",")
if not data_d or not data_url.startswith("data:"):
return None
from PIL import Image as _PILImage
with _PILImage.open(_io_dim.BytesIO(_b64_dim.b64decode(data_d))) as _img:
return _img.size
except Exception:
def _shrink_data_url(url: str) -> Optional[str]:
"""Return a smaller data URL, or None if shrink can't help."""
if not isinstance(url, str) or not url.startswith("data:"):
return None
def _shrink_data_url(url: str) -> tuple:
"""Return ``(resized_url, unshrinkable)`` for a data URL.
``resized_url`` is a smaller/dimension-correct data URL, or None when
no rewrite was applied. ``unshrinkable`` is True only when the image
exceeded a constraint (byte-size or dimensions) and the resize failed
to satisfy *that same* constraint — so the caller knows retrying is
pointless even if a different image in the request shrank.
"""
if not isinstance(url, str) or not url.startswith("data:"):
return None, False
# Determine which constraint is binding. The accept/reject gate below
# MUST be checked against the same axis that triggered the shrink: a
# downscaled screenshot PNG routinely re-encodes to *more* bytes than
# the original (PNG compression is non-monotonic in image size — a
# smaller raster with LANCZOS resampling noise compresses worse than a
# larger smooth one). Rejecting a pixel-correct downscale purely
# because its bytes grew permanently wedges sessions on the Anthropic
# many-image 2000px path (#48013).
# Check both byte size AND pixel dimensions.
needs_shrink = len(url) > target_bytes # over byte budget
triggered_by = "bytes" if needs_shrink else None
if not needs_shrink:
# Bytes are fine check pixel dimensions against the provider's
# reported per-side cap. A screenshot can be tiny in bytes yet
# too large in pixels.
dims = _decode_pixels(url)
if dims is None:
# Pillow missing or corrupt data — fall back to byte-only.
return None, False
if max(dims) <= max_dimension:
return None, False # both bytes and pixels are within limits
needs_shrink = True
triggered_by = "dimension"
# Even if bytes are fine, check pixel dimensions against the
# provider's reported per-side cap. A screenshot can be tiny in
# bytes yet too large in pixels.
try:
import base64 as _b64_dim
header_d, _, data_d = url.partition(",")
if not data_d:
return None
raw_d = _b64_dim.b64decode(data_d)
from PIL import Image as _PILImage
import io as _io_dim
with _PILImage.open(_io_dim.BytesIO(raw_d)) as _img:
if max(_img.size) <= max_dimension:
return None # both bytes and pixels are fine
needs_shrink = True # pixels exceed limit, force shrink
except Exception:
# If we can't check dimensions (Pillow unavailable, corrupt
# image, etc.), fall back to byte-only check.
return None
try:
header, _, data = url.partition(",")
@@ -795,45 +760,13 @@ def try_shrink_image_parts_in_messages(
Path(tmp.name).unlink(missing_ok=True)
except Exception:
pass
if not resized:
# Resize returned nothing — Pillow couldn't help.
return None, True
if triggered_by == "bytes":
# Byte budget is the binding constraint — bytes must shrink.
if len(resized) >= len(url):
return None, True # re-encode made it bigger
# The per-side dimension cap is ALSO an active provider
# constraint on this request (the caller passes the parsed cap
# to both this helper and the resizer). _resize_image_for_vision
# returns a best-effort, possibly-over-cap blob when it
# exhausts its halving budget — it freezes the long side once
# the short side hits its 64px floor, so a very-high-aspect
# image can stay over the cap even after bytes shrank. If the
# output is still over the cap, retrying would re-400 on
# dimensions; treat it as unshrinkable. (Skip when dims can't
# be decoded — preserves historical byte-only behaviour.)
new_dims = _decode_pixels(resized)
if new_dims is not None and max(new_dims) > max_dimension:
return None, True
return resized, False
# triggered_by == "dimension": the per-side cap is binding. The
# re-encode may have grown in bytes; accept it as long as it is now
# within the dimension cap. Verify the new dimensions when we can.
new_dims = _decode_pixels(resized)
if new_dims is not None:
if max(new_dims) <= max_dimension:
return resized, False
# Still over the per-side cap — the resize didn't satisfy it.
return None, True
# Couldn't verify the re-encode's dimensions (corrupt output or
# Pillow gone mid-call). Fall back to the historical "bytes must
# shrink" gate so we never accept an unverifiable, byte-larger blob.
if len(resized) >= len(url):
return None, True
return resized, False
if not resized or len(resized) >= len(url):
# Shrink didn't help (or made it bigger — corrupt input?).
return None
return resized
except Exception as exc:
logger.warning("image-shrink recovery: re-encode failed — %s", exc)
return None, triggered_by is not None
return None
for msg in api_messages:
if not isinstance(msg, dict):
@@ -852,18 +785,20 @@ def try_shrink_image_parts_in_messages(
# OpenAI Responses: {"image_url": "data:..."}
if isinstance(image_value, dict):
url = image_value.get("url", "")
resized, unshrinkable = _shrink_data_url(url)
resized = _shrink_data_url(url)
if resized:
image_value["url"] = resized
changed_count += 1
elif unshrinkable:
elif isinstance(url, str) and url.startswith("data:") \
and len(url) > target_bytes:
unshrinkable_oversized += 1
elif isinstance(image_value, str):
resized, unshrinkable = _shrink_data_url(image_value)
resized = _shrink_data_url(image_value)
if resized:
part["image_url"] = resized
changed_count += 1
elif unshrinkable:
elif image_value.startswith("data:") \
and len(image_value) > target_bytes:
unshrinkable_oversized += 1
if changed_count:

View File

@@ -474,7 +474,6 @@ def run_conversation(
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""
Run a complete conversation with tool calling until completion.
@@ -490,8 +489,6 @@ def run_conversation(
persist_user_message: Optional clean user message to store in
transcripts/history when user_message contains API-only
synthetic prefixes.
persist_user_timestamp: Optional platform event timestamp to store
as metadata on that persisted user message.
or queuing follow-up prefetch work.
Returns:
@@ -513,7 +510,6 @@ def run_conversation(
task_id,
stream_callback,
persist_user_message,
persist_user_timestamp,
restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates,
@@ -3197,22 +3193,15 @@ def run_conversation(
# Terminal — flush buffered context so the user sees
# what was tried before the abort.
agent._flush_status_buffer()
# Summarize once: Cloudflare/proxy HTML challenge pages and
# other raw provider bodies must be collapsed to a short
# one-liner here, otherwise the full page leaks into the
# returned ``error`` field and downstream consumers deliver
# it verbatim (e.g. a cron failure notification dumped a
# ~60KB Cloudflare challenge page as 31 Discord messages).
_nonretryable_summary = agent._summarize_api_error(api_error)
if classified.reason == FailoverReason.content_policy_blocked:
agent._emit_status(
f"❌ Provider safety filter blocked this request: "
f"{_nonretryable_summary}"
f"{agent._summarize_api_error(api_error)}"
)
else:
agent._emit_status(
f"❌ Non-retryable error (HTTP {status_code}): "
f"{_nonretryable_summary}"
f"{agent._summarize_api_error(api_error)}"
)
agent._vprint(f"{agent.log_prefix}❌ Non-retryable client error (HTTP {status_code}). Aborting.", force=True)
agent._vprint(f"{agent.log_prefix} 🔌 Provider: {_provider} Model: {_model}", force=True)
@@ -3297,17 +3286,18 @@ def run_conversation(
else:
agent._persist_session(messages, conversation_history)
if classified.reason == FailoverReason.content_policy_blocked:
_summary = agent._summarize_api_error(api_error)
_policy_response = (
"⚠️ The model provider's safety filter blocked this request "
"(not a Hermes/gateway failure).\n\n"
f"Provider message: {_nonretryable_summary}\n\n"
f"Provider message: {_summary}\n\n"
f"{_CONTENT_POLICY_RECOVERY_HINT}"
)
return _content_policy_blocked_result(
messages,
api_call_count,
final_response=_policy_response,
error_detail=_nonretryable_summary,
error_detail=_summary,
)
return {
"final_response": None,
@@ -3315,7 +3305,7 @@ def run_conversation(
"api_calls": api_call_count,
"completed": False,
"failed": True,
"error": _nonretryable_summary,
"error": str(api_error),
}
if retry_count >= max_retries:
@@ -3762,30 +3752,8 @@ def run_conversation(
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(assistant_msg)
for tc in assistant_message.tool_calls:
_tc_name = tc.function.name
if _tc_name not in agent.valid_tool_names:
# A blank/whitespace-only name is not a typo the
# model can fuzzy-correct toward a real tool — it is
# almost always a weak open model echoing tool-call
# XML/JSON it saw in file or tool output (#47967:
# <tool_call>/<invoke name=...> payloads in a file
# prime mimo/nemotron-class models to emit empty
# structured calls). Dumping the full tool catalog
# in that case feeds the priming loop more names to
# mimic and inflates context 3-4x across retries, so
# send a terse error that tells the model in-context
# tool-call syntax is DATA, not a call to make.
if not (_tc_name or "").strip():
content = (
"Tool call rejected: the tool name was empty. "
"If tool-call XML or JSON appeared in file "
"contents or tool output, that is data — do "
"not re-emit it as a tool call. To call a "
"tool, use a valid name from your tool list; "
"otherwise reply in plain text."
)
else:
content = f"Tool '{_tc_name}' does not exist. Available tools: {available}"
if tc.function.name not in agent.valid_tool_names:
content = f"Tool '{tc.function.name}' does not exist. Available tools: {available}"
else:
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
messages.append({

View File

@@ -15,7 +15,6 @@ from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL
from hermes_cli.config import load_env
from agent.secret_scope import get_secret as _get_secret
from agent.credential_persistence import (
is_borrowed_credential_source,
sanitize_borrowed_credential_payload,
@@ -1667,7 +1666,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
_env_file = load_env()
def _env_val(key: str) -> str:
return (_env_file.get(key) or _get_secret(key, "") or "").strip()
return (_env_file.get(key) or os.environ.get(key) or "").strip()
anthropic_api_key = _env_val("ANTHROPIC_API_KEY")
anthropic_oauth_env = (
@@ -1953,7 +1952,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
# changes to the .env file.
def _get_env_prefer_dotenv(key: str) -> str:
env_file = load_env()
val = env_file.get(key) or _get_secret(key, "") or ""
val = env_file.get(key) or os.environ.get(key) or ""
return val.strip()
# Honour user suppression — `hermes auth remove <provider> <N>` for an

View File

@@ -57,11 +57,6 @@ DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
DEFAULT_MIN_IDLE_HOURS = 2
DEFAULT_STALE_AFTER_DAYS = 30
DEFAULT_ARCHIVE_AFTER_DAYS = 90
# Consolidation (the LLM umbrella-building fork) is OFF by default. The
# deterministic inactivity prune (apply_automatic_transitions) still runs
# whenever the curator is enabled; only the opinionated, aux-model-cost
# consolidation pass is opt-in.
DEFAULT_CONSOLIDATE = False
# ---------------------------------------------------------------------------
@@ -187,22 +182,6 @@ def get_prune_builtins() -> bool:
return bool(cfg.get("prune_builtins", True))
def get_consolidate() -> bool:
"""Whether the curator runs its LLM consolidation (umbrella-building) pass.
OFF by default. When off, a curator run does ONLY the deterministic
inactivity prune (mark stale / archive long-unused skills) and skips the
forked aux-model review entirely — no consolidation, no umbrella-building,
no aux-model cost. Set ``curator.consolidate: true`` to opt back into the
LLM pass that merges overlapping skills into class-level umbrellas.
The explicit ``hermes curator run --consolidate`` flag overrides this for
a single invocation regardless of the config value.
"""
cfg = _load_config()
return bool(cfg.get("consolidate", DEFAULT_CONSOLIDATE))
# ---------------------------------------------------------------------------
# Idle / interval check
# ---------------------------------------------------------------------------
@@ -1429,38 +1408,25 @@ def run_curator_review(
on_summary: Optional[Callable[[str], None]] = None,
synchronous: bool = False,
dry_run: bool = False,
consolidate: Optional[bool] = None,
) -> Dict[str, Any]:
"""Execute a single curator review pass.
Steps:
1. Apply automatic state transitions (pure, no LLM).
2. If consolidation is enabled AND there are agent-created skills, spawn
a forked AIAgent that runs the LLM review prompt against the current
candidate list.
2. If there are agent-created skills, spawn a forked AIAgent that runs
the LLM review prompt against the current candidate list.
3. Update .curator_state with last_run_at and a one-line summary.
4. Invoke *on_summary* with a user-visible description.
If *synchronous* is True, the LLM review runs in the calling thread; the
default is to spawn a daemon thread so the caller returns immediately.
*consolidate* gates the LLM umbrella-building pass. ``None`` (the default)
reads ``curator.consolidate`` from config (OFF by default). Passing
``True``/``False`` overrides the config for this invocation — used by the
``hermes curator run --consolidate`` flag. When consolidation is off, only
the deterministic inactivity prune runs and the forked aux-model review is
skipped entirely (no aux-model cost).
If *dry_run* is True, the automatic stale/archive transitions are SKIPPED
and the LLM review pass is instructed to produce a report only — no
skill_manage mutations, no terminal archive moves. The REPORT.md still
gets written and ``state.last_report_path`` still records it so users
can read what the curator WOULD have done. A dry-run also honors
*consolidate*: when consolidation is off, the preview only reports the
deterministic prune candidates.
can read what the curator WOULD have done.
"""
if consolidate is None:
consolidate = get_consolidate()
start = datetime.now(timezone.utc)
if dry_run:
# Count candidates without mutating state.
@@ -1523,53 +1489,6 @@ def run_curator_review(
before_report = []
before_names = {r.get("name") for r in before_report if isinstance(r, dict)}
# Consolidation gate. When off (the default), the curator does ONLY the
# deterministic inactivity prune above — no forked aux-model review, no
# umbrella-building, no aux-model cost. Record the run, write a report
# reflecting the prune-only outcome, and return without spawning a fork.
if not consolidate:
final_summary = (
f"{prefix}{auto_summary}; llm: skipped (consolidation off)"
)
llm_meta = {
"final": "",
"summary": "skipped (consolidation off)",
"model": "",
"provider": "",
"tool_calls": [],
"error": None,
}
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
state2 = load_state()
state2["last_run_duration_seconds"] = elapsed
state2["last_run_summary"] = final_summary
try:
after_report = skill_usage.agent_created_report()
except Exception:
after_report = []
try:
report_path = _write_run_report(
started_at=start,
elapsed_seconds=elapsed,
auto_counts=counts,
auto_summary=auto_summary,
before_report=before_report,
before_names=before_names,
after_report=after_report,
llm_meta=llm_meta,
)
if report_path is not None:
state2["last_report_path"] = str(report_path)
except Exception as e:
logger.debug("Curator report write failed: %s", e, exc_info=True)
save_state(state2)
if on_summary:
try:
on_summary(f"curator: {final_summary}")
except Exception:
pass
return
llm_meta: Dict[str, Any] = {}
try:
candidate_list = _render_candidate_list()

View File

@@ -46,7 +46,7 @@ import shutil
import tarfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from typing import Any, Dict, List, Optional, Tuple
from hermes_constants import get_hermes_home
from agent.skill_utils import is_excluded_skill_path
@@ -208,17 +208,13 @@ def _write_manifest(dest: Path, reason: str, archive_path: Path,
)
def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] = None) -> Optional[Path]:
def snapshot_skills(reason: str = "manual") -> Optional[Path]:
"""Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones.
Returns the snapshot directory path, or ``None`` if the snapshot was
skipped (backup disabled, skills dir missing, or an IO error occurred —
in which case we log at debug and return None so the curator never
aborts a pass because of a backup failure).
``protect_ids`` is forwarded to the prune step so callers can guarantee
specific snapshot ids survive even when they fall outside the keep
window (rollback passes the id it is about to restore from).
"""
if not is_enabled():
logger.debug("Curator backup disabled by config; skipping snapshot")
@@ -280,19 +276,15 @@ def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] =
pass
return None
_prune_old(keep=get_keep(), protect=protect_ids)
_prune_old(keep=get_keep())
logger.info("Curator snapshot created: %s (%s)", snap_id, reason)
return dest
def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
def _prune_old(keep: int) -> List[str]:
"""Delete regular snapshots beyond the newest *keep*. Returns deleted
ids. Snapshot ids in *protect* are never deleted even when they fall
outside the keep window — rollback() uses this so the mandatory
pre-rollback safety snapshot can never evict the very snapshot being
restored. Staging dirs (``.rollback-staging-*``) are implementation
detail and pruned independently on every call."""
protect = protect or set()
ids. Staging dirs (``.rollback-staging-*``) are implementation detail
and pruned independently on every call."""
backups = _backups_dir()
if not backups.exists():
return []
@@ -313,8 +305,6 @@ def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
entries.sort(key=lambda t: t[0], reverse=True)
deleted: List[str] = []
for _, path in entries[keep:]:
if path.name in protect:
continue
try:
shutil.rmtree(path)
deleted.append(path.name)
@@ -574,13 +564,7 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
# out before touching anything — otherwise a failed extract could leave
# the user with no skills.
try:
# Protect the target from this snapshot's prune step: at the steady
# keep limit, pruning the oldest snapshot would otherwise delete the
# very snapshot we are about to extract from.
snapshot_skills(
reason=f"pre-rollback to {target.name}",
protect_ids={target.name},
)
snapshot_skills(reason=f"pre-rollback to {target.name}")
except Exception as e:
return (False, f"pre-rollback safety snapshot failed: {e}", None)

View File

@@ -11,18 +11,6 @@ Providers live in ``<repo>/plugins/image_gen/<name>/`` (built-in, auto-loaded
as ``kind: backend``) or ``~/.hermes/plugins/image_gen/<name>/`` (user, opt-in
via ``plugins.enabled``).
Unified surface
---------------
One tool — ``image_generate`` — covers **text-to-image** and
**image-to-image / image editing**. The router is the presence of
``image_url`` (and/or ``reference_image_urls``): if any source image is
provided, the provider routes to its image-to-image / edit endpoint; if
omitted, the provider routes to text-to-image. Users pick one **model**
(e.g. nano-banana-pro, gpt-image-2, grok-imagine-image); the provider
handles which underlying endpoint to hit. This mirrors the ``video_gen``
provider design (``agent/video_gen_provider.py``) so the two surfaces
stay learnable together.
Response shape
--------------
All providers return a dict that :func:`success_response` / :func:`error_response`
@@ -33,7 +21,6 @@ produce. The tool wrapper JSON-serializes it. Keys:
model str provider-specific model identifier
prompt str echoed prompt
aspect_ratio str "landscape" | "square" | "portrait"
modality str "text" | "image" (which mode was used)
provider str provider name (for diagnostics)
error str only when success=False
error_type str only when success=False
@@ -140,51 +127,19 @@ class ImageGenProvider(abc.ABC):
return models[0].get("id")
return None
def capabilities(self) -> Dict[str, Any]:
"""Return what this provider supports.
Returned dict (all keys optional)::
{
"modalities": ["text", "image"], # which inputs the backend accepts
"max_reference_images": 9, # cap for reference_image_urls
}
``modalities`` declares whether the active backend/model supports
text-to-image (``"text"``), image-to-image / editing (``"image"``),
or both. The tool layer surfaces this in the dynamic schema so the
model knows when ``image_url`` is honored. Used by ``hermes tools``
for the picker too. Default: text-only (backward compatible — a
provider that doesn't override this advertises text-to-image only).
"""
return {
"modalities": ["text"],
"max_reference_images": 0,
}
@abc.abstractmethod
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
*,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""Generate an image from a text prompt, or edit/transform a source image.
Routing: if ``image_url`` (or any ``reference_image_urls``) is
provided, the provider should route to its image-to-image / edit
endpoint; otherwise text-to-image. ``image_url`` is the primary
source image to edit; ``reference_image_urls`` are additional
style/composition references (provider clamps to its declared
``max_reference_images``).
"""Generate an image.
Implementations should return the dict from :func:`success_response`
or :func:`error_response`. ``kwargs`` may contain forward-compat
parameters future versions of the schema will expose —
implementations MUST ignore unknown keys (no TypeError).
parameters future versions of the schema will expose — implementations
should ignore unknown keys.
"""
@@ -207,26 +162,6 @@ def resolve_aspect_ratio(value: Optional[str]) -> str:
return DEFAULT_ASPECT_RATIO
def normalize_reference_images(value: Any) -> Optional[List[str]]:
"""Coerce a reference-image argument into a clean list of URL/path strings.
Accepts a single string or a list; strips blanks and whitespace. Returns
``None`` when nothing usable remains so providers can treat "no refs" as a
single sentinel.
"""
if value is None:
return None
if isinstance(value, str):
value = [value]
if not isinstance(value, (list, tuple)):
return None
out: List[str] = []
for item in value:
if isinstance(item, str) and item.strip():
out.append(item.strip())
return out or None
def _images_cache_dir() -> Path:
"""Return ``$HERMES_HOME/cache/images/``, creating parents as needed."""
from hermes_constants import get_hermes_home
@@ -345,16 +280,13 @@ def success_response(
prompt: str,
aspect_ratio: str,
provider: str,
modality: str = "text",
extra: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build a uniform success response dict.
``image`` may be an HTTP URL or an absolute filesystem path (for b64
providers like OpenAI). ``modality`` is ``"text"`` (text-to-image) or
``"image"`` (image-to-image / editing) — indicates which endpoint was
actually hit, useful for diagnostics. Callers that need to pass through
additional backend-specific fields can supply ``extra``.
providers like OpenAI). Callers that need to pass through additional
backend-specific fields can supply ``extra``.
"""
payload: Dict[str, Any] = {
"success": True,
@@ -362,7 +294,6 @@ def success_response(
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
"modality": modality,
"provider": provider,
}
if extra:

View File

@@ -1,50 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
_NON_TEXT_PART_TYPES = {"image", "image_url", "input_image", "audio", "input_audio"}
_TEXT_KEYS = ("text", "content", "input_text", "output_text", "summary_text")
def _field(value: Any, key: str) -> Any:
if isinstance(value, Mapping):
return value.get(key)
return getattr(value, key, None)
def _text_from_part(part: Any) -> str:
if part is None:
return ""
if isinstance(part, str):
return part
part_type = str(_field(part, "type") or "").strip().lower()
if part_type in _NON_TEXT_PART_TYPES:
return ""
for key in _TEXT_KEYS:
text = _field(part, key)
if isinstance(text, str):
return text
return ""
def flatten_message_text(content: Any, *, sep: str = "\n") -> str:
"""Return the visible text from common chat/Responses message content shapes."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
chunks = [_text_from_part(part) for part in content]
return sep.join(chunk for chunk in chunks if chunk)
text = _text_from_part(content)
if text:
return text
try:
return str(content)
except Exception:
return ""

View File

@@ -275,11 +275,6 @@ DEFAULT_CONTEXT_LENGTHS = {
# via a custom provider. Values sourced from models.dev (2026-04).
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
# OAuth-only slug; absent from GET /v1/models. xAI publishes a 200k
# usable context window for Composer 2.5 on Grok Build (SuperGrok /
# Premium+); /v1/responses additionally enforces a ~262144 input+output
# budget, but the usable context (what we track here) is 200k.
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
"grok-build": 256000, # grok-build-0.1
"grok-code-fast": 256000, # grok-code-fast-1
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest

51
agent/pet/__init__.py Normal file
View File

@@ -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",
]

167
agent/pet/constants.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,29 @@
"""Pet generation — base-draft → hatch pipeline.
Public surface used by the gateway RPCs, the CLI ``hermes pets generate``
command, and tests:
- :func:`generate_base_drafts` / :func:`hatch_pet` — the two-step flow.
- :class:`HatchResult`, :class:`GenerationError`.
- :mod:`atlas` — deterministic frame extraction + atlas composition/validation.
Image generation is delegated to the active reference-capable
:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea);
atlas assembly is fully deterministic so it's testable without any API calls.
"""
from __future__ import annotations
from agent.pet.generate.imagegen import GenerationError
from agent.pet.generate.orchestrate import (
HatchResult,
generate_base_drafts,
hatch_pet,
)
__all__ = [
"GenerationError",
"HatchResult",
"generate_base_drafts",
"hatch_pet",
]

400
agent/pet/generate/atlas.py Normal file
View File

@@ -0,0 +1,400 @@
"""Deterministic spritesheet assembly — generated row strips → Hermes atlas.
Image-generation models are good at *drawing* a row of poses but bad at exact
grid geometry, so the model never owns the atlas layout: it produces one loose
horizontal strip per state, and these deterministic ops slice that strip into
clean, centered, transparent ``192x208`` cells and pack them into the sheet our
renderer reads.
The atlas is **Hermes-native**, not the petdex/Codex format. Our renderer
(:mod:`agent.pet.render`) keys frames as ``rows = states, cols = frames`` using
:data:`agent.pet.constants.STATE_ROWS`, so we emit exactly the six states the
engine drives — idle, wave, run, failed, review, jump — left-packed with
trailing transparent cells (which the renderer trims). Sheet is
``COLUMNS*192 x ROWS*208`` (1152x1248).
The frame-segmentation, fit-to-cell, and transparency-residue logic is adapted
from OpenAI's ``hatch-pet`` skill (openai/skills, Apache-2.0).
"""
from __future__ import annotations
import io
import logging
import math
from pathlib import Path
from agent.pet.constants import FRAME_H, FRAME_W
logger = logging.getLogger(__name__)
CELL_WIDTH = FRAME_W
CELL_HEIGHT = FRAME_H
# (state, row index, frame count). Order/row indices MUST match
# ``STATE_ROWS`` so the renderer crops the right row for each driven state.
# Frame counts are the petdex-ish per-state lengths; the renderer trims any
# trailing blank columns, so rows shorter than ``COLUMNS`` just leave the tail
# transparent.
ROW_SPECS: list[tuple[str, int, int]] = [
("idle", 0, 6),
("wave", 1, 4),
("run", 2, 6),
("failed", 3, 6),
("review", 4, 6),
("jump", 5, 5),
]
ROWS = len(ROW_SPECS)
COLUMNS = max(count for _, _, count in ROW_SPECS)
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
FRAME_COUNTS: dict[str, int] = {state: count for state, _, count in ROW_SPECS}
# Alpha at/below which a pixel is "background" for component detection.
_ALPHA_FLOOR = 16
# Cell padding kept around a fitted sprite so poses never touch the edge.
_CELL_PAD = 10
# ───────────────────────── background removal ─────────────────────────
def _color_distance(r: int, g: int, b: int, key: tuple[int, int, int]) -> float:
return math.sqrt((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2)
def _has_transparency(image) -> bool:
"""True if the strip already carries a real alpha background."""
extrema = image.getchannel("A").getextrema()
# Min alpha 0 somewhere and a meaningful share of fully-transparent pixels.
if extrema[0] > _ALPHA_FLOOR:
return False
hist = image.getchannel("A").histogram()
transparent = sum(hist[: _ALPHA_FLOOR + 1])
total = image.width * image.height
return transparent > total * 0.05
def _dominant_corner_color(image) -> tuple[int, int, int]:
"""Sample the four corners and return the most common opaque color."""
from collections import Counter
w, h = image.width, image.height
px = image.load()
counter: Counter = Counter()
for x, y in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)):
r, g, b, a = px[x, y]
if a > _ALPHA_FLOOR:
counter[(r, g, b)] += 1
if not counter:
return (0, 255, 0)
return counter.most_common(1)[0][0]
def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, threshold: float = 110.0):
"""Return *image* (RGBA) with its flat background keyed out to transparent.
If the strip already has a transparent background we leave it alone; else we
key out *chroma_key* (or the dominant corner color when not given). This
handles both providers that emit transparency natively and those that paint
a solid backdrop.
"""
rgba = image.convert("RGBA")
if _has_transparency(rgba):
return rgba
key = chroma_key or _dominant_corner_color(rgba)
px = rgba.load()
for y in range(rgba.height):
for x in range(rgba.width):
r, g, b, a = px[x, y]
if a > _ALPHA_FLOOR and _color_distance(r, g, b, key) <= threshold:
px[x, y] = (0, 0, 0, 0)
return rgba
# ───────────────────────── frame extraction ─────────────────────────
def _fit_to_cell(image):
"""Crop to content, scale to fit a padded cell, and center on transparent."""
from PIL import Image
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
bbox = image.getbbox()
if bbox is None:
return target
sprite = image.crop(bbox)
max_w = CELL_WIDTH - _CELL_PAD
max_h = CELL_HEIGHT - _CELL_PAD
scale = min(max_w / sprite.width, max_h / sprite.height, 1.0)
if scale != 1.0:
sprite = sprite.resize(
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
Image.Resampling.LANCZOS,
)
left = (CELL_WIDTH - sprite.width) // 2
top = (CELL_HEIGHT - sprite.height) // 2
target.alpha_composite(sprite, (left, top))
return target
def _connected_components(image) -> list[dict]:
"""Flood-fill the alpha mask into connected blobs (4-connectivity)."""
alpha = image.getchannel("A")
w, h = image.size
data = alpha.tobytes()
visited = bytearray(w * h)
out: list[dict] = []
for start, a in enumerate(data):
if a <= _ALPHA_FLOOR or visited[start]:
continue
stack = [start]
visited[start] = 1
pixels: list[int] = []
min_x = w
min_y = h
max_x = 0
max_y = 0
while stack:
cur = stack.pop()
pixels.append(cur)
x = cur % w
y = cur // w
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x)
max_y = max(max_y, y)
for nb, ok in (
(cur - 1, x > 0),
(cur + 1, x + 1 < w),
(cur - w, y > 0),
(cur + w, y + 1 < h),
):
if ok and not visited[nb] and data[nb] > _ALPHA_FLOOR:
visited[nb] = 1
stack.append(nb)
out.append(
{
"pixels": pixels,
"area": len(pixels),
"bbox": (min_x, min_y, max_x + 1, max_y + 1),
"center_x": (min_x + max_x + 1) / 2,
}
)
return out
def _group_image(source, components: list[dict], padding: int = 4):
from PIL import Image
w, h = source.size
min_x = max(0, min(c["bbox"][0] for c in components) - padding)
min_y = max(0, min(c["bbox"][1] for c in components) - padding)
max_x = min(w, max(c["bbox"][2] for c in components) + padding)
max_y = min(h, max(c["bbox"][3] for c in components) + padding)
out = Image.new("RGBA", (max_x - min_x, max_y - min_y), (0, 0, 0, 0))
src_px = source.load()
out_px = out.load()
for c in components:
for idx in c["pixels"]:
x = idx % w
y = idx // w
out_px[x - min_x, y - min_y] = src_px[x, y]
return out
def _component_frames(strip, frame_count: int) -> list | None:
"""Segment a strip into *frame_count* sprites by connected components.
Picks the ``frame_count`` largest blobs as seeds (left→right), attaches
smaller blobs to the nearest seed, and returns one fitted cell per group.
Returns ``None`` when it can't find enough distinct sprites (caller falls
back to equal slicing).
"""
components = _connected_components(strip)
if not components:
return None
largest = max(c["area"] for c in components)
seed_threshold = max(120, largest * 0.20)
seeds = [c for c in components if c["area"] >= seed_threshold]
if len(seeds) < frame_count:
seeds = sorted(components, key=lambda c: c["area"], reverse=True)[:frame_count]
if len(seeds) < frame_count:
return None
seeds = sorted(
sorted(seeds, key=lambda c: c["area"], reverse=True)[:frame_count],
key=lambda c: c["center_x"],
)
seed_ids = {id(s) for s in seeds}
groups: list[list[dict]] = [[s] for s in seeds]
noise_threshold = max(12, largest * 0.002)
for c in components:
if id(c) in seed_ids or c["area"] < noise_threshold:
continue
nearest = min(range(len(seeds)), key=lambda i: abs(seeds[i]["center_x"] - c["center_x"]))
groups[nearest].append(c)
return [_fit_to_cell(_group_image(strip, g)) for g in groups]
def _slot_frames(strip, frame_count: int) -> list:
"""Fallback: slice the strip into *frame_count* equal columns."""
slot = strip.width / frame_count
frames = []
for i in range(frame_count):
left = round(i * slot)
right = round((i + 1) * slot)
frames.append(_fit_to_cell(strip.crop((left, 0, right, strip.height))))
return frames
def extract_strip_frames(
strip,
frame_count: int,
*,
chroma_key: tuple[int, int, int] | None = None,
method: str = "auto",
) -> list:
"""Turn one generated row strip into *frame_count* clean 192x208 cells.
*strip* is a PIL image (or path). Background is keyed out, then frames are
found by connected components (``auto``) with an equal-slot fallback.
"""
from PIL import Image
if isinstance(strip, (str, Path)):
with Image.open(strip) as opened:
strip = opened.convert("RGBA")
else:
strip = strip.convert("RGBA")
strip = remove_background(strip, chroma_key=chroma_key)
if method in ("auto", "components"):
frames = _component_frames(strip, frame_count)
if frames is not None:
return frames
if method == "components":
raise ValueError(f"could not segment {frame_count} sprites from strip")
return _slot_frames(strip, frame_count)
# ───────────────────────── atlas composition ─────────────────────────
def single_frame(image):
"""One fitted 192x208 cell from a standalone image (e.g. the base look).
Used as an idle fallback so a pet always renders even if the idle row
generation failed.
"""
from PIL import Image
if isinstance(image, (str, Path)):
with Image.open(image) as opened:
image = opened.convert("RGBA")
return _fit_to_cell(remove_background(image))
def _clear_transparent_rgb(image):
"""Zero the RGB of fully-transparent pixels (no colored-halo residue)."""
from PIL import Image
rgba = image.convert("RGBA")
data = bytearray(rgba.tobytes())
for i in range(0, len(data), 4):
if data[i + 3] == 0:
data[i] = data[i + 1] = data[i + 2] = 0
return Image.frombytes("RGBA", rgba.size, bytes(data))
def compose_atlas(frames_by_state: dict[str, list]):
"""Pack per-state frame lists into the Hermes atlas (RGBA, residue-cleared).
Missing/short states leave their trailing cells transparent; extra frames
beyond a state's spec are dropped.
"""
from PIL import Image
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
for state, row, count in ROW_SPECS:
frames = frames_by_state.get(state) or []
for col, frame in enumerate(frames[:count]):
cell = frame.convert("RGBA")
if cell.size != (CELL_WIDTH, CELL_HEIGHT):
cell = _fit_to_cell(cell)
atlas.alpha_composite(cell, (col * CELL_WIDTH, row * CELL_HEIGHT))
return _clear_transparent_rgb(atlas)
def atlas_to_webp_bytes(atlas) -> bytes:
"""Encode an atlas image to lossless WebP bytes (the on-disk pet format)."""
buf = io.BytesIO()
atlas.save(buf, format="WEBP", lossless=True, quality=100, method=6, exact=True)
return buf.getvalue()
def validate_atlas(atlas) -> dict:
"""Check geometry, per-cell occupancy, and transparency invariants.
Returns ``{ok, width, height, errors, warnings, filled_states}``. Errors are
blockers (wrong size, empty used cell, opaque/dirty transparency); warnings
are soft (a whole state row blank — generation likely dropped a row).
"""
from PIL import Image
if isinstance(atlas, (str, Path)):
with Image.open(atlas) as opened:
atlas = opened.convert("RGBA")
else:
atlas = atlas.convert("RGBA")
errors: list[str] = []
warnings: list[str] = []
if atlas.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
errors.append(f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {atlas.width}x{atlas.height}")
return {"ok": False, "width": atlas.width, "height": atlas.height, "errors": errors, "warnings": warnings, "filled_states": []}
filled_states: list[str] = []
for state, row, count in ROW_SPECS:
row_pixels = 0
for col in range(count):
left = col * CELL_WIDTH
top = row * CELL_HEIGHT
cell = atlas.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
nonblank = sum(cell.getchannel("A").histogram()[1:])
row_pixels += nonblank
if row_pixels > 0:
filled_states.append(state)
else:
warnings.append(f"state '{state}' has no frames")
if not filled_states:
errors.append("atlas is empty — no state produced any frames")
# Transparent pixels must carry zero RGB (no halo residue).
data = atlas.tobytes()
residue = 0
for i in range(0, len(data), 4):
if data[i + 3] == 0 and (data[i] or data[i + 1] or data[i + 2]):
residue += 1
if residue:
errors.append(f"{residue} transparent pixels retain RGB residue")
return {
"ok": not errors,
"width": atlas.width,
"height": atlas.height,
"errors": errors,
"warnings": warnings,
"filled_states": filled_states,
}

View File

@@ -0,0 +1,168 @@
"""Thin image-generation layer for pet sprites.
Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the
two things sprite generation needs that the agent-facing ``image_generate`` tool
doesn't expose: **N variants** (loop) and **reference-image grounding** (so each
animation row stays the same character as the chosen base).
Reference grounding only works on providers that support it — currently OpenAI
``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of
those and surface a clear, actionable error otherwise rather than silently
producing an ungrounded, drifting pet.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger(__name__)
# Providers that can ground generation on a reference image.
_REF_CAPABLE = ("openai", "openai-codex", "krea")
class GenerationError(RuntimeError):
"""Raised on any image-generation failure (no provider, API error, IO)."""
@dataclass(frozen=True)
class SpriteProvider:
"""Resolved provider plus whether it can take reference images."""
name: str
provider: object
supports_references: bool
def _discover() -> None:
try:
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
except Exception as exc: # noqa: BLE001 - discovery is best-effort
logger.debug("image-gen plugin discovery failed: %s", exc)
def resolve_provider(*, require_references: bool = True) -> SpriteProvider:
"""Pick the image provider to use for sprite work.
Preference: the configured provider when it's reference-capable, else the
first available reference-capable provider. With *require_references* off we
fall back to any available provider (used for prompt-only base drafts).
"""
_discover()
from agent.image_gen_registry import get_active_provider, get_provider
# Configured / active provider first.
active = None
try:
active = get_active_provider()
except Exception: # noqa: BLE001
active = None
if active is not None:
name = getattr(active, "name", "")
if name in _REF_CAPABLE and active.is_available():
return SpriteProvider(name=name, provider=active, supports_references=True)
# Any available reference-capable provider.
for name in _REF_CAPABLE:
provider = get_provider(name)
if provider is not None and provider.is_available():
return SpriteProvider(name=name, provider=provider, supports_references=True)
if not require_references and active is not None and active.is_available():
return SpriteProvider(
name=getattr(active, "name", "unknown"), provider=active, supports_references=False
)
raise GenerationError(
"Pet generation needs a reference-capable image backend. "
"Run `hermes tools` → Image Generation → OpenAI (gpt-image-2) and add an "
"OpenAI API key (or configure Krea)."
)
def _save_local(image_ref: str, *, prefix: str) -> Path:
"""Return a local path for *image_ref*, downloading it if it's a URL."""
if image_ref.startswith(("http://", "https://")):
from agent.image_gen_provider import save_url_image
return Path(save_url_image(image_ref, prefix=prefix))
return Path(image_ref)
def _rejected_background(error: str) -> bool:
"""True when a provider error is specifically about the ``background`` param.
Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers
reject ``background=transparent`` outright). We detect that one rejection so
we can retry without the flag rather than failing the whole pet — our chroma
key pass makes the result transparent regardless.
"""
lowered = (error or "").lower()
return "background" in lowered and ("not supported" in lowered or "transparent" in lowered)
def generate(
prompt: str,
*,
n: int = 1,
reference_images: list[Path] | None = None,
provider: SpriteProvider | None = None,
prefix: str = "pet_gen",
) -> list[Path]:
"""Generate *n* square sprite images and return their local paths.
*reference_images* grounds the output on a base image (required for rows).
We *ask* for a transparent background, but fall back to an opaque generation
(cleaned up downstream by the chroma-key pass) on models that reject the
flag. Raises :class:`GenerationError` if nothing usable comes back.
"""
sprite = provider or resolve_provider(require_references=bool(reference_images))
if reference_images and not sprite.supports_references:
raise GenerationError(
f"image backend '{sprite.name}' cannot use reference images; "
"configure OpenAI gpt-image-2 or Krea for pet generation"
)
refs = [str(p) for p in (reference_images or [])]
def _run(extra: dict) -> tuple[Path | None, str]:
kwargs: dict = {"aspect_ratio": "square", **extra}
if refs:
kwargs["reference_images"] = refs
try:
result = sprite.provider.generate(prompt, **kwargs)
except Exception as exc: # noqa: BLE001 - normalize provider crashes
logger.debug("provider.generate crashed: %s", exc)
return None, str(exc)
if not isinstance(result, dict) or not result.get("success"):
return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result"
image_ref = result.get("image")
if not image_ref:
return None, "provider returned no image"
try:
return _save_local(str(image_ref), prefix=prefix), ""
except Exception as exc: # noqa: BLE001
return None, f"could not save generated image: {exc}"
out: list[Path] = []
last_error = ""
allow_transparent = True
for _ in range(max(1, n)):
path, err = _run({"background": "transparent"} if allow_transparent else {})
# Model doesn't support the transparent flag → drop it for this and every
# remaining variant (no point re-probing a capability we just disproved).
if path is None and allow_transparent and _rejected_background(err):
allow_transparent = False
path, err = _run({})
if path is not None:
out.append(path)
else:
last_error = err
if not out:
raise GenerationError(last_error or "image generation produced no output")
return out

View File

@@ -0,0 +1,149 @@
"""Pet generation orchestration — the base-draft → hatch flow.
Two steps, mirroring the UX across every surface:
1. :func:`generate_base_drafts` — a handful of prompt-only "what should this pet
look like" variants. Cheap; the user picks one (or retries for a fresh set).
2. :func:`hatch_pet` — takes the chosen base and generates one grounded row
strip per Hermes state, slices each into frames, composes the atlas, validates
it, and writes the pet into the store.
Splitting it this way bounds cost (4 cheap base calls per round; the ~6 row
calls happen once, on the pet you actually keep) and gives each UI a natural
preview/loading point.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from agent.pet.generate import atlas, imagegen, prompts
from agent.pet.generate.imagegen import GenerationError, SpriteProvider
logger = logging.getLogger(__name__)
# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", "<slug>").
ProgressFn = Callable[[str, str], None]
@dataclass(frozen=True)
class HatchResult:
"""Outcome of a successful :func:`hatch_pet`."""
slug: str
display_name: str
spritesheet: Path
states: list[str]
validation: dict
def _harden_transparency(path: Path) -> Path:
"""Key out any solid backdrop the provider painted; save as an RGBA PNG.
``background=transparent`` is requested on every call, but image models honor
it inconsistently — some still paint a flat (often near-white) backdrop. We
run the same chroma-key pass the row extractor uses so every base draft the
user picks between (and the reference the rows are grounded on) is a clean
cutout. Best-effort: a decode failure leaves the original untouched.
"""
from PIL import Image
try:
with Image.open(path) as opened:
keyed = atlas.remove_background(opened.convert("RGBA"))
out = path.with_suffix(".png")
keyed.save(out, format="PNG")
return out
except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image
logger.debug("base draft transparency hardening failed for %s: %s", path, exc)
return path
def generate_base_drafts(
concept: str,
*,
n: int = 4,
style: str = "auto",
provider: SpriteProvider | None = None,
) -> list[Path]:
"""Generate *n* candidate base looks for *concept*; returns image paths.
Each draft is hardened to a transparent cutout (see :func:`_harden_transparency`).
"""
prompt = prompts.build_base_prompt(concept, style=style)
sprite = provider or imagegen.resolve_provider(require_references=False)
raw = imagegen.generate(prompt, n=n, provider=sprite, prefix="pet_base")
return [_harden_transparency(p) for p in raw]
def hatch_pet(
*,
base_image: str | Path,
slug: str,
display_name: str = "",
description: str = "",
concept: str = "",
style: str = "auto",
on_progress: ProgressFn | None = None,
provider: SpriteProvider | None = None,
) -> HatchResult:
"""Turn an approved base image into a full, installed Hermes pet.
Generates a grounded row strip per state, extracts frames, composes +
validates the atlas, and registers it. The idle row falls back to the base
look so the pet always renders. Raises :class:`GenerationError` on failure.
"""
base = Path(base_image)
if not base.is_file():
raise GenerationError(f"base image not found: {base}")
sprite = provider or imagegen.resolve_provider(require_references=True)
progress = on_progress or (lambda *_: None)
label = concept or display_name or slug
frames_by_state: dict[str, list] = {}
for state, _row, count in atlas.ROW_SPECS:
progress("row", state)
row_prompt = prompts.build_row_prompt(state, count, label, style=style)
try:
strips = imagegen.generate(
row_prompt,
n=1,
reference_images=[base],
provider=sprite,
prefix=f"pet_row_{state}",
)
frames_by_state[state] = atlas.extract_strip_frames(strips[0], count, method="auto")
except Exception as exc: # noqa: BLE001 - a single row may fail; keep going
logger.warning("pet row '%s' failed: %s", state, exc)
# Idle is the resting state the renderer falls back to — guarantee it.
if not frames_by_state.get("idle"):
progress("row", "idle-fallback")
frames_by_state["idle"] = [atlas.single_frame(base)]
progress("compose", "")
sheet = atlas.compose_atlas(frames_by_state)
validation = atlas.validate_atlas(sheet)
if not validation["ok"]:
raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed")
from agent.pet import store
progress("save", slug)
pet = store.register_local_pet(
sheet,
slug=slug,
display_name=display_name or slug,
description=description,
)
return HatchResult(
slug=pet.slug,
display_name=pet.display_name,
spritesheet=pet.spritesheet,
states=validation["filled_states"],
validation=validation,
)

View File

@@ -0,0 +1,74 @@
"""Prompt builders for pet generation.
Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the
user picks between) and per-*state* *row* prompts (grounded on the chosen base,
produce one horizontal strip of N poses). Prompts stay concise and
sprite-production oriented; the identity lock and "one transparent row" framing
matter more than flowery description.
Hermes drives six states (see :data:`agent.pet.generate.atlas.ROW_SPECS`); these
mirror that set rather than the petdex/Codex nine.
"""
from __future__ import annotations
# What each Hermes state should depict (kept short — these go straight into the
# row prompt). Phrased to avoid the common sprite-gen failure modes (detached
# effects, motion lines, shadows).
STATE_ACTIONS: dict[str, str] = {
"idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures",
"wave": "a friendly greeting: raising a paw/hand/limb to wave, clear up-and-down gesture",
"run": "focused active work: leaning in, concentrating, busy 'thinking/processing' energy (NOT foot-running)",
"failed": "a sad or deflated reaction: slumped, dejected, small frown — readable but not noisy",
"review": "careful inspection: a focused lean, head tilt, studying something intently",
"jump": "a happy celebration jump: anticipation, lift off the ground, peak, and land",
}
_STYLE_HINTS: dict[str, str] = {
"auto": "",
"pixel": " Render in clean pixel-art style.",
"plush": " Render as a soft plush toy.",
"clay": " Render as a claymation / soft 3D clay figure.",
"sticker": " Render as a glossy die-cut sticker.",
"flat-vector": " Render in flat vector mascot style.",
"3d-toy": " Render as a glossy 3D toy.",
"painterly": " Render in a soft painterly style.",
}
_BACKGROUND = (
"Center one full-body character on a fully transparent background. "
"No text, no labels, no shadow, no ground line, no scenery, no frame, no border."
)
def style_hint(style: str | None) -> str:
return _STYLE_HINTS.get((style or "auto").strip().lower(), "")
def build_base_prompt(concept: str, *, style: str | None = "auto") -> str:
"""The base look: a single, clean, centered full-body mascot."""
concept = (concept or "a cute friendly mascot creature").strip()
return (
f"A cute, characterful mascot pet: {concept}. "
"Compact, whole-body silhouette that reads clearly at small size, "
"appealing face, simple consistent palette. "
f"{_BACKGROUND}{style_hint(style)}"
)
def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str:
"""A row strip: *frame_count* poses of the SAME character, left→right.
The attached base image is the identity source of truth; the prompt locks
species, palette, face, and props to it.
"""
action = STATE_ACTIONS.get(state, "a simple idle pose")
concept = (concept or "the mascot").strip()
return (
f"Using the attached reference image as the exact same character "
f"(same species, face, colors, markings, proportions, and props), "
f"draw a single horizontal strip of {frame_count} animation frames showing {action}. "
f"The {frame_count} poses must be evenly spaced left to right, each fully separated "
"(not overlapping), same size and baseline, forming a smooth loop. "
f"Keep the character identical across all frames. {_BACKGROUND}{style_hint(style)}"
)

128
agent/pet/manifest.py Normal file
View File

@@ -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

618
agent/pet/render.py Normal file
View File

@@ -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,
)

81
agent/pet/state.py Normal file
View File

@@ -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

388
agent/pet/store.py Normal file
View File

@@ -0,0 +1,388 @@
"""On-disk pet store — install / list / resolve pets.
Pets live under ``get_hermes_home()/pets/<slug>/`` 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/<slug>/
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
import re
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
def slugify(name: str) -> str:
"""Lowercase, hyphenate, and strip a display name into a filesystem slug."""
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
return slug or "pet"
def unique_slug(name: str) -> str:
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
base = slugify(name)
slug = base
counter = 2
while (pets_dir() / slug).exists():
slug = f"{base}-{counter}"
counter += 1
return slug
def _write_spritesheet(source, dest: Path) -> None:
"""Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*."""
if isinstance(source, (bytes, bytearray)):
dest.write_bytes(bytes(source))
return
from PIL import Image
if isinstance(source, (str, Path)):
with Image.open(source) as opened:
image = opened.convert("RGBA")
else:
image = source.convert("RGBA")
image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
def register_local_pet(
spritesheet,
*,
slug: str,
display_name: str = "",
description: str = "",
) -> InstalledPet:
"""Write a locally-generated pet into the store and return it.
*spritesheet* may be a PIL image, raw WebP/PNG bytes, or a path. The pet
appears in :func:`installed_pets` immediately, and because :func:`install_pet`
returns an already-on-disk pet before consulting the manifest, it can be
adopted (``pet.select`` / ``/pet <slug>``) without a manifest entry.
"""
slug = slugify(slug)
directory = pets_dir() / slug
directory.mkdir(parents=True, exist_ok=True)
sprite_path = directory / "spritesheet.webp"
try:
_write_spritesheet(spritesheet, sprite_path)
except Exception as exc: # noqa: BLE001 - normalize to one error type
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
meta = {
"id": slug,
"displayName": display_name or slug,
"description": description or "",
"spritesheetPath": sprite_path.name,
"createdBy": "generator",
}
(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"register of generated pet '{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 ``<img src=cdn>`` 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 {}

View File

@@ -305,47 +305,6 @@ TASK_COMPLETION_GUIDANCE = (
"is always better than inventing a result."
)
# Universal parallel-tool-call guidance — applied to ALL models.
#
# Why this matters for cost: every assistant turn resends the entire
# accumulated conversation (and, on cache-friendly providers, re-reads the
# cached prefix and pays for the newly-appended turn). A model that issues
# one tool call per turn multiplies the number of round-trips — and therefore
# the resent context — for any task that needs several independent reads,
# searches, or safe lookups. Batching independent calls into a single
# assistant response collapses N turns into one, cutting both latency and the
# resent-context cost that compounds over a long conversation.
#
# The hermes-agent runtime already executes a batch of tool calls
# concurrently when they are independent (read-only tools always; path-scoped
# file ops when their targets don't overlap — see
# run_agent._execute_tool_calls / tool_dispatch_helpers). The missing piece
# was telling the *model* to emit those calls together in the first place.
# Until now the only batching steer in the prompt lived in
# GOOGLE_MODEL_OPERATIONAL_GUIDANCE — Gemini/Gemma got it, every other model
# got nothing. This block makes the steer universal; the now-redundant
# Google-only bullet has been dropped so no model receives it twice.
#
# Short on purpose — shipped in the cached system prompt to every user, every
# session. Token cost is paid once at install and amortised across all
# sessions via prefix caching. Keep it tight.
#
# Ported from cline/cline#11514 ("encourage parallel tool calls"), adapted
# from Cline's TypeScript tool-surface guidance to hermes-agent's Python
# prompt-assembly architecture.
PARALLEL_TOOL_CALL_GUIDANCE = (
"# Parallel tool calls\n"
"When you need several pieces of information that don't depend on each "
"other, request them together in a single response instead of one tool "
"call per turn. Independent reads, searches, web fetches, and read-only "
"commands should be batched into the same assistant turn — the runtime "
"executes independent calls concurrently, and batching avoids resending "
"the whole conversation on every extra round-trip.\n"
"Only serialize calls when a later call genuinely depends on an earlier "
"call's result (e.g. you must read a file before you can patch it). When "
"in doubt and the calls are independent, batch them."
)
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
# where GPT models abandon work on partial results, skip prerequisite lookups,
# hallucinate instead of using tools, and declare "done" without verification.
@@ -427,10 +386,9 @@ GOOGLE_MODEL_OPERATIONAL_GUIDANCE = (
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
"- **Conciseness:** Keep explanatory text brief — a few sentences, not "
"paragraphs. Focus on actions and results over narration.\n"
# Parallel-tool-call steering now lives in the universal
# PARALLEL_TOOL_CALL_GUIDANCE block (injected for all models), so it is no
# longer duplicated here — keeping it would send Gemini/Gemma the same
# instruction twice.
"- **Parallel tool calls:** When you need to perform multiple independent "
"operations (e.g. reading several files), make all the tool calls in a "
"single response rather than sequentially.\n"
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
"to prevent CLI tools from hanging on prompts.\n"
"- **Keep going:** Work autonomously until the task is fully resolved. "
@@ -1000,41 +958,13 @@ CONTEXT_FILE_MAX_CHARS = 20_000
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
# Dynamic-cap parameters (used when no explicit context_file_max_chars is set).
# The cap scales with the model's context window so large-context models rarely
# truncate a project doc, while small-context models stay at the historical
# 20K floor. ~4 chars/token is the usual English heuristic; we spend a small
# slice of the window on context files since they share the cached prefix with
# the system prompt, tools, memory, and the whole conversation.
_CONTEXT_FILE_CHARS_PER_TOKEN = 4
_CONTEXT_FILE_WINDOW_FRACTION = 0.06
_CONTEXT_FILE_DYNAMIC_CEILING = 500_000
def _get_context_file_max_chars() -> int:
"""Return the configured context-file truncation limit.
def _dynamic_context_file_max_chars(context_length: Optional[int]) -> int:
"""Derive a char cap from the model's context window.
Returns at least ``CONTEXT_FILE_MAX_CHARS`` (the historical 20K floor) and
at most ``_CONTEXT_FILE_DYNAMIC_CEILING``. When ``context_length`` is
unknown/invalid, returns the flat default so behavior is unchanged.
"""
if not isinstance(context_length, int) or context_length <= 0:
return CONTEXT_FILE_MAX_CHARS
budget = int(
context_length * _CONTEXT_FILE_CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION
)
return max(CONTEXT_FILE_MAX_CHARS, min(budget, _CONTEXT_FILE_DYNAMIC_CEILING))
def _get_context_file_max_chars(context_length: Optional[int] = None) -> int:
"""Return the context-file truncation limit.
Resolution order:
1. Explicit ``context_file_max_chars`` in config.yaml — user knows best,
always wins (including over the dynamic cap).
2. Dynamic cap derived from the model's ``context_length`` when provided
(scales the budget to the window; floor 20K, ceiling 500K).
3. ``CONTEXT_FILE_MAX_CHARS`` (20K) as the upstream-compatible fallback.
``CONTEXT_FILE_MAX_CHARS`` remains the upstream-compatible default and
fallback. Users with larger context windows can raise
``context_file_max_chars`` in config.yaml without patching Hermes.
"""
try:
from hermes_cli.config import load_config
@@ -1044,7 +974,7 @@ def _get_context_file_max_chars(context_length: Optional[int] = None) -> int:
return int(val)
except Exception as e:
logger.debug("Could not read context_file_max_chars from config: %s", e)
return _dynamic_context_file_max_chars(context_length)
return CONTEXT_FILE_MAX_CHARS
# Collect truncation warnings so the caller (run_agent) can surface them.
# A ContextVar (not a module-global list) isolates accumulation per thread /
@@ -1580,30 +1510,16 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
# Context files (SOUL.md, AGENTS.md, .cursorrules)
# =========================================================================
def _truncate_content(
content: str,
filename: str,
max_chars: Optional[int] = None,
context_length: Optional[int] = None,
read_path: Optional[str] = None,
) -> str:
"""Head/tail truncation with a marker in the middle.
``filename`` is the human label used in warnings. ``read_path`` is the
concrete path the agent should ``read_file`` to recover the full content
(defaults to ``filename`` when not supplied). ``context_length`` lets the
cap scale to the model's window when no explicit config override is set.
"""
def _truncate_content(content: str, filename: str, max_chars: Optional[int] = None) -> str:
"""Head/tail truncation with a marker in the middle."""
if max_chars is None:
max_chars = _get_context_file_max_chars(context_length)
max_chars = _get_context_file_max_chars()
if len(content) <= max_chars:
return content
target = read_path or filename
msg = (
f"⚠️ Context file {filename} TRUNCATED: "
f"{len(content)} chars exceeds limit of {max_chars}"
f"trim the file, pin a larger context_file_max_chars, or use a "
f"larger-context model!"
f"increase context_file_max_chars or trim the file!"
)
logger.warning(msg)
_record_truncation_warning(msg)
@@ -1611,16 +1527,11 @@ def _truncate_content(
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
head = content[:head_chars]
tail = content[-tail_chars:]
marker = (
f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of "
f"{len(content)} chars. The middle is omitted — if you need the full "
f"instructions, read the complete file with the read_file tool: "
f"{target}]\n\n"
)
marker = f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of {len(content)} chars. Use file tools to read the full file.]\n\n"
return head + marker + tail
def load_soul_md(context_length: Optional[int] = None) -> Optional[str]:
def load_soul_md() -> Optional[str]:
"""Load SOUL.md from HERMES_HOME and return its content, or None.
Used as the agent identity (slot #1 in the system prompt). When this
@@ -1641,17 +1552,14 @@ def load_soul_md(context_length: Optional[int] = None) -> Optional[str]:
if not content:
return None
content = _scan_context_content(content, "SOUL.md")
content = _truncate_content(
content, "SOUL.md", context_length=context_length,
read_path=str(soul_path),
)
content = _truncate_content(content, "SOUL.md")
return content
except Exception as e:
logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
return None
def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
def _load_hermes_md(cwd_path: Path) -> str:
""".hermes.md / HERMES.md — walk to git root."""
hermes_md_path = _find_hermes_md(cwd_path)
if not hermes_md_path:
@@ -1668,16 +1576,13 @@ def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str
pass
content = _scan_context_content(content, rel)
result = f"## {rel}\n\n{content}"
return _truncate_content(
result, ".hermes.md", context_length=context_length,
read_path=str(hermes_md_path),
)
return _truncate_content(result, ".hermes.md")
except Exception as e:
logger.debug("Could not read %s: %s", hermes_md_path, e)
return ""
def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
def _load_agents_md(cwd_path: Path) -> str:
"""AGENTS.md — top-level only (no recursive walk)."""
for name in ["AGENTS.md", "agents.md"]:
candidate = cwd_path / name
@@ -1687,16 +1592,13 @@ def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str
if content:
content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}"
return _truncate_content(
result, "AGENTS.md", context_length=context_length,
read_path=str(candidate),
)
return _truncate_content(result, "AGENTS.md")
except Exception as e:
logger.debug("Could not read %s: %s", candidate, e)
return ""
def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
def _load_claude_md(cwd_path: Path) -> str:
"""CLAUDE.md / claude.md — cwd only."""
for name in ["CLAUDE.md", "claude.md"]:
candidate = cwd_path / name
@@ -1706,16 +1608,13 @@ def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str
if content:
content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}"
return _truncate_content(
result, "CLAUDE.md", context_length=context_length,
read_path=str(candidate),
)
return _truncate_content(result, "CLAUDE.md")
except Exception as e:
logger.debug("Could not read %s: %s", candidate, e)
return ""
def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> str:
def _load_cursorrules(cwd_path: Path) -> str:
""".cursorrules + .cursor/rules/*.mdc — cwd only."""
cursorrules_content = ""
cursorrules_file = cwd_path / ".cursorrules"
@@ -1742,17 +1641,10 @@ def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> s
if not cursorrules_content:
return ""
return _truncate_content(
cursorrules_content, ".cursorrules", context_length=context_length,
read_path=str(cwd_path / ".cursorrules"),
)
return _truncate_content(cursorrules_content, ".cursorrules")
def build_context_files_prompt(
cwd: Optional[str] = None,
skip_soul: bool = False,
context_length: Optional[int] = None,
) -> str:
def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = False) -> str:
"""Discover and load context files for the system prompt.
Priority (first found wins — only ONE project context type is loaded):
@@ -1762,11 +1654,7 @@ def build_context_files_prompt(
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME is independent and always included when present.
Each context source is capped before injection. The cap defaults to the
model's context window (scaled — see ``_dynamic_context_file_max_chars``)
when *context_length* is provided, falling back to 20,000 chars otherwise.
An explicit ``context_file_max_chars`` in config.yaml always wins.
Each context source is capped at 20,000 chars.
When *skip_soul* is True, SOUL.md is not included here (it was already
loaded via ``load_soul_md()`` for the identity slot).
@@ -1779,17 +1667,17 @@ def build_context_files_prompt(
# Priority-based project context: first match wins
project_context = (
_load_hermes_md(cwd_path, context_length)
or _load_agents_md(cwd_path, context_length)
or _load_claude_md(cwd_path, context_length)
or _load_cursorrules(cwd_path, context_length)
_load_hermes_md(cwd_path)
or _load_agents_md(cwd_path)
or _load_claude_md(cwd_path)
or _load_cursorrules(cwd_path)
)
if project_context:
sections.append(project_context)
# SOUL.md from HERMES_HOME only — skip when already loaded as identity
if not skip_soul:
soul_content = load_soul_md(context_length)
soul_content = load_soul_md()
if soul_content:
sections.append(soul_content)

View File

@@ -1,205 +0,0 @@
"""Profile-scoped credential resolution for multi-profile gateway multiplexing.
The multiplexing gateway serves many profiles from one process. Each profile
has its own ``.env`` with its own provider keys and platform tokens, so we
**cannot** union them into the process-global ``os.environ`` (that would leak
profile A's keys to profile B's turns, and to every subprocess spawned with
``env=dict(os.environ)``).
This module provides a fail-closed, context-local secret scope:
- ``set_secret_scope(mapping)`` installs the active profile's secrets for the
current task (a contextvar, so it propagates into the agent's worker thread
via ``copy_context()`` exactly like the HERMES_HOME override).
- ``get_secret(name)`` reads from that scope. When multiplexing is **active**
and no scope is set, it RAISES rather than silently falling back to
``os.environ`` — an un-migrated or newly-added call site fails loud at that
exact line instead of leaking another profile's value. When multiplexing is
**off** (the default), it transparently reads ``os.environ`` so the
single-profile gateway and every non-gateway caller behave exactly as before.
Design rationale lives in ``docs/design/multiplexing-gateway.md`` (Workstream A).
"""
from __future__ import annotations
import os
from contextvars import ContextVar, Token
from pathlib import Path
from typing import Dict, Mapping, Optional
# ── multiplex-active flag ────────────────────────────────────────────────
# Process-global: set once at gateway startup when gateway.multiplex_profiles
# is true. Governs whether get_secret() fails closed on an unscoped read.
# A plain module global (not a contextvar): it describes the deployment mode,
# not a per-task value.
_MULTIPLEX_ACTIVE: bool = False
def set_multiplex_active(active: bool) -> None:
"""Mark whether the process is running as a profile multiplexer.
Called once at gateway startup. When True, ``get_secret`` fails closed on
an unscoped read instead of falling back to ``os.environ``.
"""
global _MULTIPLEX_ACTIVE
_MULTIPLEX_ACTIVE = bool(active)
def is_multiplex_active() -> bool:
"""Return whether the process is running as a profile multiplexer."""
return _MULTIPLEX_ACTIVE
# ── the secret scope contextvar ──────────────────────────────────────────
_SECRET_SCOPE: ContextVar[Optional[Mapping[str, str]]] = ContextVar(
"_SECRET_SCOPE", default=None
)
class UnscopedSecretError(RuntimeError):
"""Raised when a secret is read in multiplex mode with no scope installed.
This is the fail-closed signal: it means a credential read reached
``get_secret`` without a profile scope active, which in a multiplexer would
otherwise leak whichever profile's value happened to be in ``os.environ``.
The fix is to wrap the call path in ``set_secret_scope(...)`` (the per-turn
/ per-adapter profile scope), not to widen the allowlist.
"""
def set_secret_scope(secrets: Optional[Mapping[str, str]]) -> Token:
"""Install the active profile's secret mapping for the current context.
Returns a token for ``reset_secret_scope``. Pass ``None`` to clear.
"""
return _SECRET_SCOPE.set(secrets)
def reset_secret_scope(token: Token) -> None:
"""Restore the previous secret scope."""
_SECRET_SCOPE.reset(token)
def current_secret_scope() -> Optional[Mapping[str, str]]:
"""Return the active secret mapping, or None when no scope is installed."""
return _SECRET_SCOPE.get()
# ── genuinely-global env vars (NOT per-profile secrets) ──────────────────
# These are process/deployment-level settings, not profile credentials. They
# legitimately live in os.environ and must keep reading from it even in
# multiplex mode — routing them through the fail-closed path would wrongly
# crash. Anything matching is read from os.environ regardless of scope.
#
# Membership test is by exact name OR prefix (see _is_global_env). Keep this
# list tight: when in doubt a value is a profile secret, not a global.
_GLOBAL_ENV_EXACT = frozenset({
# Hermes runtime / deployment
"HERMES_HOME", "HERMES_PROFILE", "HERMES_GATEWAY_LOCK_DIR",
"HERMES_MAX_ITERATIONS", "HERMES_MAX_TOKENS", "HERMES_API_TIMEOUT",
"HERMES_REDACT_SECRETS", "HERMES_NOUS_TIMEOUT_SECONDS",
"_HERMES_GATEWAY",
# OS / interpreter
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TZ", "PWD", "SHELL", "TMPDIR",
"VIRTUAL_ENV", "PYTHONPATH", "SSL_CERT_FILE",
# Kanban paths (per-board, not per-profile-secret)
"HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_BOARD",
})
_GLOBAL_ENV_PREFIXES = (
"HERMES_KANBAN_",
"HERMES_TELEGRAM_", # tuning knobs (batch delays, fallback toggles) — NOT the token
"TERMINAL_", # terminal/sandbox backend settings
)
def _is_global_env(name: str) -> bool:
"""Return True for genuinely process-global (non-profile-secret) env vars."""
if name in _GLOBAL_ENV_EXACT:
return True
return any(name.startswith(p) for p in _GLOBAL_ENV_PREFIXES)
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
"""Resolve a credential by env-var name, honoring the active profile scope.
Resolution order:
1. Genuinely-global vars (``_is_global_env``) always read ``os.environ`` —
they are deployment settings, not profile secrets.
2. When a secret scope is installed (multiplexed turn), read from it; an
absent key returns ``default``. The scope is authoritative — we do NOT
fall through to ``os.environ``, because in a multiplexer ``os.environ``
may hold another profile's value.
3. No scope installed:
- multiplex INACTIVE (default deployment): read ``os.environ`` —
identical to the legacy ``os.getenv`` behavior every caller had before.
- multiplex ACTIVE: FAIL CLOSED. Raise ``UnscopedSecretError`` so the
missing scope is caught loudly instead of leaking a cross-profile value.
"""
if _is_global_env(name):
val = os.environ.get(name)
return val if val is not None else default
scope = _SECRET_SCOPE.get()
if scope is not None:
val = scope.get(name)
return val if val is not None else default
if _MULTIPLEX_ACTIVE:
raise UnscopedSecretError(
f"get_secret({name!r}) called with no profile secret scope active "
f"while multiplexing is on. This credential read must run inside a "
f"set_secret_scope(...) block (the per-turn / per-adapter profile "
f"scope). Reading os.environ here would risk leaking another "
f"profile's value. See docs/design/multiplexing-gateway.md "
f"(Workstream A)."
)
val = os.environ.get(name)
return val if val is not None else default
def load_env_file(env_path: Path) -> Dict[str, str]:
"""Parse a ``.env`` file into a plain dict WITHOUT touching ``os.environ``.
Used to load a profile's secrets into an isolated mapping for
``set_secret_scope``. Mirrors python-dotenv's basic parsing (KEY=VALUE,
``export`` prefix, ``#`` comments, optional matching quotes) but never
mutates the process environment — that isolation is the whole point.
"""
secrets: Dict[str, str] = {}
try:
text = env_path.read_text(encoding="utf-8")
except (FileNotFoundError, OSError, UnicodeDecodeError):
return secrets
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export "):].lstrip()
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
if not key:
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1]
secrets[key] = value
return secrets
def build_profile_secret_scope(hermes_home: Path) -> Dict[str, str]:
"""Build a profile's secret mapping from its ``<home>/.env``.
Returns a fresh dict (safe to install via ``set_secret_scope``). Genuinely
global vars are intentionally NOT copied in — ``get_secret`` reads those
from ``os.environ`` directly, so the scope holds only profile secrets.
"""
return load_env_file(Path(hermes_home) / ".env")

View File

@@ -33,7 +33,6 @@ from agent.prompt_builder import (
KANBAN_GUIDANCE,
MEMORY_GUIDANCE,
OPENAI_MODEL_EXECUTION_GUIDANCE,
PARALLEL_TOOL_CALL_GUIDANCE,
PLATFORM_HINTS,
SESSION_SEARCH_GUIDANCE,
SKILLS_GUIDANCE,
@@ -61,55 +60,6 @@ def _ra():
return run_agent
def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) -> str:
"""Apply a per-platform prompt-hint override to the default hint.
Reads ``agent._platform_hint_overrides`` (populated from
``config.yaml`` ``platform_hints`` by ``agent_init``) and resolves the
effective hint for *platform_key*:
* ``replace`` — substitute the default hint entirely.
* ``append`` — keep the default and append the extra text.
* a bare string value — treated as ``append`` (convenience shorthand).
Precedence: ``replace`` wins over ``append`` if both are present.
Override text is added on top of (not instead of) the SOUL/context/
memory tiers — it only affects the platform-hint segment, so other
platforms are unaffected and general system instructions still apply.
Defensive: any malformed entry falls back to the unmodified default so
a bad config value can never break prompt assembly or leak across
platforms.
"""
if not platform_key:
return default_hint
overrides = getattr(agent, "_platform_hint_overrides", None)
if not isinstance(overrides, dict) or not overrides:
return default_hint
spec = overrides.get(platform_key)
if spec is None:
return default_hint
# Shorthand: a bare string is treated as append text.
if isinstance(spec, str):
extra = spec.strip()
return f"{default_hint}\n\n{extra}".strip() if extra else default_hint
if not isinstance(spec, dict):
return default_hint
replace_text = spec.get("replace")
if isinstance(replace_text, str) and replace_text.strip():
base = replace_text.strip()
else:
base = default_hint
append_text = spec.get("append")
if isinstance(append_text, str) and append_text.strip():
return f"{base}\n\n{append_text.strip()}".strip()
return base
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
"""Assemble the system prompt as three ordered parts.
@@ -133,17 +83,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# we resolve through ``_ra()`` to honor those patches.
_r = _ra()
# Resolve the model's context window once so context-file caps can scale
# to it (dynamic cap — see prompt_builder._dynamic_context_file_max_chars).
# None falls back to the historical flat default. This value is stable for
# the life of the conversation, so it does not threaten prompt caching.
_ctx_len: Optional[int] = None
_cc = getattr(agent, "context_compressor", None)
if _cc is not None:
_cc_len = getattr(_cc, "context_length", None)
if isinstance(_cc_len, int) and _cc_len > 0:
_ctx_len = _cc_len
# ── Stable tier ────────────────────────────────────────────────
stable_parts: List[str] = []
@@ -152,7 +91,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# cwd project instructions disabled.
_soul_loaded = False
if agent.load_soul_identity or not agent.skip_context_files:
_soul_content = _r.load_soul_md(_ctx_len)
_soul_content = _r.load_soul_md()
if _soul_content:
stable_parts.append(_soul_content)
_soul_loaded = True
@@ -173,17 +112,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
stable_parts.append(TASK_COMPLETION_GUIDANCE)
# Universal parallel-tool-call guidance. Tells the model to batch
# independent tool calls into one assistant turn rather than emitting one
# call per turn — the runtime already runs independent calls concurrently
# (read-only tools always; non-overlapping path-scoped file ops), so the
# only thing missing was steering the model to produce the batch. Cuts
# round-trips and the resent-context cost that compounds over a long
# conversation. Gated by config.yaml ``agent.parallel_tool_call_guidance``
# (default True) and only injected when tools are actually loaded.
if getattr(agent, "_parallel_tool_call_guidance", True) and agent.valid_tool_names:
stable_parts.append(PARALLEL_TOOL_CALL_GUIDANCE)
# Tool-aware behavioral guidance: only inject when the tools are loaded
tool_guidance = []
if "memory" in agent.valid_tool_names:
@@ -380,25 +308,18 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
platform_key = (agent.platform or "").lower().strip()
# Resolve the built-in/plugin default hint for this platform, then apply
# any per-platform override from config (platform_hints.<platform>).
_default_hint = ""
if platform_key in PLATFORM_HINTS:
_default_hint = PLATFORM_HINTS[platform_key]
stable_parts.append(PLATFORM_HINTS[platform_key])
elif platform_key:
# Check plugin registry for platform-specific LLM guidance
try:
from gateway.platform_registry import platform_registry
_entry = platform_registry.get(platform_key)
if _entry and _entry.platform_hint:
_default_hint = _entry.platform_hint
stable_parts.append(_entry.platform_hint)
except Exception:
pass
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
if _effective_hint:
stable_parts.append(_effective_hint)
# ── Context tier (cwd-dependent, may change between sessions) ─
context_parts: List[str] = []
@@ -413,8 +334,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# dir — the user's real cwd there, but the install dir for the gateway
# daemon, which is why the gateway sets TERMINAL_CWD.
context_files_prompt = _r.build_context_files_prompt(
cwd=resolve_context_cwd(), skip_soul=_soul_loaded,
context_length=_ctx_len)
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
if context_files_prompt:
context_parts.append(context_files_prompt)

View File

@@ -1012,42 +1012,28 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
elif function_name == "memory":
def _execute(next_args: dict) -> Any:
target = next_args.get("target", "memory")
operations = next_args.get("operations")
from tools.memory_tool import memory_tool as _memory_tool
result = _memory_tool(
action=next_args.get("action"),
target=target,
content=next_args.get("content"),
old_text=next_args.get("old_text"),
operations=operations,
store=agent._memory_store,
)
# Bridge: notify external memory provider of built-in memory writes.
# Covers both the single-op shape and each add/replace inside a batch.
if agent._memory_manager:
if operations:
_mem_ops = [
op for op in operations
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
]
else:
_mem_ops = (
[{"action": next_args.get("action"), "content": next_args.get("content")}]
if next_args.get("action") in {"add", "replace"} else []
# Bridge: notify external memory provider of built-in memory writes
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
try:
agent._memory_manager.on_memory_write(
next_args.get("action", ""),
target,
next_args.get("content", ""),
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", None),
),
)
for _op in _mem_ops:
try:
agent._memory_manager.on_memory_write(
_op.get("action", ""),
target,
_op.get("content", "") or "",
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", None),
),
)
except Exception:
pass
except Exception:
pass
return result
function_result, function_args = _run_agent_tool_execution_middleware(
agent,

View File

@@ -88,7 +88,7 @@ class AnthropicTransport(ProviderTransport):
from agent.transports.types import ToolCall
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
_MCP_PREFIX = "mcp__"
_MCP_PREFIX = "mcp_"
text_parts = []
reasoning_parts = []
@@ -132,25 +132,17 @@ class AnthropicTransport(ProviderTransport):
elif block.type == "tool_use":
name = block.name
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
# On the OAuth wire every tool carries a double-underscore
# ``mcp__`` prefix (added in build_anthropic_kwargs to avoid
# Anthropic's single-underscore third-party classifier).
# Reverse it back to the name the registry/dispatcher knows.
# Two original forms map onto the same ``mcp__`` wire name:
# ``mcp__read_file`` <- bare native tool ``read_file``
# ``mcp__linear_get_issue`` <- MCP server tool
# ``mcp_linear_get_issue``
# Resolve by registry lookup, preferring whichever original
# is actually registered; never rewrite a name the LLM used
# that already resolves natively. GH-25255.
stripped = name[len(_MCP_PREFIX):]
# Only strip the mcp_ prefix for OAuth-injected tools
# (where Hermes adds the prefix when sending to Anthropic
# and must remove it on the way back). Native MCP server
# tools (from mcp_servers: in config.yaml) are registered
# in the tool registry under their FULL mcp_<server>_<tool>
# name and must NOT be stripped. GH-25255.
from tools.registry import registry as _tool_registry
if not _tool_registry.get_entry(name):
bare = name[len(_MCP_PREFIX):] # read_file
single = "mcp_" + bare # mcp_read_file / mcp_linear_get_issue
if _tool_registry.get_entry(single):
name = single
elif _tool_registry.get_entry(bare):
name = bare
if (_tool_registry.get_entry(stripped)
and not _tool_registry.get_entry(name)):
name = stripped
tool_calls.append(
ToolCall(
id=block.id,

View File

@@ -128,65 +128,6 @@ class ResponsesApiTransport(ProviderTransport):
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
response_tools = _responses_tools(tools)
# xAI server-side web search.
#
# grok models on xAI's /v1/responses surface (notably
# grok-composer-2.5-fast on SuperGrok OAuth) have a *native*,
# server-executed web search. When the model is handed a
# client-side function literally named ``web_search``, it routes
# the intent to that native engine — but because the tool is
# declared as a plain ``function`` rather than xAI's first-class
# ``{"type": "web_search"}`` built-in, the server-side search is
# dispatched but never reconciled: the response streams reasoning
# + ``web_search_call`` progress items, the searches never reach
# ``status="completed"`` in the assembled output, no final
# message is emitted, and ``_normalize_codex_response`` correctly
# sees reasoning-with-no-answer and reports ``incomplete``. The
# turn then burns 3 continuation retries and fails with "Codex
# response remained incomplete after 3 continuation attempts".
# Verified live against grok-composer-2.5-fast (2026-06).
#
# Fix: when the agent HAS a client-side ``web_search`` function (i.e.
# the user enabled the web toolset), declare xAI's native
# ``web_search`` built-in instead so the search actually runs to
# completion server-side and the model streams a real answer. The
# Responses API rejects two tools sharing the name ``web_search``
# (HTTP 400 "Duplicate tool names"), so we drop the client-side
# ``web_search`` function for the xAI path and let the native tool
# satisfy it. All other client-side tools (read_file, terminal,
# web_extract, MCP tools, …) are untouched and continue to dispatch
# through Hermes's agent loop.
#
# Scope: we ONLY swap in the native built-in when the client
# ``web_search`` was actually present. We do NOT force-enable Grok
# server-side search on turns where the user never had web enabled —
# that would silently route around Hermes's web-provider config and
# tool-trace/citation plumbing for every xai-oauth turn. The swap is
# a 1:1 replacement of an already-requested capability, not an
# additive grant.
#
# NOTE: for the swapped case this routes ``web_search`` to Grok's
# native search engine for xAI sessions instead of Hermes's
# configured web provider (Tavily/etc.), and those results bypass
# Hermes's tool-trace / citation plumbing (they arrive baked into the
# model's answer rather than as a tool result the loop observes).
# Scoped to ``is_xai_responses`` deliberately; narrow to specific
# models if a future grok variant should keep the client-side
# function.
if is_xai_responses and response_tools:
has_client_web_search = any(
isinstance(t, dict) and t.get("name") == "web_search"
for t in response_tools
)
if has_client_web_search:
filtered = [
t for t in response_tools
if not (isinstance(t, dict) and t.get("name") == "web_search")
]
filtered.append({"type": "web_search"})
response_tools = filtered
# ``tools`` MUST be omitted entirely when there are no functions to
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
@@ -277,28 +218,10 @@ class ResponsesApiTransport(ProviderTransport):
kwargs.pop("timeout", None)
if is_codex_backend:
# The Codex backend rejects body-level ``extra_headers`` with
# HTTP 400, but the OpenAI SDK's ``extra_headers`` kwarg maps
# to actual HTTP request headers (not body fields). We need
# these headers for cache-scope routing so prompt cache hits
# remain high. Send session_id / x-client-request-id as HTTP
# headers while keeping ``prompt_cache_key`` in the body for
# standard OpenAI routing as a belt-and-braces fallback.
cache_scope_id = str(session_id or "").strip()
if cache_scope_id:
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
if isinstance(existing_extra_headers, dict):
merged_extra_headers.update(
{
str(key): str(value)
for key, value in existing_extra_headers.items()
if key and value is not None
}
)
merged_extra_headers["session_id"] = cache_scope_id
merged_extra_headers["x-client-request-id"] = cache_scope_id
kwargs["extra_headers"] = merged_extra_headers
# chatgpt.com/backend-api/codex rejects body-level
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
# this backend must not be sent through the Responses payload.
kwargs.pop("extra_headers", None)
max_tokens = params.get("max_tokens")
if max_tokens is not None and not is_codex_backend:

View File

@@ -69,7 +69,6 @@ def build_turn_context(
task_id: Optional[str],
stream_callback,
persist_user_message: Optional[str],
persist_user_timestamp: Optional[float] = None,
*,
restore_or_build_system_prompt,
install_safe_stdio,
@@ -112,24 +111,6 @@ def build_turn_context(
# Restore the primary runtime if the previous turn activated fallback.
agent._restore_primary_runtime()
# Between-turns MCP refresh: an MCP server that finished connecting since
# the previous turn (slow HTTP/OAuth servers routinely take 2-6s on a cold
# connect, missing the bounded startup wait) lands in THIS turn's tool
# snapshot. This is cache-safe by construction: it runs in the per-turn
# prologue, before this turn's first API call assembles ``tools=``, so it
# only ever extends a fresh request prefix — it never mutates the cached
# prefix of an in-flight turn. No-op when no MCP servers are registered
# (the common case, gated by the cheap ``has_registered_mcp_tools`` check)
# or when the tool set is unchanged (``refresh_agent_mcp_tools`` diffs by
# name and leaves the snapshot untouched on no-change).
try:
if not getattr(agent, "_skip_mcp_refresh", False):
from tools.mcp_tool import has_registered_mcp_tools, refresh_agent_mcp_tools
if has_registered_mcp_tools():
refresh_agent_mcp_tools(agent, quiet_mode=True)
except Exception:
logger.debug("between-turns MCP tool refresh skipped", exc_info=True)
# Sanitize surrogate characters from user input.
if isinstance(user_message, str):
user_message = sanitize_surrogates(user_message)
@@ -140,7 +121,6 @@ def build_turn_context(
agent._stream_callback = stream_callback
agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message
agent._persist_user_message_timestamp = persist_user_timestamp
# Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id

View File

@@ -286,7 +286,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
emit_stage(&app, "rebuild", StageState::Running, None, None);
let started = Instant::now();
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
let mut rebuild = run_streamed(
let rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
@@ -295,33 +295,6 @@ async fn run_update(app: AppHandle) -> Result<()> {
Some("rebuild"),
)
.await?;
// Retry-once: the first `--build-only` can return nonzero on a still-settling
// post-update tree or a network-blocked Electron fetch that our self-heal
// repaired mid-run. A second attempt then builds clean off the healed dist
// (the content-hash stamp makes it a near-no-op when the first actually
// succeeded). Without this the updater bails here and never reaches the
// relaunch below — the app updates but doesn't restart. Matches the
// retry-once `hermes update` already does above, and `hermes update`'s own
// desktop rebuild in cmd_update.
if rebuild_needs_retry(rebuild.exit_code) {
emit_log(
&app,
Some("rebuild"),
LogStream::Stdout,
"[rebuild] first desktop rebuild failed; retrying once (a self-healed \
Electron download builds clean on the second run)…",
);
rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
&install_root,
&child_env,
Some("rebuild"),
)
.await?;
}
let rebuild_ms = started.elapsed().as_millis() as u64;
if rebuild.exit_code != Some(0) {
@@ -560,14 +533,6 @@ fn is_locked(path: &Path) -> bool {
}
}
/// Whether the `desktop --build-only` rebuild should be retried once. Any
/// non-success exit qualifies: the common cause is a transient first-attempt
/// failure (still-settling tree / self-healed Electron download) that a clean
/// second run resolves.
fn rebuild_needs_retry(exit_code: Option<i32>) -> bool {
exit_code != Some(0)
}
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
/// but for an arbitrary command (no install.ps1 -File wrapping).
@@ -1005,16 +970,6 @@ mod tests {
assert_eq!(update_branch_from_args(["--update"]), None);
}
#[test]
fn rebuild_retries_only_on_failure() {
assert!(!rebuild_needs_retry(Some(0)), "a clean rebuild must not retry");
assert!(rebuild_needs_retry(Some(1)), "a failed rebuild retries once");
assert!(
rebuild_needs_retry(None),
"a killed/signalled rebuild (no exit code) retries once"
);
}
#[test]
fn parses_only_app_targets() {
assert_eq!(

View File

@@ -28,7 +28,6 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ
const { runBootstrap } = require('./bootstrap-runner.cjs')
const {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
@@ -45,7 +44,6 @@ const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
@@ -2010,14 +2008,10 @@ async function applyUpdatesPosixInApp() {
}
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
// Retry-once: a first rebuild can fail on a still-settling tree or a
// self-healed (network-blocked) Electron download; a second run builds clean
// off the healed dist so we reach the swap+relaunch below instead of bailing.
const rebuilt = await runRebuildWithRetry(attempt => {
if (attempt > 0) {
emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 })
}
return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' })
const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], {
cwd: updateRoot,
env,
stage: 'rebuild'
})
if (rebuilt.code !== 0) {
emitUpdateProgress({
@@ -5112,7 +5106,14 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
if (IS_MAC) {
@@ -5154,6 +5155,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({
@@ -5179,11 +5316,23 @@ function createWindow() {
// material before the renderer paints the app theme. See createSessionWindow.
show: false,
backgroundColor: getWindowBackgroundColor(),
// Shared with the secondary session windows (chatWindowWebPreferences) so
// both keep `backgroundThrottling: false` — the chat transcript streams via
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
// windows, stalling the live answer until refocus. See session-windows.cjs.
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
// Keep timers + requestAnimationFrame running at full speed when the
// window is blurred/occluded. The chat transcript streams to the screen
// through a requestAnimationFrame-gated flush (useSessionStateCache),
// so with Chromium's default background throttling the live answer
// stalls whenever this window isn't focused (e.g. you switch to your
// editor mid-turn, or open detached devtools) and only appears once you
// refocus or refresh. A streaming chat app must render in the
// background, so opt out — matching the secondary windows above.
backgroundThrottling: false
}
})
if (IS_MAC) {
@@ -5211,6 +5360,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 +5485,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 +6799,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 {
@@ -6551,12 +6819,6 @@ app.on('before-quit', () => {
flushDesktopLogBufferSync()
closePreviewWatchers()
// Kill open PTYs before environment teardown to avoid the node-pty#904
// ThreadSafeFunction SIGABRT race.
for (const id of [...terminalSessions.keys()]) {
disposeTerminalSession(id)
}
if (hermesProcess && !hermesProcess.killed) {
hermesProcess.kill('SIGTERM')
}

View File

@@ -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),

View File

@@ -10,29 +10,6 @@ const { pathToFileURL } = require('node:url')
const SESSION_WINDOW_MIN_WIDTH = 420
const SESSION_WINDOW_MIN_HEIGHT = 620
// Shared webPreferences for every window that renders the chat transcript — the
// primary window AND the secondary session windows. Keeping it in one place is
// the whole point: the two BrowserWindow definitions in main.cjs used to be
// hand-copied, and the secondary windows silently lost `backgroundThrottling:
// false`, so a streamed answer stalled until the window regained focus.
//
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
// blurred/occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath) {
return {
preload: preloadPath,
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
backgroundThrottling: false
}
}
// Build the renderer URL for a secondary window. The renderer uses a
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
@@ -117,7 +94,6 @@ function createSessionWindowRegistry() {
module.exports = {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH

View File

@@ -1,11 +1,7 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry
} = require('./session-windows.cjs')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// test fire the 'closed' event, mirroring the slice of the Electron API the
@@ -179,21 +175,3 @@ test('registry trims the session id before keying', () => {
assert.equal(registry.has('s1'), true)
})
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
// Regression: secondary session windows used to omit this flag, so a streamed
// answer stalled until the window regained focus (Chromium pauses the
// requestAnimationFrame-gated transcript flush for backgrounded windows).
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
assert.equal(prefs.backgroundThrottling, false)
})
test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => {
const prefs = chatWindowWebPreferences('/some/preload.cjs')
assert.equal(prefs.preload, '/some/preload.cjs')
assert.equal(prefs.contextIsolation, true)
assert.equal(prefs.sandbox, true)
assert.equal(prefs.nodeIntegration, false)
})

View File

@@ -1,29 +0,0 @@
'use strict'
/**
* Retry-once policy for the desktop `--build-only` rebuild during self-update.
*
* The first rebuild can return nonzero on a still-settling post-update tree or a
* network-blocked Electron fetch that the installer's self-heal repaired mid-run.
* A second attempt then builds clean off the healed dist (the content-hash stamp
* makes it a near-no-op when the first actually succeeded). Without the retry the
* updater bails before the relaunch step — the app updates but doesn't restart.
*/
function shouldRetryRebuild(code) {
return code !== 0
}
/**
* Run `rebuild()` (async, resolves `{ code, ... }`), retrying once on failure.
* Returns the final result.
*/
async function runRebuildWithRetry(rebuild) {
let result = await rebuild(0)
if (shouldRetryRebuild(result.code)) {
result = await rebuild(1)
}
return result
}
module.exports = { shouldRetryRebuild, runRebuildWithRetry }

View File

@@ -1,55 +0,0 @@
/**
* Tests for electron/update-rebuild.cjs — the retry-once policy for the desktop
* `--build-only` rebuild during self-update.
*
* Run with: node --test electron/update-rebuild.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Why this matters: a first rebuild can return nonzero on a still-settling tree
* or a self-healed (network-blocked) Electron download. Without a second attempt
* the updater bails before the relaunch step — the app updates but never restarts
* (the field report behind this fix). The retry must fire on failure, not on
* success, and must run at most twice.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs')
test('shouldRetryRebuild retries only on a non-success exit', () => {
assert.equal(shouldRetryRebuild(0), false)
assert.equal(shouldRetryRebuild(1), true)
assert.equal(shouldRetryRebuild(null), true)
})
test('a clean first rebuild runs once and does not retry', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 0 })
})
assert.deepEqual(codes, [0])
assert.equal(result.code, 0)
})
test('a failed first rebuild retries once and succeeds', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: attempt === 0 ? 1 : 0 })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 0)
})
test('a rebuild that keeps failing runs at most twice and reports the failure', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 1, error: 'rebuild-failed' })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 1)
assert.equal(result.error, 'rebuild-failed')
})

View File

@@ -21,7 +21,7 @@
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
"dist:mac": "npm run build && npm run builder -- --mac",
@@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -55,7 +55,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hermes/shared": "file:../shared",
"@icons-pack/react-simple-icons": "=13.11.1",
"@icons-pack/react-simple-icons": "^13.13.0",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
@@ -117,7 +117,7 @@
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"electron": "40.10.2",
"electron": "^40.9.3",
"electron-builder": "^26.8.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
@@ -134,7 +134,8 @@
"wait-on": "^9.0.5"
},
"build": {
"electronVersion": "40.10.2",
"electronVersion": "40.9.3",
"electronDist": "../../node_modules/electron/dist",
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",

View File

@@ -24,11 +24,6 @@ const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes cop
if (!fs.existsSync(bundledElectronBinary)) {
const candidates = [
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
// npm may nest the workspace-only electron devDep under
// apps/desktop/node_modules (process.cwd() during pack), or hoist
// it to the repo root. Try the workspace-local install first, then
// the root hoist, so the fallback works under either layout.
path.join(process.cwd(), "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
];
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));

View File

@@ -1,57 +0,0 @@
"use strict"
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
// npm workspace hoisting is non-deterministic — require.resolve finds electron
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
const fs = require("node:fs")
const path = require("node:path")
const { spawnSync } = require("node:child_process")
function electronDistDir() {
try {
return path.join(path.dirname(require.resolve("electron/package.json")), "dist")
} catch {
return null
}
}
function distBinary(dist) {
if (process.platform === "darwin") {
return path.join(dist, "Electron.app", "Contents", "MacOS", "Electron")
}
if (process.platform === "win32") {
return path.join(dist, "electron.exe")
}
return path.join(dist, "electron")
}
function electronBuilderCli() {
const pkgJson = require.resolve("electron-builder/package.json")
const bin = require(pkgJson).bin
const rel = typeof bin === "string" ? bin : bin["electron-builder"]
return path.join(path.dirname(pkgJson), rel)
}
const dist = electronDistDir()
const args = []
if (dist && fs.existsSync(distBinary(dist))) {
args.push(`-c.electronDist=${dist}`)
} else {
console.warn(
"[run-electron-builder] no local electron dist; electron-builder will fetch " +
"via @electron/get (electronVersion + ELECTRON_MIRROR)."
)
}
args.push(...process.argv.slice(2))
const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], {
stdio: "inherit",
})
if (result.error) {
console.error(`[run-electron-builder] spawn failed: ${result.error.message}`)
process.exit(1)
}
process.exit(result.status == null ? 1 : result.status)

View File

@@ -357,7 +357,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
</button>
{visibleRows.length > 0 ? (
<div className="grid min-w-0 gap-1 pl-6" data-selectable-text="true">
<div className="grid min-w-0 gap-1 pl-6">
{visibleRows.map((entry, i) => (
<StreamLine
active={running && i === visibleRows.length - 1}
@@ -371,7 +371,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
) : null}
{open && fileLines.length > 0 ? (
<div className="grid min-w-0 gap-0.5 pl-6" data-selectable-text="true">
<div className="grid min-w-0 gap-0.5 pl-6">
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">
{t.agents.files}
</p>

View File

@@ -15,9 +15,7 @@ import { Backdrop } from '@/components/Backdrop'
import { PromptOverlays } from '@/components/prompt-overlays'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ErrorState } from '@/components/ui/error-state'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
@@ -40,7 +38,6 @@ import {
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$resumeExhaustedSessionId,
$selectedStoredSessionId,
$sessions,
sessionPinId
@@ -89,9 +86,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onRestoreToMessage?: (messageId: string) => Promise<void>
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise<string>
onDismissError?: (messageId: string) => void
}
interface ChatHeaderProps {
@@ -277,12 +272,9 @@ export function ChatView({
onEdit,
onReload,
onRestoreToMessage,
onRetryResume,
onTranscribeAudio,
onDismissError
onTranscribeAudio
}: ChatViewProps) {
const location = useLocation()
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
@@ -304,7 +296,6 @@ export function ChatView({
const messagesEmpty = useStore($messagesEmpty)
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
const selectedSessionId = useStore($selectedStoredSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const routedSessionId = routeSessionId(location.pathname)
const isRoutedSessionView = Boolean(routedSessionId)
@@ -324,21 +315,9 @@ export function ChatView({
// session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s last-visible-user gate.
//
// resumeExhausted: the bounded auto-retry in use-route-resume gave up on this
// routed session (gateway RPC + REST fallback failed through every attempt).
// Suppress the loader and show an explicit error + manual Retry instead of
// spinning forever. Gated on the route matching so a stale latch from another
// session can't blank the current one.
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
const loadingSession =
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
// Hide the composer in the exhausted error state too: there's no live runtime
// to send to until a retry rebinds one.
const showChatBar = !loadingSession && !resumeExhausted
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
@@ -453,7 +432,6 @@ export function ChatView({
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
onCancel={onCancel}
onDismissError={onDismissError}
onRestoreToMessage={onRestoreToMessage}
sessionId={activeSessionId}
sessionKey={threadKey}
@@ -487,21 +465,6 @@ export function ChatView({
</Suspense>
)}
</ChatRuntimeBoundary>
{resumeExhausted && routedSessionId && (
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
<ErrorState
className="max-w-sm"
description={t.desktop.resumeStrandedBody}
title={t.desktop.resumeStrandedTitle}
>
<div className="grid justify-items-center">
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
{t.desktop.resumeRetry}
</Button>
</div>
</ErrorState>
</div>
)}
{showChatBar && <ScrollToBottomButton />}
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />

View File

@@ -395,7 +395,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
</div>
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
<Button onClick={() => void runSystemAction('restart')} size="xs" variant="text">
{cc.restartGateway}
{cc.restartMessaging}
</Button>
<Button onClick={() => void runSystemAction('update')} size="xs" variant="textStrong">
{cc.updateHermes}
@@ -426,10 +426,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
</span>
)}
</div>
<pre
className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)"
data-selectable-text="true"
>
<pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)">
{logs.length ? logs.join('\n') : cc.noLogs}
</pre>
</div>

View File

@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { KbdCombo } from '@/components/ui/kbd'
@@ -20,6 +21,7 @@ import {
Clock,
Cpu,
Download,
Egg,
Globe,
type IconComponent,
Info,
@@ -29,8 +31,8 @@ import {
Moon,
Package,
Palette,
PawPrint,
Plus,
RefreshCw,
Settings,
Settings2,
Sun,
@@ -40,9 +42,9 @@ 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 { runGatewayRestart } from '@/store/system-actions'
import { $petGenStatus, cleanupPetGen, generateDrafts } from '@/store/pet-generate'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
@@ -64,6 +66,8 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
import { MarketplaceThemePage } from './marketplace-theme-page'
import { PetGeneratePage } from './pet-generate-page'
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
interface PaletteItem {
/** Keybind action id — its live combo renders as a hotkey hint. */
@@ -89,7 +93,7 @@ interface PaletteGroup {
// Nested page → its parent, so Back / Esc step up one level instead of closing
// the palette. Pages absent here go straight back to the root list.
const PAGE_PARENTS: Record<string, string> = { 'install-theme': 'theme' }
const PAGE_PARENTS: Record<string, string> = { 'generate-pet': 'pets', 'install-theme': 'theme' }
/** A nested page reachable from a root item via `to`. */
interface PalettePage {
@@ -207,8 +211,10 @@ 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 { requestGateway } = useGatewayRequest()
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
const [page, setPage] = useState<string | null>(null)
@@ -244,13 +250,23 @@ export function CommandPalette() {
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
// Reset the query/sub-page on close so it reopens clean.
// Reset the query/sub-page on close so it reopens clean. Cleanup also deletes
// a hatched-but-unadopted preview pet so it doesn't linger in the gallery.
useEffect(() => {
if (!open) {
setSearch('')
setPage(null)
cleanupPetGen(requestGateway)
}
}, [open])
}, [open, requestGateway])
// 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])
@@ -362,13 +378,6 @@ export function CommandPalette() {
keywords: ['command center', 'usage', 'tokens', 'cost'],
label: cc.sections.usage,
run: go(`${COMMAND_CENTER_ROUTE}?section=usage`)
},
{
icon: RefreshCw,
id: 'cc-restart-gateway',
keywords: ['gateway', 'restart', 'messaging', 'reconnect', 'system'],
label: cc.restartGateway,
run: () => void runGatewayRestart()
}
]
},
@@ -391,6 +400,20 @@ 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'
},
{
icon: Egg,
id: 'appearance-generate-pet',
keywords: ['pet', 'generate', 'create', 'make', 'new pet', 'mascot', 'hatch', 'ai'],
label: cc.generatePet.title,
to: 'generate-pet'
}
]
},
@@ -559,6 +582,18 @@ 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: describe → draft variants → hatch a custom pet.
'generate-pet': {
title: t.commandCenter.generatePet.title,
placeholder: t.commandCenter.generatePet.placeholder,
groups: []
},
// Server-driven page: items come from the Marketplace, rendered by
// <MarketplaceThemePage> (loader + live search + per-row install).
'install-theme': {
@@ -629,49 +664,77 @@ export function CommandPalette() {
event.preventDefault()
event.stopPropagation()
goBack()
return
}
// On the generate page, Enter (re)generates from the typed
// concept — cmdk has no item to select there, so each Enter,
// including a retype after drafts already exist, starts a fresh
// round. The page's own Retry/Hatch buttons cover the rest.
if (page === 'generate-pet' && event.key === 'Enter' && search.trim()) {
const genStatus = $petGenStatus.get()
if (
genStatus !== 'generating' &&
genStatus !== 'hatching' &&
genStatus !== 'preview' &&
genStatus !== 'adopting'
) {
event.preventDefault()
void generateDrafts(requestGateway, { prompt: search })
}
}
}}
onValueChange={setSearch}
placeholder={placeholder}
right={page === 'pets' ? <PetInlineToggle /> : undefined}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{page === 'install-theme' ? (
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'generate-pet' ? (
<PetGeneratePage search={search} />
) : page === 'pets' ? (
<PetPalettePage onGenerate={() => { setSearch(''); setPage('generate-pet') }} search={search} />
) : page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
)}
{visibleGroups.map((group, index) => (
<CommandGroup
className={HUD_HEADING}
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
>
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
<>
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
{visibleGroups.map((group, index) => (
<CommandGroup
className={HUD_HEADING}
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
>
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
return (
<CommandItem
className={cn(HUD_ITEM, HUD_TEXT)}
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{item.to && (
<ChevronRight
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
/>
)}
</CommandItem>
)
})}
</CommandGroup>
))}
return (
<CommandItem
className={cn(HUD_ITEM, HUD_TEXT)}
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{item.to && (
<ChevronRight
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
/>
)}
</CommandItem>
)
})}
</CommandGroup>
))}
</>
)}
</CommandList>
</Command>
</DialogPrimitive.Content>

View File

@@ -0,0 +1,303 @@
/**
* Cmd-K → Pets → "Generate" page — describe a pet, pick a draft, hatch it.
*
* A thin view over the `pet-generate` store. The palette search box doubles as
* the concept prompt; this page renders the variant grid, the selection, the
* retry/hatch actions, and the loading states. The store owns the two-step
* `pet.generate` → `pet.hatch` flow.
*/
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetSprite } from '@/components/pet/pet-sprite'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Egg, Loader2, PawPrint, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { closeCommandPalette } from '@/store/command-palette'
import { type PetInfo } from '@/store/pet'
import {
$petGenDrafts,
$petGenError,
$petGenPreview,
$petGenSelected,
$petGenStatus,
adoptHatched,
discardHatched,
generateDrafts,
hatchSelected
} from '@/store/pet-generate'
const VARIANT_COUNT = 4
// Fixed render scale for the preview so it's a predictable size regardless of
// the user's configured `display.pet.scale`.
const PREVIEW_SCALE = 0.7
// Fallback row order if a backend doesn't return `stateRows`.
const PREVIEW_ROWS = ['idle', 'waving', 'running-right', 'running-left', 'running', 'review', 'jumping', 'failed']
const PREVIEW_STATE_MS = 1500
const ROW_TO_FRAME_KEY: Record<string, string> = {
idle: 'idle',
wave: 'wave',
waving: 'wave',
jump: 'jump',
jumping: 'jump',
run: 'run',
running: 'run',
'running-right': 'run',
'running-left': 'run',
failed: 'failed',
review: 'review',
waiting: 'waiting'
}
function frameCountForRow(pet: PetInfo, row: string): number {
const byState = pet.framesByState
const mapped = ROW_TO_FRAME_KEY[row]
return byState?.[row] ?? (mapped ? byState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
}
interface PetGeneratePageProps {
search: string
}
export function PetGeneratePage({ search }: PetGeneratePageProps) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
const { requestGateway } = useGatewayRequest()
const status = useStore($petGenStatus)
const error = useStore($petGenError)
const drafts = useStore($petGenDrafts)
const selected = useStore($petGenSelected)
const preview = useStore($petGenPreview)
const [name, setName] = useState('')
const prompt = search.trim()
const busy = status === 'generating' || status === 'hatching'
const generate = () => {
if (prompt) {
void generateDrafts(requestGateway, { prompt })
}
}
const hatch = () => {
void hatchSelected(requestGateway, { name: name.trim() || prompt, prompt })
}
const adopt = () => {
void adoptHatched(requestGateway).then(out => {
if (out.ok) {
triggerHaptic('crisp')
closeCommandPalette()
}
})
}
if (status === 'stale') {
return <Status text={copy.staleBackend} tone="error" />
}
// Hatching is slow (several grounded image generations) — own the whole pane.
if (status === 'hatching') {
return <Status icon={<Loader2 className="size-4 animate-spin" />} text={copy.hatching} />
}
// Preview: play every animation row before the user commits.
if ((status === 'preview' || status === 'adopting') && preview) {
return (
<HatchPreview
adopting={status === 'adopting'}
error={error}
onAdopt={adopt}
onDiscard={() => void discardHatched(requestGateway)}
pet={preview}
/>
)
}
const hasDrafts = drafts.length > 0
const generating = status === 'generating'
const cells = generating ? Array.from({ length: VARIANT_COUNT }, (_, i) => ({ index: i, dataUri: '' })) : drafts
return (
<div className="flex flex-col gap-2 p-2">
{error && <p className="px-1 text-[0.6875rem] text-(--ui-red)">{error}</p>}
{!hasDrafts && !generating && (
<p className="px-1 py-1 text-xs text-muted-foreground">{prompt ? copy.readyHint : copy.promptHint}</p>
)}
{(hasDrafts || generating) && (
<div className="grid grid-cols-2 gap-2">
{cells.map((draft, i) => {
const isSelected = !generating && selected === draft.index
return (
<button
className={cn(
'relative flex aspect-square items-center justify-center overflow-hidden rounded-lg border bg-(--ui-bg-quinary) transition-colors',
isSelected
? 'border-(--ui-accent) ring-2 ring-(--ui-accent)/40'
: 'border-(--ui-stroke-tertiary) hover:border-foreground/40'
)}
disabled={generating || busy}
key={generating ? i : draft.index}
onClick={() => $petGenSelected.set(draft.index)}
onMouseDown={event => event.preventDefault()}
type="button"
>
{generating ? (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
) : (
<img alt="" className="size-full object-contain" draggable={false} src={draft.dataUri} />
)}
{isSelected && (
<span className="absolute right-1 top-1 rounded-full bg-(--ui-accent) p-0.5 text-(--ui-base)">
<Check className="size-3" />
</span>
)}
</button>
)
})}
</div>
)}
{hasDrafts ? (
<div className="flex flex-col gap-2">
<input
className="w-full rounded-md border border-(--ui-stroke-tertiary) bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground focus:border-foreground/40"
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
hatch()
}
}}
placeholder={copy.namePlaceholder}
value={name}
/>
<div className="flex gap-2">
<button
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border px-2 py-1.5 text-xs font-medium transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-50"
disabled={busy || !prompt}
onClick={generate}
onMouseDown={event => event.preventDefault()}
type="button"
>
<RefreshCw className="size-3.5" />
{copy.retry}
</button>
<button
className="flex flex-1 items-center justify-center gap-1.5 rounded-md bg-primary px-2 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
disabled={busy || selected === null}
onClick={hatch}
onMouseDown={event => event.preventDefault()}
type="button"
>
<PawPrint className="size-3.5" />
{copy.hatch}
</button>
</div>
</div>
) : (
<button
className="flex items-center justify-center gap-1.5 rounded-md bg-primary px-2 py-2 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
disabled={busy || !prompt}
onClick={generate}
onMouseDown={event => event.preventDefault()}
type="button"
>
{generating ? <Loader2 className="size-3.5 animate-spin" /> : <Egg className="size-3.5" />}
{generating ? copy.generating : copy.generate}
</button>
)}
</div>
)
}
interface HatchPreviewProps {
pet: PetInfo
adopting: boolean
error: string | null
onAdopt: () => void
onDiscard: () => void
}
function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: HatchPreviewProps) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
const [stateIndex, setStateIndex] = useState(0)
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
const rows = previewRows.length > 0 ? previewRows : ['idle']
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
// Cycle through the animation rows so the preview showcases all frames.
useEffect(() => {
const id = setInterval(() => {
setStateIndex(i => (i + 1) % rows.length)
}, PREVIEW_STATE_MS)
return () => clearInterval(id)
}, [rows.length])
useEffect(() => {
setStateIndex(0)
}, [pet.slug])
const previewInfo: PetInfo = { ...pet, scale: PREVIEW_SCALE }
return (
<div className="flex flex-col items-center gap-2 p-2">
<div className="flex min-h-[9rem] w-full items-center justify-center rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) py-2">
<PetSprite info={previewInfo} rowOverride={activeRow} />
</div>
{pet.displayName && <p className="text-xs font-medium text-foreground">{pet.displayName}</p>}
{error && <p className="px-1 text-[0.6875rem] text-(--ui-red)">{error}</p>}
<div className="flex w-full gap-2">
<button
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border px-2 py-1.5 text-xs font-medium transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-50"
disabled={adopting}
onClick={onDiscard}
onMouseDown={event => event.preventDefault()}
type="button"
>
<RefreshCw className="size-3.5" />
{copy.startOver}
</button>
<button
className="flex flex-1 items-center justify-center gap-1.5 rounded-md bg-primary px-2 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
disabled={adopting}
onClick={onAdopt}
onMouseDown={event => event.preventDefault()}
type="button"
>
{adopting ? <Loader2 className="size-3.5 animate-spin" /> : <PawPrint className="size-3.5" />}
{copy.adopt}
</button>
</div>
</div>
)
}
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
return (
<div
className={cn(
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
)}
>
{icon}
{text}
</div>
)
}

View File

@@ -0,0 +1,205 @@
/**
* 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, Egg, 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
/** Navigate to the "generate a pet" page (rendered as a header action). */
onGenerate?: () => void
}
export function PetPalettePage({ search, onGenerate }: 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 <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
}
if (status === 'stale') {
return <Status text={copy.staleBackend} tone="error" />
}
if (!gallery?.pets.length && error) {
return <Status text={error} tone="error" />
}
const mutating = Boolean(busy)
return (
<div role="listbox">
{onGenerate && (
<button
className={cn(
'flex w-full items-center gap-2 rounded-md text-left text-foreground transition-colors hover:bg-(--chrome-action-hover)',
HUD_ITEM,
HUD_TEXT
)}
onClick={onGenerate}
onMouseDown={event => event.preventDefault()}
type="button"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-(--chrome-action-hover)">
<Egg className="size-4" />
</span>
<span className="font-medium">{t.commandCenter.generatePet.title}</span>
</button>
)}
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
{shown.length === 0 ? (
<Status text={copy.empty} />
) : (
shown.map(pet => {
const isActive = enabled && pet.slug === active
const isBusy = busy === pet.slug
return (
<button
className={cn(
'flex w-full items-center gap-2 rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60',
HUD_ITEM,
HUD_TEXT,
isActive && 'bg-(--chrome-action-hover)/70'
)}
disabled={mutating && !isBusy}
key={pet.slug}
onClick={() => adopt(pet.slug)}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
>
<PetThumb
alt={pet.displayName}
load={(slug, url) => loadPetThumb(requestGateway, slug, url)}
size={32}
slug={pet.slug}
url={pet.spritesheetUrl}
/>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{pet.displayName}</span>
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
{pet.slug}
{pet.installed ? ` · ${copy.installed}` : ''}
</span>
</span>
<span className="ml-auto flex shrink-0 items-center text-[0.6875rem] text-muted-foreground">
{isBusy ? (
<Loader2 className="size-3 animate-spin" />
) : isActive ? (
<Check className="size-3.5 text-foreground" />
) : null}
</span>
</button>
)
})
)}
</div>
)
}
/**
* 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 (
<button
aria-label={enabled ? copy.turnOff : copy.turnOn}
aria-pressed={enabled}
className={cn(
'flex shrink-0 items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50',
enabled ? 'bg-(--chrome-action-hover) text-foreground' : 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
)}
disabled={Boolean(busy)}
onClick={toggle}
// Don't steal focus from the search input on click.
onMouseDown={event => event.preventDefault()}
title={enabled ? copy.turnOff : copy.turnOn}
type="button"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <PawPrint className="size-4" />}
</button>
)
}
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
return (
<div
className={cn(
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
)}
>
{icon}
{text}
</div>
)
}

View File

@@ -13,8 +13,7 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { storedSessionIdForNotification } from '../lib/session-ids'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
@@ -39,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,
@@ -50,13 +51,11 @@ import {
} from '../store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$currentCwd,
$freshDraftReady,
$gatewayState,
$messages,
$messagingSessions,
$resumeFailedSessionId,
$resumeExhaustedSessionId,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
@@ -203,8 +202,6 @@ export function DesktopController() {
const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd)
const freshDraftReady = useStore($freshDraftReady)
const resumeFailedSessionId = useStore($resumeFailedSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const filePreviewTarget = useStore($filePreviewTarget)
const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
@@ -277,20 +274,16 @@ export function DesktopController() {
}
}, [])
// Notification click: the main process already focused the window; jump to its
// session. Notifications are tagged with the gateway *runtime* session id, but
// the chat route is keyed by the *stored* id — navigating with the runtime id
// resumes a non-existent stored session ("session not found") and strands the
// user. Translate runtime -> stored before navigating.
// Notification click: the main process already focused the window; jump to its session.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
if (sessionId) {
navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionIdRef.current)))
navigate(sessionRoute(sessionId))
}
})
return () => unsubscribe?.()
}, [navigate, runtimeIdByStoredSessionIdRef])
}, [navigate])
// Notification action button (Approve/Reject) — resolve in place, no navigation.
useEffect(() => {
@@ -746,49 +739,6 @@ export function DesktopController() {
[branchCurrentSession, refreshSessions]
)
// Clear a failed turn's red error banner from the transcript. Errors are
// renderer-local state (never persisted), so dismissing is purely a view +
// session-cache edit. A message that errored before emitting any visible
// text is a bare error placeholder → drop it entirely; one that streamed
// partial output then failed keeps its content and just sheds the error.
// Both the per-runtime cache AND the live $messages view must be updated:
// `preserveLocalAssistantErrors` re-grafts any still-errored message it
// finds in the view onto the next session.info flush, so clearing only the
// cache would let the heartbeat resurrect the banner.
const dismissError = useCallback(
(messageId: string) => {
const runtimeSessionId = activeSessionIdRef.current
if (!runtimeSessionId) {
return
}
const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] =>
messages.flatMap(message => {
if (message.id !== messageId || !message.error) {
return [message]
}
if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) {
return []
}
return [{ ...message, error: undefined, pending: false }]
})
// View first: the flush below reads $messages as the "current" baseline
// for error preservation, so the banner must be gone from it before the
// cache update triggers a re-sync.
setMessages(clearErrorIn($messages.get()))
updateSessionState(runtimeSessionId, state => ({
...state,
messages: clearErrorIn(state.messages)
}))
},
[activeSessionIdRef, updateSessionState]
)
const startSessionInWorkspace = useCallback(
(path: null | string) => {
startFreshSessionDraft()
@@ -839,6 +789,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 => {
@@ -898,8 +895,6 @@ export function DesktopController() {
gatewayState,
locationPathname: location.pathname,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
@@ -1049,7 +1044,6 @@ export function DesktopController() {
void removeSession(selectedStoredSessionId)
}
}}
onDismissError={dismissError}
onEdit={editMessage}
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
onPickFiles={() => void composer.pickContextPaths('file')}
@@ -1058,7 +1052,6 @@ export function DesktopController() {
onReload={reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)}
onRestoreToMessage={restoreToMessage}
onRetryResume={sessionId => void resumeSession(sessionId, true)}
onSteer={steerPrompt}
onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange}

View File

@@ -17,7 +17,6 @@ import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { runGatewayRestart } from '@/store/system-actions'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -98,8 +97,6 @@ function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
const { t } = useI18n()
const m = t.messaging
// Both save/toggle toasts offer the same one-click restart.
const restartGatewayAction = { label: t.commandCenter.restartGateway, onClick: () => void runGatewayRestart() }
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('')
@@ -200,8 +197,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
notify({
kind: 'success',
title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
message: m.restartToApply,
action: restartGatewayAction
message: m.restartToApply
})
} catch (err) {
notifyError(err, m.failedUpdate(platform.name))
@@ -226,8 +222,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
notify({
kind: 'success',
title: m.setupSaved(platform.name),
message: m.restartToReconnect,
action: restartGatewayAction
message: m.restartToReconnect
})
} catch (err) {
notifyError(err, m.failedSave(platform.name))

View File

@@ -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(
<StrictMode>
<ErrorBoundary label="pet-overlay">
<ThemeProvider>
<PetOverlayApp />
</ThemeProvider>
</ErrorBoundary>
</StrictMode>
)
}

View File

@@ -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<DragState | null>(null)
const petRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
const ignoreRef = useRef(true)
const composerOpenRef = useRef(false)
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div
onPointerDown={e => {
// 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 && (
<input
onChange={e => 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}
/>
)}
<div
onPointerDown={onPetPointerDown}
onPointerMove={onPetPointerMove}
onPointerUp={onPetPointerUp}
ref={petRef}
style={{
alignItems: 'center',
cursor: 'grab',
display: 'flex',
flexDirection: 'column',
position: 'relative',
touchAction: 'none'
}}
>
<div style={{ marginBottom: 4 }}>
<PetBubble />
</div>
<div style={{ lineHeight: 0, position: 'relative' }}>
<PetSprite info={info} />
{/* 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 && (
<button
aria-label="Open in Hermes"
onClick={openApp}
onPointerDown={e => e.stopPropagation()}
onPointerUp={e => e.stopPropagation()}
style={{
alignItems: 'center',
background: 'var(--ui-bg-elevated)',
border: '1px solid var(--ui-stroke-secondary)',
borderRadius: 999,
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
color: 'var(--foreground)',
cursor: 'pointer',
display: 'inline-flex',
height: 24,
justifyContent: 'center',
padding: 0,
position: 'absolute',
right: 0,
top: 0,
width: 24
}}
title="Open in Hermes"
type="button"
>
<Mail style={{ height: 13, width: 13 }} />
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -13,7 +13,6 @@ import {
type GatewayEventPayload,
reasoningPart,
renderMediaTags,
textPart,
upsertToolPart
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
@@ -34,6 +33,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 +870,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 +903,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 +929,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
@@ -1081,32 +1112,6 @@ export function useMessageStream({
// completions / watch matches here — re-sync the status stack.
void refreshBackgroundProcesses(sessionId)
}
} else if (event.type === 'review.summary') {
// Self-improvement background review saved something to memory/skills
// and emitted a persistent summary (Python formats it as
// "💾 Self-improvement review: …"). The CLI prints this via
// prompt_toolkit and the Ink TUI renders it as a system line; the
// desktop has neither, so without this handler the skill/memory
// change happens silently. Surface it as a persistent system message
// in the transcript so the user is always informed — it must not be a
// transient toast that can be missed.
const text = coerceGatewayText(payload?.text).trim()
if (text && sessionId) {
flushQueuedDeltas(sessionId)
updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{
id: `review-summary-${Date.now()}`,
role: 'system',
parts: [textPart(text)],
timestamp: Math.floor(Date.now() / 1000)
}
]
}))
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
@@ -1120,6 +1125,11 @@ export function useMessageStream({
compactedTurnRef.current.delete(sessionId)
}
if (isActiveEvent) {
setPetActivity({ reasoning: false, toolRunning: false })
flashPetActivity({ error: true })
}
dispatchNativeNotification({
body: errorMessage,
kind: 'turnError',
@@ -1129,13 +1139,8 @@ export function useMessageStream({
if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage)
} else {
// Toast globally, not just when the failing thread is focused: a
// turn-ending error (e.g. out of funds) blocks every thread, so the
// inline error alone is too easy to miss. The stable id collapses the
// same error from multiple blocked threads into one toast.
} else if (isActiveEvent) {
notify({
id: `gateway-error:${errorMessage}`,
kind: 'error',
title: 'Hermes error',
message: errorMessage

View File

@@ -27,18 +27,19 @@ 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,
type ComposerAttachment,
setComposerAttachmentUploadState,
setComposerDraft,
terminalContextBlocksFromDraft,
updateComposerAttachment
} from '@/store/composer'
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,
@@ -58,8 +59,8 @@ import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import type {
ClientSessionState,
BrowserManageResponse,
ClientSessionState,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
@@ -952,26 +953,8 @@ export function usePromptActions({
return
}
// send / prefill carry an optional `notice` (e.g. "⊙ Goal set …")
// that the backend wants shown as a system line before the message
// is acted on. Mirrors the TUI's createSlashHandler — without it a
// `/goal <text>` looked like it did nothing.
if ((dispatch.type === 'send' || dispatch.type === 'prefill') && dispatch.notice?.trim()) {
renderSlashOutput(dispatch.notice.trim())
}
const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? ''
// /undo returns a prefill directive: drop the backed-up message into
// the composer for editing instead of submitting it immediately.
if (dispatch.type === 'prefill') {
if (message) {
setComposerDraft(message)
}
return
}
if (!message) {
renderSlashOutput(
`/${name}: ${dispatch.type === 'skill' ? 'skill payload missing message' : 'empty message'}`
@@ -1162,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 <n>` 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 <factor> (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
@@ -1378,6 +1390,7 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
const releaseBusy = () => {
setMutableRef(busyRef, false)
setBusy(false)

View File

@@ -2,8 +2,6 @@ import { cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $resumeExhaustedSessionId, setResumeExhaustedSessionId } from '@/store/session'
import { useRouteResume } from './use-route-resume'
interface HarnessProps {
@@ -15,8 +13,6 @@ interface HarnessProps {
gatewayState: string
locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
resumeFailedSessionId?: null | string
resumeExhaustedSessionId?: null | string
routedSessionId: null | string
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: null | string
@@ -24,12 +20,8 @@ interface HarnessProps {
startFreshSessionDraft: (focus: boolean) => unknown
}
function RouteResumeHarness({
resumeFailedSessionId = null,
resumeExhaustedSessionId = null,
...props
}: HarnessProps) {
useRouteResume({ ...props, resumeExhaustedSessionId, resumeFailedSessionId })
function RouteResumeHarness(props: HarnessProps) {
useRouteResume(props)
return null
}
@@ -264,212 +256,3 @@ describe('useRouteResume', () => {
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
})
describe('useRouteResume bounded auto-retry after a failed resume', () => {
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
setResumeExhaustedSessionId(null)
})
// Common stranded-window props: gateway open, route on the session, no runtime
// yet, and the ref already synced to the route (resumeSession sets it at entry
// before failing) — the exact state that defeats the main effect's self-heal.
function strandedProps(resumeSession: (sid: string, focus: boolean) => Promise<unknown>) {
return {
activeSessionId: null,
activeSessionIdRef: { current: null } as MutableRefObject<null | string>,
creatingSessionRef: { current: false },
currentView: 'chat',
freshDraftReady: false,
gatewayState: 'open',
locationPathname: '/session-1',
resumeSession,
routedSessionId: 'session-1',
runtimeIdByStoredSessionIdRef: { current: new Map<string, string>() },
selectedStoredSessionId: 'session-1',
// Synced to the route by the failed resume's synchronous entry-write.
selectedStoredSessionIdRef: { current: 'session-1' } as MutableRefObject<null | string>,
startFreshSessionDraft: vi.fn()
}
}
it('retries the resume on backoff when the routed session is flagged as failed', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="session-1" />)
// The main effect fires one resume on mount (pathname-changed). Clear it so
// we assert purely the bounded-retry effect's scheduled retry below.
resumeSession.mockClear()
// No immediate fire — the retry is scheduled behind the backoff timer.
expect(resumeSession).not.toHaveBeenCalled()
// First backoff window (1s) elapses → one retry.
vi.advanceTimersByTime(1_000)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
it('does NOT retry a failed session that is not the routed one', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
// The failure flag points at a different session than the route.
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="other-session" />)
resumeSession.mockClear() // drop the mount resume
vi.advanceTimersByTime(10_000)
expect(resumeSession).not.toHaveBeenCalled()
})
it('skips the scheduled retry if the session already recovered when the timer fires', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear() // drop the mount resume
// A resume landed while we waited: runtime is now bound.
props.activeSessionIdRef.current = 'runtime-1'
vi.advanceTimersByTime(8_000)
expect(resumeSession).not.toHaveBeenCalled()
})
it('stops retrying after MAX_RESUME_RETRIES consecutive failures', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Model the real re-arm loop: resumeSession clears $resumeFailedSessionId at
// entry (null) and a repeat failure re-sets it ('session-1'). That null->id
// toggle is what re-runs the effect and advances the bounded counter. The
// routed session never changes, so the counter is NOT reset between cycles.
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear() // drop the mount resume; count only the retries
for (let i = 0; i < 8; i += 1) {
vi.advanceTimersByTime(8_000) // fire the scheduled retry (if any)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />) // cleared at entry
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />) // re-armed on failure
}
// Capped at MAX_RESUME_RETRIES (4): a persistently dead backend can't
// hot-loop the resume forever.
expect(resumeSession.mock.calls.length).toBe(4)
// Once auto-retry gives up, the exhausted latch is armed for the routed
// session so the chat view can swap the perpetual loader for an explicit
// error + manual Retry instead of spinning forever.
expect($resumeExhaustedSessionId.get()).toBe('session-1')
})
it('does not arm the exhausted latch while retries remain', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear()
// Two failure cycles — still under the 4-retry cap, so the latch must stay
// clear and the loader keeps spinning (auto-recovery hasn't given up yet).
for (let i = 0; i < 2; i += 1) {
vi.advanceTimersByTime(8_000)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
}
expect($resumeExhaustedSessionId.get()).toBeNull()
})
it('clears a stale exhausted latch when the route moves off the stranded session', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Pre-arm the latch as if this session had exhausted its retries.
setResumeExhaustedSessionId('session-1')
// Route is now on a different, healthy session that is not flagged as
// failed — the retry effect's "route moved off" branch clears the latch.
render(
<RouteResumeHarness
{...props}
activeSessionId="runtime-2"
activeSessionIdRef={{ current: 'runtime-2' }}
locationPathname="/session-2"
resumeFailedSessionId={null}
routedSessionId="session-2"
selectedStoredSessionId="session-2"
selectedStoredSessionIdRef={{ current: 'session-2' }}
/>
)
expect($resumeExhaustedSessionId.get()).toBeNull()
})
it('resets the retry counter for a fresh backoff cycle when the exhausted latch clears (manual retry, same session)', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Phase A — exhaust the bounded auto-retry (counter → MAX) like a dead
// backend. The resumeExhaustedSessionId prop stays null here: the hook sets
// the store, which doesn't feed back into the prop in this harness.
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear()
for (let i = 0; i < 8; i += 1) {
vi.advanceTimersByTime(8_000)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
}
expect(resumeSession.mock.calls.length).toBe(4) // capped
expect($resumeExhaustedSessionId.get()).toBe('session-1')
// Phase B — user clicks Retry on the SAME stranded session. resumeSession
// clears both latches at entry; the exhausted latch's armed->cleared edge
// must reset the attempt counter so a fresh bounded cycle runs, not a single
// one-shot attempt that immediately re-arms the error. Model the prop
// transitions: reflect the armed latch, then clear it (retry), then re-arm
// the failure latch on the fresh failure.
resumeSession.mockClear()
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId="session-1" resumeFailedSessionId="session-1" />)
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId="session-1" />)
// A real retry fires again instead of staying pinned at MAX (which would
// dispatch nothing). Without the reset the counter stays >= MAX and this
// advance dispatches zero resumes.
vi.advanceTimersByTime(8_000)
expect(resumeSession.mock.calls.length).toBeGreaterThan(0)
})
it('does not burn retry attempts on unrelated re-renders during the backoff window', () => {
vi.useFakeTimers()
const props = strandedProps(vi.fn())
// Mount schedules the first backoff timer. Then re-render repeatedly with a
// fresh resumeSession identity (referential instability — a real dep change
// for the retry effect) WITHOUT ever letting the timer fire. The old code
// incremented the attempt counter at schedule time, so >= MAX re-renders
// armed the exhausted error with zero resumes actually dispatched. The fix
// only advances the counter when a timer truly fires, so the latch stays
// clear no matter how many spurious re-renders happen mid-backoff.
const { rerender } = render(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
)
for (let j = 0; j < 8; j += 1) {
rerender(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
)
}
expect($resumeExhaustedSessionId.get()).toBeNull()
})
})

View File

@@ -1,7 +1,6 @@
import { type MutableRefObject, useEffect, useRef } from 'react'
import { isNewChatRoute } from '@/app/routes'
import { setResumeExhaustedSessionId } from '@/store/session'
interface RouteResumeOptions {
activeSessionId: string | null
@@ -12,17 +11,6 @@ interface RouteResumeOptions {
gatewayState: string | undefined
locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
// Stored-session id whose most recent resume failed terminally (set by
// useSessionActions, mirrored from $resumeFailedSessionId). While this equals
// routedSessionId the window would otherwise latch on the loader forever, so
// the bounded-retry effect below re-attempts the resume.
resumeFailedSessionId: string | null
// Stored-session id whose bounded auto-retry has EXHAUSTED (mirrored from
// $resumeExhaustedSessionId). Only resumeSession clears this latch (manual
// Retry / reconnect / reselect) — the auto-retry loop never does — so its
// armed->cleared edge is an unambiguous "give me a fresh backoff cycle"
// signal the effect below uses to reset the attempt counter.
resumeExhaustedSessionId: string | null
routedSessionId: string | null
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: string | null
@@ -30,19 +18,6 @@ interface RouteResumeOptions {
startFreshSessionDraft: (focus: boolean) => unknown
}
// Bounded auto-retry for a stranded session window. A resume can fail terminally
// (gateway RPC reject + REST fallback failure) on a transiently wedged backend —
// dead provider key, a runaway turn hogging the dispatcher, flaky DNS. Without a
// retry the loader latches forever. We retry with backoff, capped, so a
// genuinely dead backend doesn't hot-loop the resume.
const MAX_RESUME_RETRIES = 4
const RESUME_RETRY_BASE_MS = 1_000
const RESUME_RETRY_MAX_MS = 8_000
function resumeRetryDelayMs(attempt: number): number {
return Math.min(RESUME_RETRY_MAX_MS, RESUME_RETRY_BASE_MS * 2 ** attempt)
}
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
// parsed. If the hash references a real session, defer; resume picks it up
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
@@ -74,8 +49,6 @@ export function useRouteResume({
gatewayState,
locationPathname,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
@@ -85,16 +58,6 @@ export function useRouteResume({
const lastPathnameRef = useRef<string | null>(null)
const seenGatewayStateRef = useRef(false)
const wasGatewayOpenRef = useRef(false)
// Per-session retry bookkeeping for the bounded auto-retry effect below. Keyed
// by the session id we're retrying so switching chats resets the counter.
const retrySessionIdRef = useRef<string | null>(null)
const retryAttemptRef = useRef(0)
// Tracks the previous exhausted-latch value so we can detect its armed->cleared
// edge. resumeSession clears $resumeExhaustedSessionId on a manual Retry /
// reconnect / reselect; that transition is our cue to reset the attempt counter
// for a fresh backoff cycle on the SAME session (the auto-retry loop itself
// never touches this latch, so it can't spuriously trigger the reset).
const prevResumeExhaustedRef = useRef<string | null>(null)
useEffect(() => {
const gatewayOpen = gatewayState === 'open'
@@ -176,111 +139,4 @@ export function useRouteResume({
selectedStoredSessionIdRef,
startFreshSessionDraft
])
// Bounded auto-retry: when the routed session's resume failed terminally
// (resumeFailedSessionId matches the route), schedule a backoff retry so the
// window recovers on its own instead of latching the loader forever. This is
// the safety net the main effect above can't provide: after a failed resume,
// selectedStoredSessionIdRef.current already equals the route (resumeSession
// sets it synchronously at entry) and the pathname/gateway are unchanged, so
// none of stuckOnRoutedSession / pathnameChanged / gatewayBecameOpen fire
// again. resumeSession clears resumeFailedSessionId on its next attempt; a
// success keeps it clear (the effect's guard then no-ops), a repeat failure
// re-arms it and we back off further, capped at MAX_RESUME_RETRIES.
useEffect(() => {
// Detect the exhausted-latch armed->cleared edge for the current route. Only
// resumeSession clears $resumeExhaustedSessionId (manual Retry / reconnect /
// reselect) — the auto-retry loop never touches it — so this transition
// uniquely means "the user asked for another go." Reset the attempt counter
// for a fresh bounded backoff cycle on the SAME session. Without this,
// retryAttemptRef stays pinned at MAX after exhaustion (the !stranded reset
// below only fires on a route CHANGE to a different session), so a manual
// retry on the same stranded session would get exactly ONE attempt and then
// immediately re-arm the exhausted error — never the renewed backoff cycle
// the store/session.ts + use-session-actions.ts comments promise. (Point 2)
const wasExhausted = prevResumeExhaustedRef.current
prevResumeExhaustedRef.current = resumeExhaustedSessionId
if (wasExhausted && wasExhausted === routedSessionId && resumeExhaustedSessionId !== wasExhausted) {
retrySessionIdRef.current = routedSessionId
retryAttemptRef.current = 0
}
if (currentView !== 'chat' || gatewayState !== 'open') {
return
}
const stranded =
Boolean(routedSessionId) &&
resumeFailedSessionId === routedSessionId &&
!creatingSessionRef.current
if (!stranded) {
// Route moved off the stranded session (or it recovered) — reset the
// counter so a future failure on another session starts fresh, and clear
// any exhausted-latch armed for a session we're no longer viewing (never
// the current route: that's the error state we want to keep showing).
// resumeSession also clears it on a fresh attempt; this covers a plain
// route-change away from the stranded window.
if (retrySessionIdRef.current !== routedSessionId) {
retrySessionIdRef.current = null
retryAttemptRef.current = 0
setResumeExhaustedSessionId(current => (current && current !== routedSessionId ? null : current))
}
return
}
// New stranded session id → reset the attempt counter.
if (retrySessionIdRef.current !== routedSessionId) {
retrySessionIdRef.current = routedSessionId
retryAttemptRef.current = 0
}
if (retryAttemptRef.current >= MAX_RESUME_RETRIES) {
// Give up auto-retrying a persistently dead backend; the user can still
// reconnect / reselect (which resets the counter via the branch above).
// Surface an explicit error + manual Retry in the chat view instead of
// spinning the loader forever — resumeSession (manual Retry / reconnect /
// reselect) clears this latch and resets the counter for a fresh cycle.
setResumeExhaustedSessionId(routedSessionId)
return
}
const attempt = retryAttemptRef.current
const sessionId = routedSessionId as string
const timer = setTimeout(() => {
// Re-check liveness at fire time: a resume may have landed while we waited.
if (
creatingSessionRef.current ||
selectedStoredSessionIdRef.current !== sessionId ||
activeSessionIdRef.current !== null
) {
return
}
// Consume an attempt ONLY now that a resume is actually dispatching.
// Incrementing at schedule time (the old behavior) let unrelated dep
// changes during the 1s8s backoff window — a transient gatewayState
// flip, a non-referentially-stable resumeSession — clear the pending
// timer and re-run the effect, burning an attempt without any resume
// having fired. A flapping backend could then hit MAX in a couple of
// re-renders with far fewer than MAX real attempts. (Point 3)
retryAttemptRef.current += 1
void resumeSession(sessionId, true)
}, resumeRetryDelayMs(attempt))
return () => clearTimeout(timer)
}, [
activeSessionIdRef,
creatingSessionRef,
currentView,
gatewayState,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
selectedStoredSessionIdRef
])
}

View File

@@ -3,9 +3,8 @@ import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages } from '@/hermes'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
import { $currentCwd } from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -118,142 +117,3 @@ describe('createBackendSessionForSend profile routing', () => {
expect(params).toMatchObject({ profile: 'default' })
})
})
// ── Resume failure recovery (the "stuck loading session window" bug) ──────────
// When session.resume rejects AND the REST transcript fallback ALSO fails, the
// hook must (a) not throw out of the fallback (which stranded the loader), and
// (b) arm $resumeFailedSessionId so use-route-resume can retry. A resume that
// succeeds must NOT leave the flag armed.
function ResumeHarness({
onReady,
requestGateway
}: {
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
})
useEffect(() => {
onReady(actions.resumeSession)
}, [actions.resumeSession, onReady])
return null
}
describe('resumeSession failure recovery', () => {
afterEach(() => {
cleanup()
setResumeFailedSessionId(null)
setMessages([])
vi.restoreAllMocks()
})
async function runResume(
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
): Promise<void> {
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
}
it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => {
// session.resume rejects (e.g. timeout against a wedged backend)...
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
// ...and the REST transcript fallback also rejects (backend unreachable).
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
await runResume(requestGateway)
// The window is no longer silently stranded: the failure latch is armed for
// the stored session, which use-route-resume consumes to retry.
expect($resumeFailedSessionId.get()).toBe('stored-1')
})
it('does NOT arm the failure latch when the resume RPC fails but the REST fallback paints history', async () => {
// session.resume rejects, but the REST transcript fallback succeeds and
// hydrates a readable transcript — the window is NOT stranded.
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [
{ content: 'hello', role: 'user', timestamp: 1 },
{ content: 'hi there', role: 'assistant', timestamp: 2 }
],
session_id: 'stored-1'
} as never)
await runResume(requestGateway)
// Arming here would auto-retry a window that already shows history and,
// on exhaustion, blank that transcript behind the error overlay — a
// regression vs. plain fallback-success. The latch must stay clear.
expect($resumeFailedSessionId.get()).toBeNull()
// The fallback transcript is visible.
expect($messages.get().length).toBeGreaterThan(0)
})
it('does NOT throw out of the fallback when REST also fails (no unhandled rejection)', async () => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
// resumeSession must resolve (swallow the fallback failure), not reject.
await expect(runResume(requestGateway)).resolves.toBeUndefined()
})
it('leaves the failure latch clear when resume succeeds', async () => {
// Pre-arm to prove a successful resume clears it (entry-clear path).
setResumeFailedSessionId('stored-1')
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
await runResume(requestGateway)
expect($resumeFailedSessionId.get()).toBeNull()
})
})

View File

@@ -38,8 +38,6 @@ import {
setFreshDraftReady,
setIntroSeed,
setMessages,
setResumeExhaustedSessionId,
setResumeFailedSessionId,
setSelectedStoredSessionId,
setSessions,
setSessionStartedAt,
@@ -581,15 +579,6 @@ export function useSessionActions({
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
// Optimistically clear any prior resume-failure latch for this session:
// we're attempting a fresh resume, so the self-heal in use-route-resume
// must not keep treating it as stranded. It's re-armed below only if THIS
// attempt fails terminally (RPC reject + REST fallback failure).
setResumeFailedSessionId(current => (current === storedSessionId ? null : current))
// Also clear the exhausted-latch: a fresh attempt (manual Retry, reconnect,
// reselect) gives the bounded auto-retry counter a clean cycle, so the
// chat view drops the error state and shows the loader again.
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
@@ -780,41 +769,13 @@ export function useSessionActions({
return
}
// The gateway resume RPC failed. Try the REST transcript as a fallback
// so the window at least shows history. CRITICAL: this fallback must be
// wrapped in its own try — if it ALSO throws (wedged/unreachable backend,
// the common case when resume failed in the first place), an unguarded
// throw here skips setMessages AND leaves activeSessionId null with an
// empty transcript. That is the exact state the thread loader latches on
// forever (messagesEmpty && !activeSessionId) with no recovery path —
// the "open in new window stays stuck loading, even after a nap" bug.
try {
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
if (!isCurrentResume()) {
return
}
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
} catch {
// Fallback also failed: nothing to paint. Leave whatever messages are
// already shown and fall through to arm the resume-failure latch so
// use-route-resume re-attempts the resume on the next render / window
// focus / gateway reconnect instead of stranding the loader.
}
if (isCurrentResume() && $messages.get().length === 0) {
// Arm the self-heal ONLY when the window is still empty: the gateway
// resume rejected AND the REST fallback failed to paint a transcript.
// That is the exact stranded state the loader latches on
// (messagesEmpty && !activeSessionId), and matches $resumeFailedSessionId's
// documented contract. If the REST fallback DID paint history, the
// window is readable — arming here would needlessly auto-retry and,
// once retries exhaust, blank that visible transcript behind the
// exhausted-state error overlay (a regression vs. plain fallback success).
setResumeFailedSessionId(storedSessionId)
if (!isCurrentResume()) {
return
}
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
notifyError(err, copy.resumeFailed)
} finally {
if (isCurrentResume()) {

View File

@@ -2,14 +2,12 @@ import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentServiceTier,
$messages,
$turnStartedAt,
setCurrentFastMode,
setCurrentModel,
@@ -215,113 +213,3 @@ describe('useSessionStateCache — per-session turn timer', () => {
expect($currentFastMode.get()).toBe(false)
})
})
function userMessage(id: string, text: string): ChatMessage {
return { id, role: 'user', parts: [{ type: 'text', text }] }
}
function assistantText(id: string, text: string): ChatMessage {
return { id, role: 'assistant', parts: [{ type: 'text', text }] }
}
function assistantError(id: string, error: string): ChatMessage {
return { id, role: 'assistant', parts: [], error, pending: false }
}
interface ViewHarnessProps {
activeSessionId: string | null
onReady: (cache: Cache) => void
}
function ViewHarness({ activeSessionId, onReady }: ViewHarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId: null,
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
// Wire the published view back into the real $messages atom the flush
// reads from, so the round-trip matches production.
setMessages: messages => $messages.set(messages)
})
onReady(cache)
return null
}
describe('useSessionStateCache — cross-thread error isolation', () => {
afterEach(() => {
cleanup()
$messages.set([])
})
it('does not leak a failed turn into another thread on switch', () => {
$messages.set([])
let cache!: Cache
const { rerender } = render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
// Thread A ends its turn with an out-of-funds error and is on screen.
act(() => {
cache.updateSessionState(
'thread-A',
state => ({
...state,
busy: false,
messages: [userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'Out of funds')]
}),
'stored-A'
)
})
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(true)
// Switch to thread B (which completed cleanly). Its cached state syncs to
// the view while $messages still holds thread A's transcript.
rerender(<ViewHarness activeSessionId="thread-B" onReady={c => (cache = c)} />)
act(() => {
cache.updateSessionState(
'thread-B',
state => ({
...state,
busy: false,
messages: [userMessage('user-b', 'hello'), assistantText('assistant-b', 'hi there')]
}),
'stored-B'
)
})
expect($messages.get().map(message => message.id)).toEqual(['user-b', 'assistant-b'])
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(false)
})
it('still preserves a same-session local error a heartbeat dropped', () => {
$messages.set([])
let cache!: Cache
render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
// First paint establishes thread A as the on-screen session.
act(() => {
cache.updateSessionState(
'thread-A',
state => ({ ...state, busy: false, messages: [userMessage('user-a', 'do the thing')] }),
'stored-A'
)
})
// A local error lands in the view (e.g. failAssistantMessage wrote it).
$messages.set([userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'OpenRouter 403')])
// A later same-session heartbeat carries cached state that lost the error.
act(() => {
cache.updateSessionState('thread-A', state => ({
...state,
busy: false,
messages: [userMessage('user-a', 'do the thing')]
}))
})
expect($messages.get().some(message => message.error === 'OpenRouter 403')).toBe(true)
})
})

View File

@@ -79,9 +79,6 @@ export function useSessionStateCache({
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
const viewSyncRafRef = useRef<number | null>(null)
// Runtime id whose transcript currently occupies `$messages` — lets the
// flush below tell a same-session refresh from a thread switch.
const viewSessionIdRef = useRef<string | null>(null)
useEffect(() => {
activeSessionIdRef.current = activeSessionId
@@ -145,22 +142,12 @@ export function useSessionStateCache({
// jerks the scroll position while the user is reading. Skip the publish when
// the merged result is content-identical to what's already on screen.
const currentMessages = $messages.get()
// On a thread switch `$messages` still holds the *previous* thread, so
// preserving its local errors would graft that thread's failed turn (e.g.
// an out-of-funds error) onto this one — then cascade it everywhere as the
// polluted view becomes the next switch's baseline. Only carry errors
// across a same-session refresh; our cached state already keeps its own.
const nextMessages =
viewSessionIdRef.current === pending.sessionId
? preserveLocalAssistantErrors(pending.state.messages, currentMessages)
: pending.state.messages
const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages)
if (!sameMessageList(nextMessages, currentMessages)) {
setMessages(nextMessages)
}
viewSessionIdRef.current = pending.sessionId
syncRuntimeMetadataToView(pending.state)
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)

View File

@@ -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 (
<div
@@ -57,90 +58,200 @@ function ThemePreview({ name }: { name: string }) {
)
}
function VscodeThemeInstaller() {
function useDebounced<T>(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<string>
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<string | null>(null)
const [installedHere, setInstalledHere] = useState<Record<string, true>>({})
const [error, setError] = useState<string | null>(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 (
<div className="mt-3">
<div className="flex flex-wrap items-center gap-2">
<input
className="min-w-0 flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 font-mono text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
disabled={busy}
onChange={event => {
setId(event.target.value)
setStatus(null)
}}
onKeyDown={event => {
if (event.key === 'Enter') {
void install()
}
}}
placeholder={a.installPlaceholder}
spellCheck={false}
value={id}
/>
<button
className="inline-flex items-center gap-1.5 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] font-medium transition hover:bg-(--chrome-action-hover) disabled:opacity-50"
disabled={busy || !id.trim()}
onClick={() => void install()}
type="button"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
{busy ? a.installing : a.installButton}
</button>
</div>
{status && (
<p
className={cn(
'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'
)}
>
{status.text}
if (!debounced) {
return null
}
const header = (
<p className="mb-2 mt-4 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
From the VS Code Marketplace
</p>
)
if (search.isLoading) {
return (
<>
{header}
<p className="flex items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-3.5 animate-spin" />
{copy.loading}
</p>
)}
</div>
</>
)
}
if (search.isError) {
return (
<>
{header}
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{copy.error}</p>
</>
)
}
const results = search.data ?? []
if (results.length === 0) {
return (
<>
{header}
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{copy.empty}</p>
</>
)
}
return (
<>
{header}
{error && <p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{error}</p>}
<div className="grid gap-2 sm:grid-cols-2">
{results.map(item => {
const busy = installingId === item.extensionId
const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId)
return (
<button
className={cn(
'flex items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-60',
selectableCardClass({ prominent: done })
)}
disabled={Boolean(installingId) && !busy}
key={item.extensionId}
onClick={() => void install(item)}
type="button"
>
<Palette className="size-4 shrink-0 text-(--ui-text-tertiary)" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
{item.displayName}
</span>
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{item.publisher}
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
</span>
</span>
<span className="shrink-0 text-(--ui-text-tertiary)">
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : done ? (
<Check className="size-4 text-(--ui-green)" />
) : (
<Download className="size-4" />
)}
</span>
</button>
)
})}
</div>
</>
)
}
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 · <publisher.extension>";
// 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}
</p>
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
<div className="mt-2">
<ListRow
action={<LanguageSwitcher />}
description={isSavingLocale ? t.language.saving : t.language.description}
@@ -171,18 +282,107 @@ export function AppearanceSettings() {
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
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. */}
<div className="mt-3">
<input
className="w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
onChange={event => setQuery(event.target.value)}
placeholder="Search your themes or the VS Code Marketplace…"
spellCheck={false}
value={query}
/>
</div>
{/* Fixed-height scroll area so the (growing) theme list never
runs the page long; the grid scrolls inside it. */}
<div className="mt-3 max-h-96 overflow-y-auto pr-1">
{filteredThemes.length === 0 ? (
needle ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
No installed themes match "{query.trim()}".
</p>
) : null
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{filteredThemes.map(theme => {
const active = themeName === theme.name
const removable = isUserTheme(theme.name)
return (
<div className="group relative" key={theme.name}>
<button
className={cn('w-full p-2 text-left', selectableCardClass({ active, prominent: true }))}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview mode={resolvedMode} name={theme.name} />
<div className="mt-3 px-1">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
</button>
{removable && (
<button
aria-label={a.removeTheme}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => {
triggerHaptic('crisp')
removeUserTheme(theme.name)
// Re-normalize off the now-missing skin → default.
if (active) {
setTheme(theme.name)
}
}}
title={a.removeTheme}
type="button"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
)
})}
</div>
)}
<MarketplaceThemeResults
installedExtIds={installedExtIds}
onInstalled={name => setTheme(name)}
query={query}
/>
</div>
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}
</p>
)}
</>
}
description={a.colorModeDesc}
title={a.colorMode}
description={a.themeDesc}
title={
<div className="flex items-center justify-between gap-3">
<span>{a.themeTitle}</span>
<SegmentedControl
onChange={id => {
triggerHaptic('crisp')
setMode(id)
}}
options={modeOptions}
value={mode}
/>
</div>
}
wide
/>
<ListRow
@@ -211,80 +411,6 @@ export function AppearanceSettings() {
title={a.translucencyTitle}
/>
<ListRow
below={
<>
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
const removable = isUserTheme(theme.name)
return (
<div className="group relative" key={theme.name}>
<button
className={cn(
'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
{removable && (
<button
aria-label={a.removeTheme}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => {
triggerHaptic('crisp')
removeUserTheme(theme.name)
// Re-normalize off the now-missing skin → default.
if (active) {
setTheme(theme.name)
}
}}
title={a.removeTheme}
type="button"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
)
})}
</div>
<VscodeThemeInstaller />
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}
</p>
)}
</>
}
description={a.themeDesc}
title={a.themeTitle}
wide
/>
<ListRow
action={
<SegmentedControl
@@ -301,6 +427,10 @@ export function AppearanceSettings() {
/>
</div>
</div>
<div className="mt-6">
<PetSettings />
</div>
</SettingsContent>
)
}

View File

@@ -23,7 +23,6 @@ import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import { ProviderConfigPanel } from './provider-config-panel'
function ConfigField({
schemaKey,
@@ -369,9 +368,6 @@ export function ConfigSettings({
schemaKey={key}
value={getNested(config, key)}
/>
{key === 'memory.provider' && typeof getNested(config, key) === 'string' && getNested(config, key) ? (
<ProviderConfigPanel provider={String(getNested(config, key))} />
) : null}
</div>
))}
</div>

View File

@@ -239,7 +239,7 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'code_execution.mode': ['project', 'strict'],
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
'memory.provider': ['', 'builtin', 'hindsight', 'honcho'],
'memory.provider': ['', 'builtin', 'honcho'],
// Terminal execution backends — kept in sync with the dispatch ladder in
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).

View File

@@ -6,12 +6,6 @@ import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
describe('settings helpers', () => {
it('lists Hindsight as a built-in desktop memory provider option', () => {
const options = enumOptionsFor('memory.provider', '', {})
expect(options).toContain('hindsight')
})
describe('defineFieldCopy', () => {
it('flattens nested field copy paths', () => {
const copy = defineFieldCopy({

View File

@@ -16,8 +16,6 @@ const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
const startManualProviderOAuth = vi.fn()
vi.mock('@/hermes', () => ({
@@ -26,9 +24,7 @@ vi.mock('@/hermes', () => ({
getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
setEnvVar: (key: string, value: string) => setEnvVar(key, value)
}))
vi.mock('@/store/onboarding', () => ({
@@ -39,13 +35,7 @@ beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true },
// An unconfigured api_key provider — surfaced by the full-universe payload.
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
]
@@ -57,8 +47,6 @@ beforeEach(() => {
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
@@ -112,31 +100,6 @@ describe('ModelSettings', () => {
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
})
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
const fastSwitch = await screen.findByRole('switch')
fireEvent.click(fastSwitch)
await waitFor(() =>
expect(saveHermesConfig).toHaveBeenCalledWith(
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
)
)
})
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
})
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
expect(screen.queryByRole('switch')).toBeNull()
})
it('renders the auxiliary task rows', async () => {
await renderModelSettings()

View File

@@ -3,14 +3,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import {
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getHermesConfigRecord,
getRecommendedDefaultModel,
saveHermesConfig,
setEnvVar,
setModelAssignment
} from '@/hermes'
@@ -18,26 +15,11 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import type { HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { getNested, setNested } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
// Empty config = Hermes default (medium), shown as Medium.
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
// normal (mirrors tui_gateway _load_service_tier).
const isFastTier = (tier: unknown): boolean =>
['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase())
// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1).
const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal'
// A provider row is "ready" to pick a model from when it reports models. The
// backend now surfaces the full `hermes model` universe (every canonical
// provider), so unconfigured providers come back with `authenticated:false`
@@ -115,9 +97,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
// Full profile config, kept so the reasoning/speed defaults round-trip
// (read agent.* → write back the whole record) like the generic config page.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
@@ -134,11 +113,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setError('')
try {
const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels(),
getHermesConfigRecord()
getAuxiliaryModels()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
@@ -146,7 +124,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
setConfig(cfg)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@@ -204,42 +181,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
}, [auxiliary, mainModel])
// Capabilities of the APPLIED main model — gates the profile-default
// reasoning/speed controls the same way the composer picker gates per-model
// edits (reasoning defaults on, fast defaults off when unreported).
const mainCaps = useMemo(() => {
const row = providers.find(provider => provider.slug === mainModel?.provider)
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
}, [providers, mainModel])
const reasoningSupported = mainCaps?.reasoning ?? true
const fastSupported = mainCaps?.fast ?? false
const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium'
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
// Persist a single agent.* default by round-tripping the whole config record
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
const writeAgentDefault = useCallback(
async (key: string, value: string) => {
if (!config) {
return
}
const prev = config
const next = setNested(config, key, value)
setConfig(next)
try {
await saveHermesConfig(next)
} catch (err) {
setConfig(prev)
notifyError(err, m.defaultsFailed)
}
},
[config, m.defaultsFailed]
)
// Paste an API key for the selected `api_key` provider, persist it, then
// refresh so the now-authenticated provider's models populate. Auto-selects
// the recommended default model so the user can Apply in one more click.
@@ -492,38 +433,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
</p>
)}
{config && mainModel && (reasoningSupported || fastSupported) && (
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
{reasoningSupported && (
<div className="flex items-center gap-2 text-xs">
{m.reasoning}
<Select onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)} value={effortValue}>
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_VALUES.map(value => (
<SelectItem key={value} value={value}>
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{fastSupported && (
<label className="flex items-center gap-2 text-xs">
{t.shell.modelOptions.fast}
<Switch
checked={fastOn}
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
size="xs"
/>
</label>
)}
</div>
)}
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
{switchStaleAux.length > 0 && (
<div className="mt-2">

View File

@@ -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 (
<div>
<SectionHeading icon={PawPrint} title={copy.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.intro}
</p>
{staleBackend && (
<p className="mt-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.restartHint}
</p>
)}
<div className="mt-2">
<ListRow
below={
<>
<input
className="mt-3 w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
onChange={event => 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. */}
<div className="mt-3 h-72 overflow-y-auto pr-1">
{pets.length === 0 ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.unreachable}
</p>
) : shown.length === 0 ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.noMatch(query)}
</p>
) : (
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{shown.map(pet => {
const isActive = enabled && active === pet.slug
const isBusy = busySlug === pet.slug
return (
<div className="group relative" key={pet.slug}>
<button
className={cn(
'flex w-full items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-50',
selectableCardClass({ active: isActive, prominent: pet.installed })
)}
disabled={isBusy}
onClick={() => void selectPet(pet.slug)}
type="button"
>
<PetThumb
alt={pet.displayName}
load={(slug, url) => loadPetThumb(requestGateway, slug, url)}
slug={pet.slug}
url={pet.spritesheetUrl}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
{pet.displayName}
</span>
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{pet.slug}
{pet.installed ? ` · ${copy.installedTag}` : ''}
</span>
</span>
{isBusy && <Loader2 className="size-4 shrink-0 animate-spin text-(--ui-text-tertiary)" />}
</button>
{pet.installed && !isBusy && (
<button
aria-label={copy.uninstall(pet.displayName)}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => void removePet(pet.slug)}
title={copy.uninstall(pet.displayName)}
type="button"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
)
})}
</div>
)}
</div>
{/* Always-present status line so its appearance never shifts layout. */}
<p className="mt-2 min-h-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{error ? (
<span className="text-(--ui-red)">{error}</span>
) : sorted.length > RENDER_CAP ? (
copy.countCapped(RENDER_CAP, sorted.length)
) : (
copy.count(sorted.length)
)}
</p>
</>
}
description={copy.chooseDesc}
title={
<div className="flex items-center justify-between gap-3">
<span>{copy.chooseTitle}</span>
<SegmentedControl
onChange={id => void toggle(id === 'on')}
options={[
{ id: 'off', label: copy.off },
{ id: 'on', label: copy.on }
]}
value={enabled ? 'on' : 'off'}
/>
</div>
}
wide
/>
{enabled && (
<ListRow
action={
<div className="flex items-center gap-3">
<input
aria-label={copy.scaleTitle}
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
max={PET_SCALE_MAX}
min={PET_SCALE_MIN}
onChange={event => {
triggerHaptic('selection')
setPetScale(requestGateway, Number(event.target.value))
}}
step={0.05}
style={{ accentColor: 'var(--dt-primary)' }}
type="range"
value={scale}
/>
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
{`${Math.round(scale * 100)}%`}
</span>
</div>
}
description={copy.scaleDesc}
title={copy.scaleTitle}
/>
)}
</div>
</div>
)
}

View File

@@ -1,142 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig } from '@/types/hermes'
const getMemoryProviderConfig = vi.fn()
const saveMemoryProviderConfig = vi.fn()
vi.mock('@/hermes', () => ({
getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider),
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
function hindsightSchema(overrides: Partial<MemoryProviderConfig['fields'][number]>[] = []): MemoryProviderConfig {
const fields: MemoryProviderConfig['fields'] = [
{
key: 'mode',
label: 'Mode',
kind: 'select',
value: 'cloud',
description: 'How Hermes connects to Hindsight.',
placeholder: '',
is_set: true,
options: [
{ value: 'cloud', label: 'Cloud', description: 'Hindsight Cloud API (lightweight, just needs an API key)' },
{ value: 'local_external', label: 'Local External', description: 'Connect to an existing Hindsight instance' }
]
},
{
key: 'api_key',
label: 'API key',
kind: 'secret',
value: '',
description: 'Used to authenticate with the Hindsight API.',
placeholder: 'Enter Hindsight API key',
is_set: false,
options: []
},
{
key: 'api_url',
label: 'API URL',
kind: 'text',
value: 'https://api.hindsight.vectorize.io',
description: '',
placeholder: '',
is_set: true,
options: []
},
{ key: 'bank_id', label: 'Bank ID', kind: 'text', value: 'hermes', description: '', placeholder: '', is_set: true, options: [] },
{
key: 'recall_budget',
label: 'Recall budget',
kind: 'select',
value: 'mid',
description: '',
placeholder: '',
is_set: true,
options: [
{ value: 'low', label: 'low', description: '' },
{ value: 'mid', label: 'mid', description: '' },
{ value: 'high', label: 'high', description: '' }
]
}
]
return {
name: 'hindsight',
label: 'Hindsight',
fields: fields.map((field, index) => ({ ...field, ...overrides[index] }))
}
}
beforeEach(() => {
getMemoryProviderConfig.mockResolvedValue(hindsightSchema())
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderPanel(provider = 'hindsight') {
const { ProviderConfigPanel } = await import('./provider-config-panel')
return render(<ProviderConfigPanel provider={provider} />)
}
describe('ProviderConfigPanel', () => {
it('renders the declared provider fields generically', async () => {
await renderPanel()
expect(await screen.findByDisplayValue('https://api.hindsight.vectorize.io')).toBeTruthy()
expect(screen.getByDisplayValue('hermes')).toBeTruthy()
expect(screen.getByText('Cloud')).toBeTruthy()
expect(screen.getAllByText('Hindsight Cloud API (lightweight, just needs an API key)').length).toBeGreaterThan(0)
expect(screen.getByText('mid')).toBeTruthy()
})
it('collapses and expands the fields', async () => {
await renderPanel()
expect(await screen.findByLabelText('API URL')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
expect(screen.queryByLabelText('API URL')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
expect(await screen.findByLabelText('API URL')).toBeTruthy()
})
it('saves edited values without requiring a secret replacement', async () => {
await renderPanel()
const apiUrl = await screen.findByLabelText('API URL')
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() =>
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', {
mode: 'cloud',
api_key: '',
api_url: 'http://localhost:8888',
bank_id: 'ben-bank',
recall_budget: 'mid'
})
)
})
it('renders nothing for a provider with no declared config surface', async () => {
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', fields: [] })
const { container } = await renderPanel('builtin')
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
expect(container.querySelector('section')).toBeNull()
})
})

View File

@@ -1,182 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { Check, Loader2, Save } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { LoadingState, Pill } from './primitives'
/** Seed editable values from the schema: non-secret fields keep their current
* value, secret fields start blank (their value is never returned). */
function seedValues(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(
config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value])
)
}
function FieldControl({
field,
value,
onChange
}: {
field: MemoryProviderField
value: string
onChange: (value: string) => void
}) {
if (field.kind === 'select') {
const selected = field.options.find(option => option.value === value)
return (
<>
<Select onValueChange={onChange} value={value}>
<SelectTrigger className={CONTROL_TEXT}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{(selected?.description || field.description) && (
<span className="text-xs text-muted-foreground">{selected?.description || field.description}</span>
)}
</>
)
}
if (field.kind === 'secret') {
return (
<div className="flex flex-wrap items-center gap-2">
<Input
className="min-w-64 flex-1 font-mono"
onChange={event => onChange(event.target.value)}
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
type="password"
value={value}
/>
{field.is_set && (
<Pill tone="primary">
<Check className="size-3" />
Set
</Pill>
)}
</div>
)
}
return (
<Input
className="font-mono"
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
value={value}
/>
)
}
export function ProviderConfigPanel({ provider }: { provider: string }) {
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
const [values, setValues] = useState<Record<string, string>>({})
const [expanded, setExpanded] = useState(true)
const [saving, setSaving] = useState(false)
const refresh = useCallback(async () => {
try {
const next = await getMemoryProviderConfig(provider)
setConfig(next)
setValues(seedValues(next))
} catch (err) {
notifyError(err, 'Memory provider settings failed to load')
setConfig(null)
}
}, [provider])
useEffect(() => {
setConfig(null)
void refresh()
}, [refresh])
const save = useCallback(async () => {
if (!config) {
return
}
setSaving(true)
try {
await saveMemoryProviderConfig(provider, values)
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
await refresh()
} catch (err) {
notifyError(err, `Failed to save ${config.label} settings`)
} finally {
setSaving(false)
}
}, [config, provider, refresh, values])
// Providers without a declared config surface (e.g. builtin) render nothing.
if (config && config.fields.length === 0) {
return null
}
if (!config) {
return <LoadingState label="Loading memory provider settings..." />
}
const secretFields = config.fields.filter(field => field.kind === 'secret')
return (
<section className="py-3">
<button
aria-expanded={expanded}
className="flex w-full items-center justify-between gap-3 rounded-lg bg-background/60 px-3 py-2 text-left hover:bg-accent/50"
onClick={() => setExpanded(open => !open)}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<DisclosureCaret open={expanded} />
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{config.label} settings
</span>
{secretFields.map(field => (
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
))}
</span>
</button>
{expanded && (
<div className="mt-3 grid gap-4 rounded-xl bg-background/60 p-4">
{config.fields.map(field => (
<label className="grid gap-1.5" key={field.key}>
<span className="text-xs font-medium text-muted-foreground">{field.label}</span>
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
value={values[field.key] ?? ''}
/>
{field.kind !== 'select' && field.description && (
<span className="text-xs text-muted-foreground">{field.description}</span>
)}
</label>
))}
<div className="flex justify-end">
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save
</Button>
</div>
</div>
)}
</section>
)
}

View File

@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import type { OAuthProvider } from '@/types/hermes'
const listOAuthProviders = vi.fn()
const disconnectOAuthProvider = vi.fn()
@@ -36,25 +36,6 @@ function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> =
}
}
// One `/api/env` row (an EnvVarInfo) for the API-keys view. Mirrors the
// `provider()` factory above: a valid base + per-test overrides, typed against
// the real response shape so it can't drift from EnvVarInfo.
function keyVar(patch: Partial<EnvVarInfo> = {}): EnvVarInfo {
return {
advanced: false,
category: 'provider',
description: '',
is_password: true,
is_set: false,
provider: '',
provider_label: '',
redacted_value: null,
tools: [],
url: '',
...patch
}
}
beforeEach(() => {
onboarding.set({ manual: false })
getEnvVars.mockResolvedValue({})
@@ -116,56 +97,4 @@ describe('ProvidersSettings', () => {
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
})
it('renders a Keys card for a backend-tagged provider with no PROVIDER_GROUPS prefix', async () => {
// A provider the backend catalog tags (provider/provider_label) but that has
// no desktop PROVIDER_GROUPS prefix row must still render its own card —
// this is the GUI/CLI drift fix: membership comes from the backend, not
// from the hand-maintained prefix list.
getEnvVars.mockResolvedValue({
WIDGETAI_API_KEY: keyVar({
provider: 'widgetai',
provider_label: 'WidgetAI',
url: 'https://widgetai.example/keys'
})
})
listOAuthProviders.mockResolvedValue({ providers: [] })
const { ProvidersSettings } = await import('./providers-settings')
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
expect(await screen.findByText('WidgetAI')).toBeTruthy()
})
it('orders API-key providers by priority then name, and filters them via search', async () => {
// These three providers have no curated PROVIDER_GROUPS priority, so they
// share the default priority and fall back to alphabetical among themselves
// (Acme, Middle, Zebra) — exercising the name tiebreak of the priority sort.
getEnvVars.mockResolvedValue({
ZEBRA_API_KEY: keyVar({ provider: 'zebra', provider_label: 'Zebra' }),
ACME_API_KEY: keyVar({ provider: 'acme', provider_label: 'Acme' }),
MIDDLE_API_KEY: keyVar({ provider: 'middle', provider_label: 'Middle' })
})
listOAuthProviders.mockResolvedValue({ providers: [] })
const { ProvidersSettings } = await import('./providers-settings')
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
// Equal priority → alphabetical tiebreak: Acme, Middle, Zebra.
await screen.findByText('Acme')
const labels = screen.getAllByText(/Acme|Middle|Zebra/).map(el => el.textContent)
expect(labels).toEqual(['Acme', 'Middle', 'Zebra'])
// Typing narrows the list to matching providers only.
const search = screen.getByPlaceholderText('Search providers…')
fireEvent.change(search, { target: { value: 'mid' } })
await waitFor(() => expect(screen.queryByText('Acme')).toBeNull())
expect(screen.getByText('Middle')).toBeTruthy()
expect(screen.queryByText('Zebra')).toBeNull()
// A non-matching query shows the empty-state copy.
fireEvent.change(search, { target: { value: 'nonesuch-xyz' } })
expect(await screen.findByText('No providers match your search.')).toBeTruthy()
})
})

View File

@@ -12,7 +12,6 @@ import {
sortProviders
} from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button'
import { SearchField } from '@/components/ui/search-field'
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
@@ -46,17 +45,8 @@ export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
// Group the env catalog by provider — one ListRow per vendor plus optional
// advanced overrides (base URL, region, etc.). Groups without a key field are
// skipped.
//
// Grouping key precedence:
// 1. Backend `provider_label` / `provider` (from the unified provider catalog
// in hermes_cli/provider_catalog.py) — the SAME provider identity
// `hermes model` uses. This is authoritative: a provider tagged by the
// backend always renders a card, even with no PROVIDER_GROUPS row.
// 2. Desktop prefix match (`providerGroup`) — legacy fallback for provider
// env vars that predate the backend tagging.
// Only entries that resolve to neither (the "Other" bucket) are skipped.
// advanced overrides (base URL, region, etc.). Groups without a key field and
// the "Other" bucket are skipped.
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
const buckets = new Map<string, [string, EnvVarInfo][]>()
@@ -65,9 +55,7 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
continue
}
// Prefer the backend-supplied provider label/id so the Keys tab groups by
// the same identity the CLI picker uses; fall back to the prefix guess.
const name = info.provider_label?.trim() || info.provider?.trim() || providerGroup(key)
const name = providerGroup(key)
if (name === 'Other') {
continue
@@ -85,9 +73,6 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
continue
}
// Presentation overlay (priority, blurb, docs) is keyed by the prefix-based
// group name; when the backend introduced this provider it may have no
// overlay entry, so fall back to the backend/env metadata for display.
const meta = providerMeta(name)
groups.push({
@@ -146,7 +131,6 @@ function OAuthPicker({
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
// Keep connected accounts grouped and always visible; only the unconnected
// providers hide behind the disclosure, so the page leads with what's set up.
// Both lists preserve `sortProviders` order (curated priority, then name).
const connected = rest.filter(p => p.status?.logged_in)
const others = rest.filter(p => !p.status?.logged_in)
const collapsible = others.length > 0
@@ -300,8 +284,6 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
const [openProvider, setOpenProvider] = useState<null | string>(null)
const [disconnecting, setDisconnecting] = useState<null | string>(null)
// Free-text filter for the API-keys view (provider name / env-var key / desc).
const [keyQuery, setKeyQuery] = useState('')
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
// re-read connection state when the user finishes (or dismisses) a sign-in
// they launched from this page — otherwise the cards keep their stale status.
@@ -390,49 +372,20 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett
const keyGroups = buildProviderKeyGroups(vars)
if (showApiKeys) {
const q = keyQuery.trim().toLowerCase()
const visibleGroups = q
? keyGroups.filter(group => {
const haystack = [
group.name,
group.description ?? '',
group.primary[0],
...group.advanced.map(([k]) => k)
]
return haystack.some(s => s.toLowerCase().includes(q))
})
: keyGroups
return (
<SettingsContent>
{keyGroups.length > 0 ? (
<div className="grid gap-3">
<SearchField
aria-label={t.settings.providers.searchKeys}
containerClassName="w-full"
onChange={setKeyQuery}
placeholder={t.settings.providers.searchKeys}
value={keyQuery}
/>
{visibleGroups.length > 0 ? (
<div className="grid gap-2">
{visibleGroups.map(group => (
<ProviderKeyRows
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
))}
</div>
) : (
<div className="grid min-h-24 place-items-center px-4 py-6 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
{t.settings.providers.noKeysMatch}
</div>
)}
<div className="grid gap-2">
{keyGroups.map(group => (
<ProviderKeyRows
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
))}
</div>
) : (
<NoProviderKeys />

View File

@@ -272,10 +272,7 @@ function PostSetupRunner({ toolset, postSetupKey, onComplete }: PostSetupRunnerP
</div>
{status && (status.lines.length > 0 || status.running) && (
<pre
className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap"
data-selectable-text="true"
>
<pre className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap">
{status.lines.length > 0 ? status.lines.join('\n') : copy.postSetupStarting}
</pre>
)}

View File

@@ -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. */}
<NotificationStack />
{/* Petdex floating mascot — in-window, always-on-top, reactive to agent
activity. Renders nothing unless a pet is installed + enabled. */}
<FloatingPet />
</SidebarProvider>
)
}

View File

@@ -4,7 +4,6 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import {
Activity,
@@ -36,7 +35,6 @@ import {
setYoloActive
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
import { $gatewayRestarting } from '@/store/system-actions'
import {
$backendUpdateApply,
$backendUpdateStatus,
@@ -91,7 +89,6 @@ export function useStatusbarItems({
const busy = useStore($busy)
const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks)
const gatewayRestarting = useStore($gatewayRestarting)
const previewServerRestartStatus = useStore($previewServerRestartStatus)
const sessionStartedAt = useStore($sessionStartedAt)
const turnStartedAt = useStore($turnStartedAt)
@@ -302,15 +299,9 @@ export function useStatusbarItems({
variant: 'action'
},
{
className: gatewayRestarting ? undefined : gatewayClassName,
detail: gatewayRestarting ? copy.gatewayRestarting : gatewayDetail,
icon: gatewayRestarting ? (
<GlyphSpinner ariaLabel={copy.gatewayRestarting} className="size-3" />
) : inferenceReady ? (
<Activity className="size-3" />
) : (
<AlertCircle className="size-3" />
),
className: gatewayClassName,
detail: gatewayDetail,
icon: inferenceReady ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
id: 'gateway-health',
label: copy.gateway,
menuClassName: 'w-72',
@@ -363,7 +354,6 @@ export function useStatusbarItems({
gatewayMenuContent,
gatewayClassName,
gatewayDetail,
gatewayRestarting,
inferenceReady,
inferenceStatus?.reason,
openAgents,

View File

@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { createContext, useContext, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
@@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { currentPickerSelection, displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import {
@@ -62,8 +62,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const copy = t.shell.modelMenu
const closeMenu = useContext(ModelMenuCloseContext)
const [search, setSearch] = useState('')
const [refreshing, setRefreshing] = useState(false)
const queryClient = useQueryClient()
// Reactive session state is read from the stores here (not drilled in), so
// toggling effort/fast/model re-renders this panel in place without forcing
// the parent to rebuild the menu content (which would close the dropdown).
@@ -86,12 +84,8 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
}
})
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!activeSessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error
@@ -112,38 +106,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// next session.create (see selectModel). The default lives in Settings → Model.
const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
// Explicit "Refresh Models": re-fetch the catalog with refresh:true so the
// backend busts its 1h provider-model disk cache and re-pulls each provider's
// live list. Fixes live-only models (e.g. OpenCode Zen free tier) vanishing
// when the cache expires and falls back to the curated static list.
const refreshModels = async () => {
if (refreshing) {
return
}
setRefreshing(true)
try {
const queryKey = ['model-options', activeSessionId || 'global']
const next =
gateway && activeSessionId
? await gateway.request<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
refresh: true
})
: await getGlobalModelOptions({ refresh: true })
queryClient.setQueryData<ModelOptionsResponse>(queryKey, next)
} catch {
// Network/backend hiccup — fall back to a plain invalidate so the next
// open re-fetches (still cached, but no worse than before).
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
} finally {
setRefreshing(false)
}
}
// Selecting a model row restores that model's remembered preset onto the
// session (effort/fast), gated by capability. Unset → Hermes defaults.
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
@@ -302,18 +264,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
disabled={refreshing}
onSelect={event => {
event.preventDefault()
void refreshModels()
}}
>
<Codicon className={cn('mr-1.5', refreshing && 'animate-spin')} name="sync" size="0.75rem" />
{copy.refreshModels}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}

View File

@@ -106,13 +106,6 @@ export interface SkillCommandDispatchResponse {
export interface SendCommandDispatchResponse {
type: 'send'
message: string
notice?: string
}
export interface PrefillCommandDispatchResponse {
type: 'prefill'
message: string
notice?: string
}
export type CommandDispatchResponse =
@@ -120,7 +113,6 @@ export type CommandDispatchResponse =
| AliasCommandDispatchResponse
| SkillCommandDispatchResponse
| SendCommandDispatchResponse
| PrefillCommandDispatchResponse
export type SidebarNavId = 'artifacts' | 'command-center' | 'messaging' | 'new-session' | 'settings' | 'skills'

View File

@@ -1,129 +0,0 @@
// Lists and blockquotes have chrome beside the text (markers, the quote
// border) whose side is driven by the box's CSS direction, which the
// unicode-bidi:plaintext rules never touch. These tests pin the split of
// responsibilities: ul/ol/blockquote carry dir="auto" so the browser
// resolves their box direction from content, inline code carries dir="ltr"
// so it neither votes in that resolution nor reorders, and plain prose
// blocks stay attribute-free (the plaintext CSS owns them). jsdom does not
// resolve dir="auto", so the contract is asserted at the attribute level.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Thread } from './thread'
const createdAt = new Date('2026-06-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'hi' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function Harness({ text }: { text: string }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage(text)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('block-level direction chrome', () => {
it('lists carry dir="auto" so markers follow the resolved direction', async () => {
render(<Harness text={'מקומות:\n\n1. חוף גורדון\n2. שוק הכרמל\n\n- פריט\n- item'} />)
const item = await screen.findByText(/חוף גורדון/)
expect(item.closest('ol')?.getAttribute('dir')).toBe('auto')
const bullet = await screen.findByText(/פריט/)
expect(bullet.closest('ul')?.getAttribute('dir')).toBe('auto')
})
it('blockquotes carry dir="auto" so the border follows the resolved direction', async () => {
render(<Harness text={'> ציטוט קצר בעברית'} />)
const quote = await screen.findByText(/ציטוט קצר/)
expect(quote.closest('blockquote')?.getAttribute('dir')).toBe('auto')
})
it('inline code carries dir="ltr" so it does not vote in dir="auto" resolution', async () => {
render(<Harness text={'1. `npm install` מתקין תלויות'} />)
const code = await screen.findByText('npm install')
expect(code.tagName).toBe('CODE')
expect(code.getAttribute('dir')).toBe('ltr')
expect(code.closest('ol')?.getAttribute('dir')).toBe('auto')
})
it('plain prose blocks stay attribute-free (plaintext CSS owns them)', async () => {
render(<Harness text={'שלום לכולם'} />)
const paragraph = await screen.findByText(/שלום לכולם/)
expect(paragraph.closest('p')?.hasAttribute('dir')).toBe(false)
})
})

View File

@@ -322,29 +322,13 @@ function shortLabel(type: HermesRefType, id: string): string {
return tail || id
}
function safeEmbeddedImages(text: string) {
try {
return extractEmbeddedImages(text)
} catch {
return { cleanedText: text, images: [] as string[] }
}
}
function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] {
try {
return [...hermesDirectiveFormatter.parse(text)]
} catch {
return [{ kind: 'text', text }]
}
}
/**
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
* inline chips. Embedded MEDIA images render below as a thumbnail row.
*/
export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">

View File

@@ -201,13 +201,4 @@ describe('preprocessMarkdown', () => {
expect(output).toContain('<https://example.com/a_b/c~d/page>')
})
it('handles a fenced block larger than V8 spread-argument limit', () => {
// A single huge code block (e.g. a logged minified bundle) used to throw
// `RangeError: Maximum call stack size exceeded` via `out.push(...lines)`.
const body = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join('\n')
const input = `\`\`\`js\n${body}\n\`\`\``
expect(() => preprocessMarkdown(input)).not.toThrow()
})
})

View File

@@ -19,9 +19,8 @@ import {
useState
} from 'react'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
@@ -58,11 +57,7 @@ const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
// module-scope so the prop identity is stable across renders.
function preprocessWithTailRepair(text: string): string {
try {
return tailBoundedRemend(preprocessMarkdown(text))
} catch {
return text
}
return tailBoundedRemend(preprocessMarkdown(text))
}
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
@@ -458,35 +453,8 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn(
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
)
const MAX_MARKDOWN_CHARS = 200_000
function HugeTextFallback({ containerClassName, text }: { containerClassName?: string; text: string }) {
const chunks = useMemo(() => chunkByLines(text, 200), [text])
return (
<div
className={cn(
'aui-md w-full max-w-none overflow-hidden rounded-[0.625rem] border border-border font-mono text-[0.7rem] leading-relaxed text-foreground/90',
containerClassName
)}
>
<ExpandableBlock className="p-2">
{chunks.map((chunk, index) => (
<div
className="[content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * 16}px` }}
>
{chunk.text}
</div>
))}
</ExpandableBlock>
</div>
)
}
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
const { status, text } = useMessagePartText()
const { status } = useMessagePartText()
const isStreaming = status.type === 'running'
// Keep code parsing enabled while streaming so incomplete fenced blocks still
@@ -516,37 +484,19 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
),
a: MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
// mirroring the CSS isolate that already keeps it out of the
// plaintext scan. Fenced code never reaches this override; it goes
// through the code plugin's CodeCard path.
inlineCode: ({ className, ...props }: ComponentProps<'code'>) => (
<code className={className} dir="ltr" {...props} />
),
// `---` as quiet spacing, not a heavy full-width rule.
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
// Lists and blockquotes have chrome that sits *beside* the text
// (markers, the quote border), and that side is driven by the CSS
// `direction` of the box, which `unicode-bidi: plaintext` never
// touches — an RTL list otherwise renders its numbers stranded at
// the far left. `dir="auto"` lets the browser resolve the box
// direction from content; the plaintext rules in styles.css keep
// owning per-line text direction. Inline code carries `dir="ltr"`
// (see the `code` override) so it doesn't vote here either, same
// contract as the CSS isolate.
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote
className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
dir="auto"
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
{...props}
/>
),
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('my-1 gap-0', className)} dir="auto" {...props} />
<ul className={cn('my-1 gap-0', className)} {...props} />
),
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('my-1 gap-0', className)} dir="auto" {...props} />
<ol className={cn('my-1 gap-0', className)} {...props} />
),
li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
@@ -583,10 +533,6 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
[isStreaming]
)
if (text.length > MAX_MARKDOWN_CHARS) {
return <HugeTextFallback containerClassName={containerClassName} text={text} />
}
return (
<StreamdownTextPrimitive
components={components}

View File

@@ -378,20 +378,6 @@ function IntroHarness() {
)
}
function DismissibleErrorHarness({ onDismissError }: { onDismissError: (messageId: string) => void }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantErrorMessage('OpenRouter rejected the request (403).')],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onDismissError={onDismissError} />
</AssistantRuntimeProvider>
)
}
describe('assistant-ui streaming renderer', () => {
beforeEach(() => {
resizeObservers.clear()
@@ -435,23 +421,6 @@ describe('assistant-ui streaming renderer', () => {
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
})
it('omits the dismiss control when no onDismissError handler is supplied', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
expect(screen.queryByRole('button', { name: 'Dismiss error' })).toBeNull()
})
it('invokes onDismissError with the errored message id when the dismiss control is clicked', () => {
const onDismissError = vi.fn()
render(<DismissibleErrorHarness onDismissError={onDismissError} />)
const dismiss = screen.getByRole('button', { name: 'Dismiss error' })
fireEvent.click(dismiss)
expect(onDismissError).toHaveBeenCalledTimes(1)
expect(onDismissError).toHaveBeenCalledWith('assistant-error-1')
})
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
// by the use-stick-to-bottom library and covered by its own test suite. We
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real

View File

@@ -91,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
@@ -169,7 +169,6 @@ export const Thread: FC<{
loading?: ThreadLoadingState
onBranchInNewChat?: (messageId: string) => void
onCancel?: () => Promise<void> | void
onDismissError?: (messageId: string) => void
onRestoreToMessage?: (messageId: string) => Promise<void> | void
sessionId?: string | null
sessionKey?: string | null
@@ -181,19 +180,18 @@ export const Thread: FC<{
loading,
onBranchInNewChat,
onCancel,
onDismissError,
onRestoreToMessage,
sessionId = null,
sessionKey
}) => {
const messageComponents = useMemo(
() => ({
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />,
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
SystemMessage,
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
}),
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId]
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
)
const emptyPlaceholder = intro ? (
@@ -247,13 +245,9 @@ const CenteredThreadSpinner: FC = () => {
)
}
const AssistantMessage: FC<{
onBranchInNewChat?: (messageId: string) => void
onDismissError?: (messageId: string) => void
}> = ({ onBranchInNewChat, onDismissError }) => {
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const messageId = useAuiState(s => s.message.id)
const messageRuntime = useMessageRuntime()
const { t } = useI18n()
// PERF: this component must NOT subscribe to the streaming text. Every
// selector here returns a value that stays referentially stable across
@@ -312,20 +306,10 @@ const AssistantMessage: FC<{
)}
<MessagePrimitive.Error>
<ErrorPrimitive.Root
className="mt-1.5 flex items-start gap-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
className="mt-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
role="alert"
>
<ErrorPrimitive.Message className="min-w-0 flex-1" />
{onDismissError && (
<TooltipIconButton
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
onClick={() => onDismissError(messageId)}
side="top"
tooltip={t.assistant.thread.dismissError}
>
<XIcon className="size-3.5" />
</TooltipIconButton>
)}
<ErrorPrimitive.Message />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
@@ -827,7 +811,7 @@ function StickyHumanMessageContainer({ attachments, children }: { attachments?:
// so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer.
const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-y-auto rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
@@ -859,10 +843,7 @@ const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
output
</summary>
<pre
className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55"
data-selectable-text="true"
>
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55">
{detail}
</pre>
</details>

View File

@@ -66,7 +66,7 @@ function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
'p-1.5 font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
className
)}
data-slot="code-card-body"

View File

@@ -1,52 +0,0 @@
'use client'
import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface ExpandableBlockProps {
children: ReactNode
className?: string
}
export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
const innerRef = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState(false)
const [overflowing, setOverflowing] = useState(false)
useLayoutEffect(() => {
const el = innerRef.current
if (!el) {return}
const measure = () => setOverflowing(el.scrollHeight > 121)
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [])
return (
<div className="relative">
<div
className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
ref={innerRef}
>
{children}
</div>
{overflowing && (
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
)}
</div>
)
}

View File

@@ -1,37 +0,0 @@
import { describe, expect, it } from 'vitest'
import { chunkByLines, exceedsHighlightBudget } from '@/components/chat/shiki-highlighter'
describe('exceedsHighlightBudget', () => {
it('highlights normal-sized blocks', () => {
expect(exceedsHighlightBudget('const x = 1\n'.repeat(100))).toBe(false)
})
it('skips highlighting past the line budget', () => {
expect(exceedsHighlightBudget('x\n'.repeat(5_000))).toBe(true)
})
it('skips highlighting past the char budget on few lines', () => {
expect(exceedsHighlightBudget('a'.repeat(200_000))).toBe(true)
})
it('short-circuits on char budget before line loop', () => {
expect(exceedsHighlightBudget('y\n'.repeat(250_000))).toBe(true)
})
})
describe('chunkByLines', () => {
it('keeps a small block as a single chunk', () => {
const code = 'a\nb\nc'
expect(chunkByLines(code, 200)).toEqual([{ text: code, lines: 3 }])
})
it('splits a large block and reconstructs it losslessly', () => {
const code = Array.from({ length: 1000 }, (_, i) => `line ${i}`).join('\n')
const chunks = chunkByLines(code, 200)
expect(chunks).toHaveLength(5)
expect(chunks.map(chunk => chunk.text).join('\n')).toBe(code)
expect(chunks.reduce((sum, chunk) => sum + chunk.lines, 0)).toBe(1000)
})
})

View File

@@ -1,7 +1,7 @@
'use client'
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
import { type FC, useMemo } from 'react'
import type { FC } from 'react'
import ShikiHighlighter from 'react-shiki'
import {
@@ -12,7 +12,6 @@ import {
CodeCardSubtitle,
CodeCardTitle
} from '@/components/chat/code-card'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
@@ -44,74 +43,6 @@ const SHIKI_COLOR_REPLACEMENTS: Record<string, Record<string, string>> = {
'github-light-default': { '#6e7781': '#57606a' }
}
const MAX_HIGHLIGHT_CHARS = 150_000
const MAX_HIGHLIGHT_LINES = 3_000
const CHUNK_LINES = 200
const EST_LINE_PX = 16
export function exceedsHighlightBudget(code: string): boolean {
if (code.length > MAX_HIGHLIGHT_CHARS) {
return true
}
let lines = 1
let idx = code.indexOf('\n')
while (idx !== -1) {
if ((lines += 1) > MAX_HIGHLIGHT_LINES) {
return true
}
idx = code.indexOf('\n', idx + 1)
}
return false
}
interface CodeChunk {
text: string
lines: number
}
export function chunkByLines(code: string, perChunk: number): CodeChunk[] {
const lines = code.split('\n')
if (lines.length <= perChunk) {
return [{ text: code, lines: lines.length }]
}
const chunks: CodeChunk[] = []
for (let i = 0; i < lines.length; i += perChunk) {
const slice = lines.slice(i, i + perChunk)
chunks.push({ text: slice.join('\n'), lines: slice.length })
}
return chunks
}
const PlainCode: FC<{ code: string }> = ({ code }) => {
const chunks = useMemo(() => chunkByLines(code, CHUNK_LINES), [code])
if (chunks.length === 1) {
return <code className="block whitespace-pre">{code}</code>
}
return (
<>
{chunks.map((chunk, index) => (
<code
className="block whitespace-pre [content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * EST_LINE_PX}px` }}
>
{chunk.text}
</code>
))}
</>
)
}
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
components: { Pre },
language,
@@ -133,7 +64,6 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
const cleanLanguage = sanitizeLanguageTag(language || '')
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
const plain = defer || exceedsHighlightBudget(trimmed)
return (
<CodeCard data-streaming={defer ? 'true' : undefined}>
@@ -153,26 +83,24 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
/>
</CodeCardHeader>
<CodeCardBody>
<ExpandableBlock>
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
{plain ? (
<PlainCode code={trimmed} />
) : (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
defaultColor="light-dark()"
delay={120}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{trimmed}
</ShikiHighlighter>
)}
</Pre>
</ExpandableBlock>
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
{defer ? (
<code className="block whitespace-pre">{trimmed}</code>
) : (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
defaultColor="light-dark()"
delay={120}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{trimmed}
</ShikiHighlighter>
)}
</Pre>
</CodeCardBody>
</CodeCard>
)

View File

@@ -41,11 +41,7 @@ export function TerminalOutput({ className, text }: TerminalOutputProps) {
}, [text])
return (
<div
className={cn('max-h-16 overflow-auto overscroll-contain', className)}
data-selectable-text="true"
ref={ref}
>
<div className={cn('max-h-16 overflow-auto overscroll-contain', className)} ref={ref}>
<pre className="w-max min-w-full font-mono text-[0.5625rem] leading-[0.85rem] whitespace-pre text-muted-foreground/70">
{text}
</pre>

View File

@@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useI18n } from '@/i18n'
import { currentPickerSelection } from '@/lib/model-status-label'
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
@@ -67,13 +66,8 @@ export function ModelPickerDialog({
})
const providers = modelOptions.data?.providers ?? []
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!sessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error

View File

@@ -154,10 +154,7 @@ function NotificationDetail({ detail }: { detail: string }) {
<details className="mt-2 text-xs text-muted-foreground">
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
<div className="mt-1 rounded-md bg-background/65 p-2">
<pre
className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed"
data-selectable-text="true"
>
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
{detail}
</pre>
<CopyButton

View File

@@ -0,0 +1,291 @@
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, setPetInfo } from '@/store/pet'
import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay'
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<Point>(loadPosition)
const containerRef = useRef<HTMLDivElement | null>(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<HTMLDivElement | null>(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 <slug>` 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<PetInfo>('pet.info')
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])
// 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 (
<div
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
ref={containerRef}
style={{
cursor: 'grab',
left: position.x,
pointerEvents: 'auto',
position: 'fixed',
top: position.y,
touchAction: 'none',
userSelect: 'none',
zIndex: 60
}}
>
<div
aria-hidden
style={{
background: `radial-gradient(ellipse at center, rgba(0,0,0,${shadowAlpha}) 0%, rgba(0,0,0,0) 70%)`,
bottom: -shadowH * 0.4,
height: shadowH,
left: '50%',
pointerEvents: 'none',
position: 'absolute',
transform: 'translateX(-50%)',
width: shadowW,
zIndex: 0
}}
/>
<div ref={spriteWrapRef} style={{ lineHeight: 0, position: 'relative', transform: facing(position.x, petW), zIndex: 1 }}>
<PetSprite info={info} />
</div>
</div>
)
}

View File

@@ -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<Record<PetState, Spec>> = {
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', 'balls in your court', 'awaiting orders'],
tone: 'wait'
}
}
const TONE_COLOR: Record<Tone, string> = {
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 (
<div
style={{
alignItems: 'center',
// Solid, theme-driven surface (the prior --ui-bg-card mixes in
// `transparent`, so the bubble was see-through).
background: 'var(--ui-bg-elevated)',
border: '1px solid var(--ui-stroke-secondary)',
borderRadius: hasText ? 10 : 999,
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
color: 'var(--foreground)',
display: 'inline-flex',
fontSize: 11,
fontWeight: 500,
gap: hasText ? 5 : 0,
lineHeight: 1,
// Glyph-only bubbles collapse to a tight, symmetric badge.
padding: hasText ? '5px 8px' : 5,
pointerEvents: 'none',
whiteSpace: 'nowrap'
}}
>
{Glyph && (
<span style={{ display: 'inline-flex' }}>
<Glyph style={{ color: spec.tone ? TONE_COLOR[spec.tone] : 'currentColor', height: 13, width: 13 }} />
</span>
)}
{text}
</div>
)
}

View File

@@ -0,0 +1,221 @@
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<PetState, string[]> = {
idle: ['idle'],
wave: ['wave', 'waving'],
jump: ['jump', 'jumping'],
run: ['run', 'running'],
failed: ['failed'],
review: ['review'],
waiting: ['waiting']
}
const ROW_TO_STATE: Record<string, PetState> = {
idle: 'idle',
wave: 'wave',
waving: 'wave',
jump: 'jump',
jumping: 'jump',
run: 'run',
running: 'run',
'running-right': 'run',
'running-left': 'run',
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
/**
* Force a specific animation state instead of reading the live `$petState`.
* Used by the generate-flow preview to showcase every row without driving (or
* being driven by) the real agent activity that moves the floating mascot.
*/
stateOverride?: PetState
/** Force a concrete row name from `info.stateRows` (e.g. `running-right`). */
rowOverride?: string
}
/**
* 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, stateOverride, rowOverride }: PetSpriteProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const stateRef = useRef<PetState>($petState.get())
const overrideRef = useRef<PetState | undefined>(stateOverride)
const rowOverrideRef = useRef<string | undefined>(rowOverride)
// Keep the override current without re-running the RAF setup effect.
useEffect(() => {
overrideRef.current = stateOverride
}, [stateOverride])
useEffect(() => {
rowOverrideRef.current = rowOverride
}, [rowOverride])
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 resolveRow = (rowName: string): { row: number; count: number } => {
const row = rows.indexOf(rowName)
const state = ROW_TO_STATE[rowName]
const count = Math.max(1, framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames)
return { row: row >= 0 ? row : rowIndexForState(state ?? 'idle'), count }
}
const render = (now: number) => {
const forcedRow = rowOverrideRef.current
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? 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 (
<canvas
aria-label={info.displayName ? `${info.displayName} pet` : 'pet'}
height={drawH}
ref={canvasRef}
style={{ height: drawH, width: drawW }}
width={drawW}
/>
)
}
/**
* 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)

View File

@@ -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<string | null>
/**
* 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 `<img src=cdn>`.
*/
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<string | null>(null)
const boxRef = useRef<HTMLSpanElement | null>(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 (
<span
className="grid shrink-0 place-items-center overflow-hidden rounded-md bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)"
ref={boxRef}
style={{ height, width: size }}
>
{src ? (
<img
alt={alt}
aria-hidden
className="pointer-events-none size-full object-contain"
src={src}
style={{ imageRendering: 'pixelated' }}
/>
) : (
<PawPrint className="size-4" />
)}
</span>
)
}

Some files were not shown because too many files have changed in this diff Show More