Compare commits

..
22 Commits
Author SHA1 Message Date
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 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
317 changed files with 9306 additions and 23789 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

-1
View File
@@ -5,7 +5,6 @@
*.pyc*
__pycache__/
.venv/
.venv
.vscode/
.env
.env.local
+25 -32
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
-10
View File
@@ -599,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
@@ -1156,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:
@@ -1227,12 +1223,6 @@ 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
+13 -27
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:
+11 -32
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
-18
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
):
-295
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)
+2 -67
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
-10
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]}"
+2 -28
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,
@@ -3756,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({
+3 -84
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()
+7 -23
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)
-5
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
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
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
+128
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
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
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
+316
View File
@@ -0,0 +1,316 @@
"""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
from dataclasses import dataclass
from pathlib import Path
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
_DOWNLOAD_TIMEOUT = 60.0
class PetStoreError(RuntimeError):
"""Raised on install/IO failures."""
@dataclass(frozen=True)
class InstalledPet:
"""A pet present on disk."""
slug: str
display_name: str
description: str
directory: Path
spritesheet: Path
@property
def exists(self) -> bool:
return self.spritesheet.is_file()
def pets_dir() -> Path:
"""Return the profile-scoped pets directory (created on demand)."""
path = get_hermes_home() / "pets"
path.mkdir(parents=True, exist_ok=True)
return path
def _read_pet_json(directory: Path) -> dict:
pet_json = directory / "pet.json"
if not pet_json.is_file():
return {}
try:
return json.loads(pet_json.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.debug("unreadable pet.json in %s: %s", directory, exc)
return {}
def _resolve_spritesheet(directory: Path, meta: dict) -> Path:
"""Find the spritesheet for a pet dir.
Honors ``spritesheetPath`` from pet.json, else probes the conventional
filenames (``spritesheet.{webp,png}`` and petdex R2's ``sprite.webp``).
"""
declared = str(meta.get("spritesheetPath", "") or "").strip()
if declared:
candidate = directory / declared
if candidate.is_file():
return candidate
for name in ("spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"):
candidate = directory / name
if candidate.is_file():
return candidate
# Default expectation even if missing, so callers get a stable path.
return directory / "spritesheet.webp"
def load_pet(slug: str) -> InstalledPet | None:
"""Return the :class:`InstalledPet` for *slug*, or ``None`` if absent."""
slug = slug.strip()
directory = pets_dir() / slug
if not directory.is_dir():
return None
meta = _read_pet_json(directory)
return InstalledPet(
slug=slug,
display_name=str(meta.get("displayName", "") or slug),
description=str(meta.get("description", "") or ""),
directory=directory,
spritesheet=_resolve_spritesheet(directory, meta),
)
def installed_pets() -> list[InstalledPet]:
"""Return every installed pet (dirs containing a usable spritesheet)."""
out: list[InstalledPet] = []
for child in sorted(pets_dir().iterdir()):
if not child.is_dir():
continue
pet = load_pet(child.name)
if pet and pet.exists:
out.append(pet)
return out
def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None:
"""Resolve which pet to display.
Precedence: the configured slug (``display.pet.slug``) if it's installed,
otherwise the first installed pet alphabetically, otherwise ``None``.
"""
if configured_slug:
pet = load_pet(configured_slug.strip())
if pet and pet.exists:
return pet
pets = installed_pets()
return pets[0] if pets else None
def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet:
"""Download *slug* from the manifest into the pets directory.
Idempotent: a fully-installed pet is returned as-is unless *force*. Raises
:class:`PetStoreError` / :class:`~agent.pet.manifest.ManifestError` on
failure.
"""
from agent.pet.manifest import find_entry
slug = slug.strip()
existing = load_pet(slug)
if existing and existing.exists and not force:
return existing
entry = find_entry(slug, timeout=timeout)
if entry is None:
raise PetStoreError(f"pet '{slug}' is not in the petdex manifest")
directory = pets_dir() / slug
directory.mkdir(parents=True, exist_ok=True)
sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp"
sprite_path = directory / f"spritesheet{sprite_ext}"
_download(entry.spritesheet_url, sprite_path, timeout=timeout)
# Fetch the upstream pet.json if present; otherwise synthesize a minimal
# one so the local layout is self-describing.
meta: dict = {}
if entry.pet_json_url:
try:
meta = _download_json(entry.pet_json_url, timeout=timeout)
except Exception as exc: # noqa: BLE001 - non-fatal, fall back below
logger.debug("pet.json fetch failed for %s: %s", slug, exc)
if not isinstance(meta, dict) or not meta:
meta = {"id": slug, "displayName": entry.display_name, "description": ""}
meta["spritesheetPath"] = sprite_path.name
meta.setdefault("id", slug)
meta.setdefault("displayName", entry.display_name)
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
pet = load_pet(slug)
if pet is None or not pet.exists:
raise PetStoreError(f"install of '{slug}' did not produce a spritesheet")
return pet
_THUMB_FRAME_W = 192
_THUMB_FRAME_H = 208
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
def _thumbs_dir() -> Path:
path = pets_dir() / ".thumbs"
path.mkdir(parents=True, exist_ok=True)
return path
def _is_petdex_host(url: str) -> bool:
"""True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF)."""
from urllib.parse import urlparse
try:
host = (urlparse(url).hostname or "").lower()
except ValueError:
return False
return host == "petdex.dev" or host.endswith(".petdex.dev")
def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None:
"""Return a small idle-frame PNG for *slug*, cached on disk.
Crops the top-left (idle, frame 0) cell of the spritesheet and downsamples
it to a thumbnail. Source preference: an installed spritesheet on disk, else
*source_url* but only when it points at petdex (so the gateway never
fetches an arbitrary client-supplied URL). Returns ``None`` when there's no
usable source or Pillow/network fails; callers render a placeholder.
Doing this server-side sidesteps the renderer's CSP / R2 hotlink limits that
break a direct ``<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 {}
+31 -143
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)
+2 -26
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,
@@ -84,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] = []
@@ -103,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
@@ -124,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:
@@ -357,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)
+13 -27
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,
+11 -19
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,
+4 -81
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:
-2
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,
@@ -122,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
@@ -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!(
+284 -22
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')
}
+26
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),
-24
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
+1 -23
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)
})
-29
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 }
@@ -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')
})
+5 -4
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",
@@ -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",
@@ -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));
@@ -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)
+3 -40
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} />
+64 -34
View File
@@ -29,6 +29,7 @@ import {
Moon,
Package,
Palette,
PawPrint,
Plus,
Settings,
Settings2,
@@ -39,7 +40,7 @@ import {
Zap
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
@@ -62,6 +63,7 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
import { MarketplaceThemePage } from './marketplace-theme-page'
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
interface PaletteItem {
/** Keybind action id — its live combo renders as a hotkey hint. */
@@ -205,6 +207,7 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
const pendingPage = useStore($commandPalettePage)
const bindings = useStore($bindings)
const navigate = useNavigate()
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
@@ -250,6 +253,14 @@ export function CommandPalette() {
}
}, [open])
// Deep-link into a nested page (e.g. `/pet list` → pets picker).
useEffect(() => {
if (open && pendingPage) {
setPage(pendingPage)
$commandPalettePage.set(null)
}
}, [open, pendingPage])
const go = useCallback((path: string) => () => navigate(path), [navigate])
// Step up one nested page (or back to the root list), clearing the filter so
@@ -382,6 +393,13 @@ export function CommandPalette() {
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
label: cc.changeColorMode,
to: 'color-mode'
},
{
icon: PawPrint,
id: 'appearance-pets',
keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'],
label: cc.pets.title,
to: 'pets'
}
]
},
@@ -550,6 +568,12 @@ export function CommandPalette() {
}
]
},
// Server-driven page: browse petdex gallery, adopt/switch, toggle off.
pets: {
title: t.commandCenter.pets.title,
placeholder: t.commandCenter.pets.placeholder,
groups: []
},
// Server-driven page: items come from the Marketplace, rendered by
// <MarketplaceThemePage> (loader + live search + per-row install).
'install-theme': {
@@ -624,45 +648,51 @@ export function CommandPalette() {
}}
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 === 'pets' ? (
<PetPalettePage 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>
@@ -0,0 +1,185 @@
/**
* Cmd-K "Pets…" page browse the petdex gallery, adopt/switch, toggle off.
*
* A thin view over the `pet-gallery` store: it subscribes to the shared atoms
* and calls the store's actions. The store owns fetching, caching, the thumb
* cache, and optimistic mutations, so reopening this page is instant and a
* toggle never re-pulls the network gallery.
*/
import { useStore } from '@nanostores/react'
import { useEffect, useMemo } from 'react'
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Loader2, PawPrint } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$petBusy,
$petGallery,
$petGalleryError,
$petGalleryStatus,
adoptPet,
loadPetGallery,
loadPetThumb,
rankedGalleryPets,
setPetEnabled
} from '@/store/pet-gallery'
interface PetPalettePageProps {
search: string
}
export function PetPalettePage({ search }: PetPalettePageProps) {
const { t } = useI18n()
const copy = t.commandCenter.pets
const { requestGateway } = useGatewayRequest()
const gallery = useStore($petGallery)
const status = useStore($petGalleryStatus)
const error = useStore($petGalleryError)
const busy = useStore($petBusy)
useEffect(() => {
void loadPetGallery(requestGateway)
}, [requestGateway])
const enabled = gallery?.enabled ?? false
const active = gallery?.active ?? ''
const shown = useMemo(() => rankedGalleryPets(gallery, search).slice(0, 50), [gallery, search])
const adopt = (slug: string) => {
void adoptPet(requestGateway, slug, copy.adoptFailed).then(ok => ok && triggerHaptic('crisp'))
}
if (status === 'loading' && !gallery) {
return <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">
{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>
)
}
+51 -53
View File
@@ -13,7 +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 { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
@@ -38,6 +38,8 @@ import {
unpinSession
} from '../store/layout'
import { respondToApprovalAction } from '../store/native-notifications'
import { setPetActivity } from '../store/pet'
import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeGatewayProfile,
@@ -49,13 +51,11 @@ import {
} from '../store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$currentCwd,
$freshDraftReady,
$gatewayState,
$messages,
$messagingSessions,
$resumeFailedSessionId,
$resumeExhaustedSessionId,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
@@ -202,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)
@@ -741,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()
@@ -834,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 => {
@@ -893,8 +895,6 @@ export function DesktopController() {
gatewayState,
locationPathname: location.pathname,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
@@ -1044,7 +1044,6 @@ export function DesktopController() {
void removeSession(selectedStoredSessionId)
}
}}
onDismissError={dismissError}
onEdit={editMessage}
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
onPickFiles={() => void composer.pickContextPaths('file')}
@@ -1053,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}
@@ -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>
)
}
@@ -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>
)
}
@@ -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
@@ -27,6 +27,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import { openCommandPalettePage } from '@/store/command-palette'
import {
$composerAttachments,
clearComposerAttachments,
@@ -38,6 +39,7 @@ import {
import { resetSessionBackground } from '@/store/composer-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { setPetScale } from '@/store/pet-gallery'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
@@ -57,8 +59,8 @@ import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import type {
ClientSessionState,
BrowserManageResponse,
ClientSessionState,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
@@ -1143,6 +1145,35 @@ export function usePromptActions({
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
pet: async ctx => {
const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/)
const lower = sub.toLowerCase()
if (lower === 'list' || lower === 'gallery' || lower === 'browse' || lower === 'all') {
openCommandPalettePage('pets')
return
}
// `/pet scale <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
@@ -1359,6 +1390,7 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
const releaseBusy = () => {
setMutableRef(busyRef, false)
setBusy(false)
@@ -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()
})
})
@@ -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
])
}
@@ -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()
})
})
@@ -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()) {
@@ -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)
})
})
@@ -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)
@@ -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>
)
}
@@ -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()
@@ -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">
@@ -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>
)
}
+5
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>
)
}
@@ -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 {
@@ -84,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
@@ -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)
})
})
@@ -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">
@@ -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()
})
})
@@ -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}
@@ -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
@@ -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'
@@ -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"
@@ -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>
)
}
@@ -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)
})
})
@@ -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>
)
+2 -8
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
@@ -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>
)
}
@@ -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>
)
}
@@ -0,0 +1,178 @@
import { memo, useEffect, useMemo, useRef } from 'react'
import { $petState, type PetInfo, type PetState } from '@/store/pet'
const DEFAULT_FRAME_W = 192
const DEFAULT_FRAME_H = 208
const DEFAULT_FRAMES = 6
const DEFAULT_LOOP_MS = 1100
// Mirrors agent.pet.constants.DEFAULT_SCALE — fallback only; the gateway sends
// the configured scale.
const DEFAULT_SCALE = 0.33
// Mirrors agent.pet.constants.CODEX_STATE_ROWS (Petdex current taxonomy).
const DEFAULT_STATE_ROWS = [
'idle',
'running-right',
'running-left',
'waving',
'jumping',
'failed',
'waiting',
'running',
'review'
]
const STATE_ALIASES: Record<PetState, string[]> = {
idle: ['idle'],
wave: ['wave', 'waving'],
jump: ['jump', 'jumping'],
run: ['run', 'running'],
failed: ['failed'],
review: ['review'],
waiting: ['waiting']
}
interface PetSpriteProps {
info: PetInfo
/** On-screen scale multiplier applied on top of the pet's native scale. */
zoom?: number
}
/**
* Canvas renderer for a petdex spritesheet the one piece that must be
* TypeScript (the engine's decode/encode is Python). Draws the row matching the
* live `$petState`, stepping `framesPerState` frames across a `loopMs` loop.
*
* State is read from `$petState` via a ref + subscription rather than a prop,
* so the frequent activity-driven state changes during an agent turn update the
* canvas (inside its RAF loop) WITHOUT triggering a React re-render. Combined
* with `memo`, this component effectively never re-renders after mount until
* the pet itself changes.
*/
function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const stateRef = useRef<PetState>($petState.get())
const frameW = info.frameW ?? DEFAULT_FRAME_W
const frameH = info.frameH ?? DEFAULT_FRAME_H
const frames = info.framesPerState ?? DEFAULT_FRAMES
const framesByState = info.framesByState
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
const drawW = Math.round(frameW * scale)
const drawH = Math.round(frameH * scale)
const image = useMemo(() => {
if (!info.spritesheetBase64) {
return null
}
const img = new Image()
img.src = `data:${info.mime ?? 'image/webp'};base64,${info.spritesheetBase64}`
return img
}, [info.spritesheetBase64, info.mime])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !image) {
return
}
const ctx = canvas.getContext('2d')
if (!ctx) {
return
}
// Track state via subscription, not a prop — no re-render on activity ticks.
stateRef.current = $petState.get()
const unsubState = $petState.listen(next => {
stateRef.current = next
})
let raf = 0
let frame = 0
let lastStep = performance.now()
let drawnFrame = -1
let drawnRow = -1
const rowIndexForState = (s: PetState): number => {
for (const key of STATE_ALIASES[s] ?? [s]) {
const idx = rows.indexOf(key)
if (idx >= 0) {
return idx
}
}
return 0
}
// Resolve a state to the row it draws and its real frame count. A state
// with no real frames (ragged sheet, empty row) falls back to idle rather
// than flashing blank padding.
const resolve = (s: PetState): { row: number; count: number } => {
const real = framesByState?.[s] ?? frames
if (real > 0) {
return { row: rowIndexForState(s), count: real }
}
return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) }
}
const render = (now: number) => {
const { row, count } = resolve(stateRef.current)
// Per-state step keeps every state's loop ~loopMs even when frame counts
// differ; counts vary per row so derive the cadence here, not once.
const stepMs = loopMs / count
if (now - lastStep >= stepMs) {
frame += 1
lastStep = now
}
frame %= count
// Only touch the canvas when the visible cell actually changes. The RAF
// ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of
// the clear+draw work and keeps the main thread free.
if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) {
const sx = frame * frameW
const sy = row * frameH
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.imageSmoothingEnabled = false
ctx.drawImage(image, sx, sy, frameW, frameH, 0, 0, drawW, drawH)
drawnFrame = frame
drawnRow = row
}
raf = requestAnimationFrame(render)
}
raf = requestAnimationFrame(render)
return () => {
cancelAnimationFrame(raf)
unsubState()
}
}, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows])
return (
<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)
@@ -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>
)
}
+7 -1
View File
@@ -17,7 +17,12 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
)
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
interface CommandInputProps extends React.ComponentProps<typeof CommandPrimitive.Input> {
/** Inline trailing slot, rendered on the right of the search row. */
right?: React.ReactNode
}
function CommandInput({ className, right, ...props }: CommandInputProps) {
return (
<div className="flex h-11 items-center gap-2 border-b border-border px-3" data-slot="command-input-wrapper">
<SearchIcon className="size-4 shrink-0 text-muted-foreground" />
@@ -29,6 +34,7 @@ function CommandInput({ className, ...props }: React.ComponentProps<typeof Comma
data-slot="command-input"
{...props}
/>
{right}
</div>
)
}
+21
View File
@@ -1,3 +1,10 @@
import type {
PetOverlayBounds,
PetOverlayControl,
PetOverlayOpenRequest,
PetOverlayStatePayload
} from './store/pet-overlay'
export {}
declare global {
@@ -26,6 +33,20 @@ declare global {
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
// Open (or focus) a compact secondary window on the new-session draft.
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
// The pop-out pet overlay: a transparent always-on-top window hosting only
// the mascot. The main renderer drives it (open/close/drag + state push);
// the overlay sends control messages back (pop-in, composer submit).
petOverlay: {
open: (request: PetOverlayOpenRequest) => Promise<{ ok: boolean; bounds?: PetOverlayBounds }>
close: () => Promise<{ ok: boolean }>
setBounds: (bounds: PetOverlayBounds) => void
setIgnoreMouse: (ignore: boolean) => void
setFocusable: (focusable: boolean) => void
pushState: (payload: PetOverlayStatePayload) => void
control: (payload: PetOverlayControl) => void
onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void
onControl: (callback: (payload: PetOverlayControl) => void) => () => void
}
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
+41 -10
View File
@@ -366,7 +366,32 @@ export const en: Translations = {
installError: 'Could not install that theme.',
installed: name => `Installed “${name}”.`,
removeTheme: 'Remove theme',
importedBadge: 'Imported'
importedBadge: 'Imported',
pet: {
title: 'Pet',
intro:
'Adopt an animated petdex mascot that floats over the app and reacts to what Hermes is doing — running while tools execute, celebrating on success, sulking on errors.',
restartHint:
'Pets need a quick restart — the running app started before this feature was added. Quit and reopen Hermes, then come back here.',
on: 'On',
off: 'Off',
scaleTitle: 'Size',
scaleDesc: 'Resize the floating mascot. Applies everywhere instantly.',
chooseTitle: 'Choose a pet',
chooseDesc: 'Picking one installs it (if needed) and makes it active.',
searchPlaceholder: 'Search pets…',
unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.",
noMatch: query => `No pets match "${query}".`,
installedTag: 'installed',
countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`,
count: n => `${n} pet${n === 1 ? '' : 's'}.`,
uninstall: name => `Uninstall ${name}`,
adoptFailed: slug => `Could not adopt ${slug}`,
uninstallFailed: slug => `Could not uninstall ${slug}`,
noneAvailable: 'No pets available to turn on right now.',
turnOnFailed: 'Could not turn the pet on.',
turnOffFailed: 'Could not turn the pet off.'
}
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
@@ -538,10 +563,6 @@ export const en: Translations = {
provider: 'Provider',
model: 'Model',
applying: 'Applying...',
defaultsLabel: 'Defaults',
reasoning: 'Reasoning',
reasoningOff: 'Off',
defaultsFailed: 'Failed to save model defaults',
auxiliaryTitle: 'Auxiliary models',
resetAllToMain: 'Reset all to main',
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
@@ -714,8 +735,22 @@ export const en: Translations = {
commandCenter: 'Command Center',
appearance: 'Appearance',
settings: 'Settings',
changeTheme: 'Change theme...',
changeTheme: 'Change theme',
changeColorMode: 'Change color mode...',
pets: {
title: 'Pets',
placeholder: 'Search pets…',
loading: 'Loading petdex gallery…',
error: 'Could not reach the petdex gallery.',
staleBackend: 'Restart Hermes to use pets — the backend predates this feature.',
empty: 'No matching pets.',
turnOff: 'Turn off',
turnOn: 'Turn on',
installed: 'Installed',
adoptFailed: 'Could not adopt that pet.',
toggleFailed: 'Could not toggle the pet.',
noneAvailable: 'No pets available — pick one below to install.'
},
installTheme: {
title: 'Install theme...',
placeholder: 'Search the VS Code Marketplace...',
@@ -1733,7 +1768,6 @@ export const en: Translations = {
refresh: 'Refresh',
moreActions: 'More actions',
branchNewChat: 'Branch in new chat',
dismissError: 'Dismiss error',
readAloudFailed: 'Read aloud failed',
preparingAudio: 'Preparing audio...',
stopReading: 'Stop reading',
@@ -1843,9 +1877,6 @@ export const en: Translations = {
regenerateFailed: 'Regenerate failed',
editFailed: 'Edit failed',
resumeFailed: 'Resume failed',
resumeStrandedTitle: "Couldn't load this session",
resumeStrandedBody: 'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
resumeRetry: 'Retry',
nothingToBranch: 'Nothing to branch',
branchNeedsChat: 'Start or resume a chat before branching.',
sessionBusy: 'Session busy',
+41 -6
View File
@@ -281,7 +281,32 @@ export const ja = defineLocale({
installError: 'そのテーマをインストールできませんでした。',
installed: name => `${name}」をインストールしました。`,
removeTheme: 'テーマを削除',
importedBadge: 'インポート済み'
importedBadge: 'インポート済み',
pet: {
title: 'ペット',
intro:
'アプリ上に浮かぶ petdex のアニメーションマスコットを採用しましょう。ツール実行中は走り、成功すると喜び、エラーでしょんぼりと、Hermes の状態に反応します。',
restartHint:
'ペット機能には再起動が必要です。この機能が追加される前に起動したアプリが動作中です。Hermes を終了して再度開き、このページに戻ってください。',
scaleTitle: 'サイズ',
scaleDesc: '浮遊マスコットの大きさを変更します。すべての画面に即時反映されます。',
on: 'オン',
off: 'オフ',
chooseTitle: 'ペットを選ぶ',
chooseDesc: '選ぶと(必要に応じて)インストールされ、アクティブになります。',
searchPlaceholder: 'ペットを検索…',
unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。',
noMatch: query => `${query}」に一致するペットがありません。`,
installedTag: 'インストール済み',
countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`,
count: n => `${n} 件のペット。`,
uninstall: name => `${name} をアンインストール`,
adoptFailed: slug => `${slug} を採用できませんでした`,
uninstallFailed: slug => `${slug} をアンインストールできませんでした`,
noneAvailable: 'オンにできるペットがありません。',
turnOnFailed: 'ペットをオンにできませんでした。',
turnOffFailed: 'ペットをオフにできませんでした。'
}
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',
@@ -834,8 +859,22 @@ export const ja = defineLocale({
commandCenter: 'コマンドセンター',
appearance: '外観',
settings: '設定',
changeTheme: 'テーマを変更...',
changeTheme: 'テーマを変更',
changeColorMode: 'カラーモードを変更...',
pets: {
title: 'ペット',
placeholder: 'ペットを検索…',
loading: 'petdex ギャラリーを読み込み中…',
error: 'petdex ギャラリーに接続できません。',
staleBackend: 'ペット機能を使うには Hermes を再起動してください。',
empty: '一致するペットがありません。',
turnOff: 'オフ',
turnOn: 'オン',
installed: 'インストール済み',
adoptFailed: 'ペットを採用できませんでした。',
toggleFailed: 'ペットを切り替えできませんでした。',
noneAvailable: '利用可能なペットがありません。'
},
installTheme: {
title: 'テーマをインストール...',
placeholder: 'VS Code Marketplace を検索...',
@@ -1864,7 +1903,6 @@ export const ja = defineLocale({
refresh: '更新',
moreActions: 'その他のアクション',
branchNewChat: '新しいチャットでブランチ',
dismissError: 'エラーを閉じる',
readAloudFailed: '読み上げに失敗しました',
preparingAudio: '音声を準備中...',
stopReading: '読み上げを停止',
@@ -1974,9 +2012,6 @@ export const ja = defineLocale({
regenerateFailed: '再生成に失敗しました',
editFailed: '編集に失敗しました',
resumeFailed: '再開に失敗しました',
resumeStrandedTitle: 'このセッションを読み込めませんでした',
resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
resumeRetry: '再試行',
nothingToBranch: 'ブランチするものがありません',
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
sessionBusy: 'セッションが使用中',
+37 -8
View File
@@ -265,6 +265,29 @@ export interface Translations {
installed: (name: string) => string
removeTheme: string
importedBadge: string
pet: {
title: string
intro: string
restartHint: string
on: string
off: string
scaleTitle: string
scaleDesc: string
chooseTitle: string
chooseDesc: string
searchPlaceholder: string
unreachable: string
noMatch: (query: string) => string
installedTag: string
countCapped: (cap: number, total: number) => string
count: (n: number) => string
uninstall: (name: string) => string
adoptFailed: (slug: string) => string
uninstallFailed: (slug: string) => string
noneAvailable: string
turnOnFailed: string
turnOffFailed: string
}
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
@@ -430,10 +453,6 @@ export interface Translations {
provider: string
model: string
applying: string
defaultsLabel: string
reasoning: string
reasoningOff: string
defaultsFailed: string
auxiliaryTitle: string
resetAllToMain: string
auxiliaryDesc: string
@@ -594,6 +613,20 @@ export interface Translations {
settings: string
changeTheme: string
changeColorMode: string
pets: {
title: string
placeholder: string
loading: string
error: string
staleBackend: string
empty: string
turnOff: string
turnOn: string
installed: string
adoptFailed: string
toggleFailed: string
noneAvailable: string
}
installTheme: {
title: string
placeholder: string
@@ -1373,7 +1406,6 @@ export interface Translations {
refresh: string
moreActions: string
branchNewChat: string
dismissError: string
readAloudFailed: string
preparingAudio: string
stopReading: string
@@ -1481,9 +1513,6 @@ export interface Translations {
regenerateFailed: string
editFailed: string
resumeFailed: string
resumeStrandedTitle: string
resumeStrandedBody: string
resumeRetry: string
nothingToBranch: string
branchNeedsChat: string
sessionBusy: string
+39 -6
View File
@@ -271,7 +271,30 @@ export const zhHant = defineLocale({
installError: '無法安裝該主題。',
installed: name => `已安裝「${name}」。`,
removeTheme: '移除主題',
importedBadge: '已匯入'
importedBadge: '已匯入',
pet: {
title: '寵物',
intro: '領養一隻懸浮在應用上的 petdex 動畫寵物,它會根據 Hermes 的狀態做出反應——工具執行時奔跑、成功時歡呼、出錯時沮喪。',
restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes,然後回到此處。',
scaleTitle: '大小',
scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。',
on: '開啟',
off: '關閉',
chooseTitle: '選擇寵物',
chooseDesc: '選擇後會自動安裝(如需)並設為目前寵物。',
searchPlaceholder: '搜尋寵物…',
unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。',
noMatch: query => `沒有符合「${query}」的寵物。`,
installedTag: '已安裝',
countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`,
count: n => `${n} 個寵物。`,
uninstall: name => `解除安裝 ${name}`,
adoptFailed: slug => `無法領養 ${slug}`,
uninstallFailed: slug => `無法解除安裝 ${slug}`,
noneAvailable: '目前沒有可開啟的寵物。',
turnOnFailed: '無法開啟寵物。',
turnOffFailed: '無法關閉寵物。'
}
},
fieldLabels: defineFieldCopy({
model: '預設模型',
@@ -807,8 +830,22 @@ export const zhHant = defineLocale({
commandCenter: '命令中心',
appearance: '外觀',
settings: '設定',
changeTheme: '變更主題...',
changeTheme: '變更主題',
changeColorMode: '變更色彩模式...',
pets: {
title: '寵物',
placeholder: '搜尋寵物…',
loading: '正在載入 petdex 畫廊…',
error: '無法連線至 petdex 畫廊。',
staleBackend: '請重新啟動 Hermes 以使用寵物功能。',
empty: '沒有符合的寵物。',
turnOff: '關閉',
turnOn: '開啟',
installed: '已安裝',
adoptFailed: '無法領養該寵物。',
toggleFailed: '無法切換寵物顯示。',
noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。'
},
installTheme: {
title: '安裝主題...',
placeholder: '搜尋 VS Code Marketplace...',
@@ -1806,7 +1843,6 @@ export const zhHant = defineLocale({
refresh: '重新整理',
moreActions: '更多動作',
branchNewChat: '在新聊天中分支',
dismissError: '关闭错误',
readAloudFailed: '朗讀失敗',
preparingAudio: '正在準備音訊...',
stopReading: '停止朗讀',
@@ -1914,9 +1950,6 @@ export const zhHant = defineLocale({
regenerateFailed: '重新生成失敗',
editFailed: '編輯失敗',
resumeFailed: '繼續失敗',
resumeStrandedTitle: '無法載入此工作階段',
resumeStrandedBody: '與此工作階段的連線失敗,自動重試已停止。請確認閘道正在執行,然後重試。',
resumeRetry: '重試',
nothingToBranch: '沒有可分支的內容',
branchNeedsChat: '分支前請先開始或繼續一個聊天。',
sessionBusy: '工作階段忙碌中',
+39 -10
View File
@@ -359,7 +359,30 @@ export const zh: Translations = {
installError: '无法安装该主题。',
installed: name => `已安装「${name}」。`,
removeTheme: '移除主题',
importedBadge: '已导入'
importedBadge: '已导入',
pet: {
title: '宠物',
intro: '领养一只悬浮在应用上的 petdex 动画宠物,它会根据 Hermes 的状态做出反应——工具执行时奔跑、成功时欢呼、出错时沮丧。',
restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes,然后回到此处。',
scaleTitle: '大小',
scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。',
on: '开启',
off: '关闭',
chooseTitle: '选择宠物',
chooseDesc: '选择后会自动安装(如需)并设为当前宠物。',
searchPlaceholder: '搜索宠物…',
unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。',
noMatch: query => `没有匹配「${query}」的宠物。`,
installedTag: '已安装',
countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`,
count: n => `${n} 个宠物。`,
uninstall: name => `卸载 ${name}`,
adoptFailed: slug => `无法领养 ${slug}`,
uninstallFailed: slug => `无法卸载 ${slug}`,
noneAvailable: '当前没有可开启的宠物。',
turnOnFailed: '无法开启宠物。',
turnOffFailed: '无法关闭宠物。'
}
},
fieldLabels: defineFieldCopy({
model: '默认模型',
@@ -733,10 +756,6 @@ export const zh: Translations = {
provider: '提供方',
model: '模型',
applying: '应用中...',
defaultsLabel: '默认值',
reasoning: '推理',
reasoningOff: '关闭',
defaultsFailed: '保存模型默认值失败',
auxiliaryTitle: '辅助模型',
resetAllToMain: '全部重置为主模型',
auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',
@@ -904,8 +923,22 @@ export const zh: Translations = {
commandCenter: '命令中心',
appearance: '外观',
settings: '设置',
changeTheme: '更改主题...',
changeTheme: '更改主题',
changeColorMode: '更改颜色模式...',
pets: {
title: '宠物',
placeholder: '搜索宠物…',
loading: '正在加载 petdex 画廊…',
error: '无法连接到 petdex 画廊。',
staleBackend: '请重启 Hermes 以使用宠物功能——当前后端版本过旧。',
empty: '没有匹配的宠物。',
turnOff: '关闭',
turnOn: '开启',
installed: '已安装',
adoptFailed: '无法领养该宠物。',
toggleFailed: '无法切换宠物显示。',
noneAvailable: '暂无可用宠物——请在下方选择一个安装。'
},
installTheme: {
title: '安装主题...',
placeholder: '搜索 VS Code Marketplace...',
@@ -1912,7 +1945,6 @@ export const zh: Translations = {
refresh: '刷新',
moreActions: '更多操作',
branchNewChat: '在新对话中分支',
dismissError: '关闭错误',
readAloudFailed: '朗读失败',
preparingAudio: '正在准备音频...',
stopReading: '停止朗读',
@@ -2021,9 +2053,6 @@ export const zh: Translations = {
regenerateFailed: '重新生成失败',
editFailed: '编辑失败',
resumeFailed: '恢复失败',
resumeStrandedTitle: '无法加载此会话',
resumeStrandedBody: '与此会话的连接失败,自动重试已停止。请确认网关正在运行,然后重试。',
resumeRetry: '重试',
nothingToBranch: '没有可分支的内容',
branchNeedsChat: '分支前请先开始或恢复一个对话。',
sessionBusy: '会话忙碌中',
@@ -52,6 +52,16 @@ describe('desktop slash command curation', () => {
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
})
it('routes /pet through the desktop action handler and drops /pets', () => {
expect(resolveDesktopCommand('/pet')?.surface).toEqual({ kind: 'action', action: 'pet' })
expect(resolveDesktopCommand('/pet')?.args).toBe(true)
expect(isDesktopSlashSuggestion('/pet')).toBe(true)
expect(isDesktopSlashCommand('/pet')).toBe(true)
expect(resolveDesktopCommand('/pets')?.surface).toEqual({ kind: 'unavailable', reason: 'settings' })
expect(isDesktopSlashSuggestion('/pets')).toBe(false)
expect(isDesktopSlashCommand('/pets')).toBe(false)
})
it('treats /browser as an executable action command (local-gateway connect)', () => {
// /browser used to be terminal-only; it now resolves to a desktop action
// handler that routes browser.manage RPC when the gateway is local.
@@ -34,6 +34,7 @@ export type DesktopActionId =
| 'handoff'
| 'help'
| 'new'
| 'pet'
| 'profile'
| 'skin'
| 'title'
@@ -128,6 +129,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
{ name: '/debug', description: 'Create a debug report', surface: exec() },
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
{ name: '/pet', description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', surface: action('pet'), args: true },
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
@@ -155,7 +157,7 @@ const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> =
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
],
messaging: ['/approve', '/deny'],
settings: ['/skills'],
settings: ['/skills', '/pets'],
advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice']
}
+4
View File
@@ -51,6 +51,7 @@ import {
IconLoader2 as Loader2Icon,
IconLock as Lock,
IconLogin as LogIn,
IconMail as Mail,
IconMessageCircle as MessageCircle,
IconMessage2 as MessageSquareText,
IconMicrophone as Mic,
@@ -67,6 +68,7 @@ import {
IconLayoutBottombar as PanelBottom,
IconLayoutSidebar as PanelLeftIcon,
IconPlayerPause as Pause,
IconPaw as PawPrint,
IconPencil as Pencil,
IconPencil as PencilIcon,
IconPencil as PencilLine,
@@ -153,6 +155,7 @@ export {
Loader2Icon,
Lock,
LogIn,
Mail,
MessageCircle,
MessageSquareText,
Mic,
@@ -169,6 +172,7 @@ export {
PanelBottom,
PanelLeftIcon,
Pause,
PawPrint,
Pencil,
PencilIcon,
PencilLine,
+6 -12
View File
@@ -151,18 +151,12 @@ function normalizeVisibleProse(text: string): string {
.join('')
}
function extend(out: string[], lines: string[]) {
for (const line of lines) {
out.push(line)
}
}
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
if (info) {
out.push(`${indent}${info}`.trimEnd())
}
extend(out, lines)
out.push(...lines)
}
function findClosingFence(lines: string[], start: number, marker: string): number {
@@ -247,7 +241,7 @@ function normalizeFenceBlocks(text: string): string {
}
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
extend(out, bodyLines)
out.push(...bodyLines)
index = closeIndex + 1
continue
@@ -270,10 +264,10 @@ function normalizeFenceBlocks(text: string): string {
// any literal `$$` characters in the body don't collide with
// an outer math wrapper. No close emitted yet — streaming.
out.push(`${indent}${marker}math`)
extend(out, bodyLines)
out.push(...bodyLines)
} else {
out.push(`${indent}${marker}${language}`)
extend(out, bodyLines)
out.push(...bodyLines)
}
break
@@ -294,7 +288,7 @@ function normalizeFenceBlocks(text: string): string {
// colliding with our wrapper. Without this rewrite the block
// would render as a syntax-highlighted "latex" code listing.
out.push(`${indent}${marker}math`)
extend(out, bodyLines)
out.push(...bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
@@ -302,7 +296,7 @@ function normalizeFenceBlocks(text: string): string {
}
out.push(`${indent}${marker}${language}`)
extend(out, bodyLines)
out.push(...bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { currentPickerSelection, displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
describe('model-status-label', () => {
it('formats display names consistently', () => {
@@ -35,25 +35,4 @@ describe('model-status-label', () => {
it('returns just the placeholder name when there is no model', () => {
expect(formatModelStatusLabel('')).toBe('No model')
})
describe('currentPickerSelection', () => {
const store = { model: 'opus', provider: 'anthropic' }
const options = { model: 'hermes-4', provider: 'nous' }
it('prefers the sticky composer pick over the profile default pre-session', () => {
expect(currentPickerSelection(false, store, options)).toEqual(store)
})
it('lets the live session model.options win when a session exists', () => {
expect(currentPickerSelection(true, store, options)).toEqual(options)
})
it('falls back to options when the store is empty', () => {
expect(currentPickerSelection(false, { model: '', provider: '' }, options)).toEqual(options)
})
it('falls back to the store while options are still loading', () => {
expect(currentPickerSelection(true, store, undefined)).toEqual(store)
})
})
})
@@ -17,22 +17,6 @@ export function reasoningEffortLabel(effort: string): string {
return REASONING_LABELS[key] ?? effort
}
/** Which model/provider a picker should mark "current". With a live session the
* gateway's `model.options` is authoritative; pre-session there is no server
* "current", so the sticky composer pick wins over the profile default the
* global options query returns else the checkmark snaps back to the default
* and the pick looks ignored. */
export function currentPickerSelection(
hasSession: boolean,
store: { model: string; provider: string },
options?: { model?: string; provider?: string }
): { model: string; provider: string } {
return {
model: String((hasSession && options?.model) || store.model || options?.model || ''),
provider: String((hasSession && options?.provider) || store.provider || options?.provider || '')
}
}
/** Strip provider prefix and normalize for display. */
export function modelBaseId(model: string): string {
const trimmed = model.trim()
+31
View File
@@ -0,0 +1,31 @@
import { cn } from '@/lib/utils'
export interface SelectableCardState {
/** Currently selected / active — the strongest emphasis. */
active?: boolean
/**
* Configured / installed / "you have this" solid surface + border. When
* false the card renders muted (transparent, dimmed) until hovered, so the
* eye lands on what you already have. Ignored when `active` is set.
*/
prominent?: boolean
}
/**
* Shared emphasis for selectable list cards across settings surfaces (theme
* picker, pet picker, Marketplace results, provider rows). Three tiers:
* active > prominent > muted. Keeps the "installed = solid, not-installed =
* quiet" pattern consistent everywhere instead of each picker rolling its own.
*
* Callers own layout (padding, flex, width); this owns only border + surface.
*/
export function selectableCardClass({ active, prominent }: SelectableCardState): string {
return cn(
'rounded-lg border transition-colors',
active
? 'border-primary bg-primary/[0.06] ring-2 ring-primary/20'
: prominent
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)'
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-bg-quinary)'
)
}
+24 -17
View File
@@ -26,20 +26,27 @@ if (import.meta.env.MODE !== 'production') {
import('./app/chat/perf-probe')
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary label="root">
<QueryClientProvider client={queryClient}>
<I18nProvider>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</I18nProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
)
// The pet overlay rides this same bundle (`?win=overlay`) but mounts a tiny,
// transparent, gateway-less surface instead of the full app. Branch before any
// app-shell work so the overlay window stays cheap.
if (new URLSearchParams(window.location.search).get('win') === 'overlay') {
void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay())
} else {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary label="root">
<QueryClientProvider client={queryClient}>
<I18nProvider>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</I18nProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
)
}
+14
View File
@@ -3,16 +3,30 @@ import { atom } from 'nanostores'
/** Whether the global command palette (Cmd/Ctrl+K) is currently open. */
export const $commandPaletteOpen = atom(false)
/** Optional nested page to open when the palette next opens (e.g. `pets`). */
export const $commandPalettePage = atom<string | null>(null)
export function openCommandPalette(): void {
$commandPaletteOpen.set(true)
}
/** Open the palette directly on a nested page (`theme`, `pets`, …). */
export function openCommandPalettePage(page: string): void {
$commandPalettePage.set(page)
$commandPaletteOpen.set(true)
}
export function closeCommandPalette(): void {
$commandPaletteOpen.set(false)
$commandPalettePage.set(null)
}
export function setCommandPaletteOpen(open: boolean): void {
$commandPaletteOpen.set(open)
if (!open) {
$commandPalettePage.set(null)
}
}
export function toggleCommandPalette(): void {
+298
View File
@@ -0,0 +1,298 @@
import { atom } from 'nanostores'
import { $petInfo, type PetInfo, setPetInfo } from '@/store/pet'
/**
* Feature store for the petdex gallery picker (Cmd+K "Pets…" + Settings).
*
* Why this exists: `pet.gallery` does a *network* manifest fetch on the gateway,
* so re-pulling it after every adopt/toggle made the picker feel laggy and made
* two components (palette + settings) each carry their own copy of the same
* fetch / thumb-cache / optimistic-mutation logic. This store centralizes it:
*
* - The gallery is fetched once and cached; reopening the picker is instant.
* - Mutations (adopt / enable / remove) patch local state and only re-pull the
* cheap, local `pet.info` never the network manifest again.
* - Thumbnails are deduped in a process-global cache (the backend disk-caches
* too, so a slug is fetched at most once per session).
*
* Consumers just `useStore($petGallery)` and call the actions; no component
* owns gallery state anymore.
*/
export interface GalleryPet {
slug: string
displayName: string
installed: boolean
spritesheetUrl?: string
/** petdex's hand-picked set — used only to rank "popular" pets first. */
curated?: boolean
}
export interface PetGallery {
enabled: boolean
active: string
pets: GalleryPet[]
}
export type PetGalleryStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error'
/** The recovering `requestGateway` from `useGatewayRequest` passed in so the
* store reuses the hook's reconnect/reauth handling instead of duplicating it. */
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/** A JSON-RPC "method not found" — the backend predates the pet RPCs. */
function isMissingMethod(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /method not found|-32601|unknown method|no such method/i.test(message)
}
export const $petGallery = atom<PetGallery | null>(null)
export const $petGalleryStatus = atom<PetGalleryStatus>('idle')
export const $petGalleryError = atom<string | null>(null)
// Which action is in flight, so rows/buttons can show a spinner. A slug for a
// per-pet mutation; the `TOGGLE_*` sentinels for the on/off switch.
export const TOGGLE_ON = '\u0000on'
export const TOGGLE_OFF = '\u0000off'
export const $petBusy = atom<string | null>(null)
// Process-global caches (survive component unmount → instant reopen).
const thumbCache = new Map<string, Promise<string | null>>()
let galleryLoad: Promise<void> | null = null
export function loadPetThumb(request: GatewayRequest, slug: string, url?: string): Promise<string | null> {
let pending = thumbCache.get(slug)
if (!pending) {
pending = request<{ ok: boolean; dataUri?: string }>('pet.thumb', { slug, url: url ?? '' })
.then(result => (result?.ok && result.dataUri ? result.dataUri : null))
.catch(() => null)
thumbCache.set(slug, pending)
}
return pending
}
/**
* Fetch the gallery once and cache it. Subsequent calls are no-ops while a
* ready snapshot is held; pass `{ force: true }` to bypass the cache (e.g. a
* manual refresh). Concurrent callers share a single in-flight request.
*/
export function loadPetGallery(request: GatewayRequest, options: { force?: boolean } = {}): Promise<void> {
if (!options.force && $petGallery.get() && $petGalleryStatus.get() === 'ready') {
return Promise.resolve()
}
if (galleryLoad) {
return galleryLoad
}
galleryLoad = (async () => {
if (!$petGallery.get()) {
$petGalleryStatus.set('loading')
}
try {
const [next, info] = await Promise.all([request<PetGallery>('pet.gallery'), request<PetInfo>('pet.info')])
if (next) {
$petGallery.set(next)
$petGalleryStatus.set('ready')
$petGalleryError.set(null)
}
if (info) {
setPetInfo(info)
}
} catch (e) {
if (isMissingMethod(e)) {
$petGalleryStatus.set('stale')
} else if (!$petGallery.get()) {
// Only surface a hard error when we have nothing to show; a transient
// hiccup mid-session leaves the cached gallery intact.
$petGalleryStatus.set('error')
$petGalleryError.set(e instanceof Error ? e.message : 'Could not reach the petdex gallery.')
}
} finally {
galleryLoad = null
}
})()
return galleryLoad
}
// Push the live mascot state (cheap, local config read) without re-pulling the
// network gallery — the floating pet repaints, the picker keeps its cache.
async function syncInfo(request: GatewayRequest): Promise<void> {
try {
const info = await request<PetInfo>('pet.info')
if (info) {
setPetInfo(info)
}
} catch {
// The mutation already succeeded; a stale mascot self-heals on its poll.
}
}
/**
* Filter (drop the internal `clawd*` pets + apply a search query) and rank the
* gallery for a picker. Ranking has no popularity data, so it leans on the
* signals we do have: active pet first, then installed, then curated. Shared by
* the Cmd-K palette and the Settings grid so the two can't drift each caller
* applies its own cap and reads `.length` for the total.
*/
export function rankedGalleryPets(gallery: PetGallery | null, query = ''): GalleryPet[] {
if (!gallery) {
return []
}
const needle = query.trim().toLowerCase()
const rank = (p: GalleryPet) =>
Number(gallery.enabled && p.slug === gallery.active) * 4 + Number(p.installed) * 2 + Number(p.curated)
return gallery.pets
.filter(
p =>
!/^clawd(-|$)/i.test(p.slug) &&
(!needle || p.slug.toLowerCase().includes(needle) || p.displayName.toLowerCase().includes(needle))
)
.sort((a, b) => rank(b) - rank(a))
}
function patchGallery(fn: (gallery: PetGallery) => PetGallery): void {
const current = $petGallery.get()
if (current) {
$petGallery.set(fn(current))
}
}
/** Shared mutation wrapper: spin, fire, patch on success, surface failures. */
async function mutate(
busyKey: string,
fallback: string,
request: GatewayRequest,
run: () => Promise<void>
): Promise<boolean> {
$petBusy.set(busyKey)
$petGalleryError.set(null)
try {
await run()
await syncInfo(request)
return true
} catch (e) {
if (isMissingMethod(e)) {
$petGalleryStatus.set('stale')
} else {
$petGalleryError.set(e instanceof Error ? e.message : fallback)
}
return false
} finally {
$petBusy.set(null)
}
}
/** Install (if needed) + activate a pet. Optimistically marks it active. */
export function adoptPet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
return mutate(slug, fallback, request, async () => {
await request('pet.select', { slug })
patchGallery(g => ({
...g,
enabled: true,
active: slug,
pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: true } : p))
}))
})
}
/**
* Turn the floating mascot on/off. On enable, activates the current pet (or the
* first installed one). Returns false without firing if there's nothing to show.
*/
export function setPetEnabled(
request: GatewayRequest,
on: boolean,
copy: { noneAvailable: string; fallback: string }
): Promise<boolean> {
const gallery = $petGallery.get()
if (!on && !(gallery?.enabled ?? false)) {
return Promise.resolve(true)
}
let slug = gallery?.active || ''
if (on) {
slug = slug || gallery?.pets.find(p => p.installed)?.slug || ''
if (!slug) {
$petGalleryError.set(copy.noneAvailable)
return Promise.resolve(false)
}
}
return mutate(on ? TOGGLE_ON : TOGGLE_OFF, copy.fallback, request, async () => {
if (on) {
await request('pet.select', { slug })
} else {
await request('pet.disable')
}
patchGallery(g => ({ ...g, enabled: on, active: on ? slug : g.active }))
})
}
// Pet scale bounds — mirror `agent/pet/constants.py` (MIN_SCALE / MAX_SCALE) so
// the slider and the server clamp to the same range.
export const PET_SCALE_MIN = 0.1
export const PET_SCALE_MAX = 3.0
export const PET_SCALE_DEFAULT = 0.33
export const clampPetScale = (n: number) => Math.max(PET_SCALE_MIN, Math.min(PET_SCALE_MAX, n))
let scalePersist: ReturnType<typeof setTimeout> | undefined
/**
* Resize the floating pet. Updates `$petInfo` synchronously so the on-screen pet
* (and the slider) react on the same frame, then debounce-persists to
* `display.pet.scale` so a slider drag fires one RPC, not one per pixel. No poll
* or event needed the pet already renders from `$petInfo.scale`.
*/
export function setPetScale(request: GatewayRequest, scale: number): void {
const next = clampPetScale(scale)
setPetInfo({ ...$petInfo.get(), scale: next })
clearTimeout(scalePersist)
scalePersist = setTimeout(() => {
request<{ ok: boolean; scale?: number }>('pet.scale', { scale: next })
.then(result => {
// Reconcile with the server's clamp (cheap; only matters at the bounds).
if (typeof result?.scale === 'number' && result.scale !== $petInfo.get().scale) {
setPetInfo({ ...$petInfo.get(), scale: result.scale })
}
})
.catch(() => {
// Cosmetic — the pet already resized; persistence self-heals next write.
})
}, 200)
}
/** Uninstall a pet; turns the mascot off if it was the active one. */
export function removePet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
return mutate(slug, fallback, request, async () => {
await request('pet.remove', { slug })
patchGallery(g => ({
...g,
enabled: g.active === slug ? false : g.enabled,
pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: false } : p))
}))
})
}
+260
View File
@@ -0,0 +1,260 @@
import { atom } from 'nanostores'
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
import { $petActivity, $petInfo, $petUnread, clearPetUnread, type PetActivity, type PetInfo } from '@/store/pet'
import { $awaitingResponse, $busy } from '@/store/session'
/**
* Controller for the pop-out pet overlay (main-renderer side).
*
* Shift-clicking the in-window pet "pops it out" into a transparent,
* always-on-top OS window (created in electron/main.cjs) that can leave the
* app's bounds and stays visible while Hermes is minimized. That window carries
* NO gateway connection this renderer remains the single source of truth and
* pushes the live pet state to it over IPC. Control flows back (pop the pet back
* in, submit a composer message) via `onControl`.
*
* The overlay renders the same `PetSprite` / `PetBubble` as the in-window pet by
* mirroring the four reactive inputs of `$petState` (`$petInfo`, `$petActivity`,
* `$busy`, `$awaitingResponse`) into its own copies of those atoms so the
* popped-out mascot is pixel-identical and needs zero bespoke render logic.
*/
export interface PetOverlayBounds {
x: number
y: number
width: number
height: number
}
/**
* Request to open the overlay window. `screen` says whether `bounds` are already
* in absolute screen coordinates (a remembered/dragged spot) or in the main
* window's viewport space (a fresh shift-click pop-out, which main.cjs converts
* by adding the content origin).
*/
export interface PetOverlayOpenRequest {
bounds: PetOverlayBounds
screen?: boolean
}
/** Everything the overlay needs to reproduce the live mascot. */
export interface PetOverlayStatePayload {
info: PetInfo
activity: PetActivity
busy: boolean
awaiting: boolean
/** Drives the overlay's mail icon: a finish landed while you were away. */
unread: boolean
}
export type PetOverlayControl =
| { type: 'pop-in' }
| { type: 'ready' }
| { type: 'submit'; text: string }
| { type: 'bounds'; bounds: PetOverlayBounds }
| { type: 'open-app' }
| { type: 'toggle-app' }
// Persisted across restarts: was the pet popped out, and where on the desktop
// did the user leave it. Keyed v1; bump if the bounds shape ever changes.
const OVERLAY_ACTIVE_KEY = 'hermes.desktop.pet-overlay-active.v1'
const OVERLAY_BOUNDS_KEY = 'hermes.desktop.pet-overlay-bounds.v1'
export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false))
// Persist the in/out choice so a popped-out pet comes back popped out.
$petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active))
function loadSavedBounds(): null | PetOverlayBounds {
try {
const raw = storedString(OVERLAY_BOUNDS_KEY)
if (!raw) {
return null
}
const parsed = JSON.parse(raw) as Partial<PetOverlayBounds>
if (
typeof parsed.x === 'number' &&
typeof parsed.y === 'number' &&
typeof parsed.width === 'number' &&
typeof parsed.height === 'number'
) {
return { height: parsed.height, width: parsed.width, x: parsed.x, y: parsed.y }
}
} catch {
// fall through to null
}
return null
}
function saveBounds(bounds: PetOverlayBounds): void {
persistString(OVERLAY_BOUNDS_KEY, JSON.stringify(bounds))
}
// The overlay window is padded around the sprite so the bubble (above), the
// drag area, and the pop-up composer all have room; the pet sits near the
// bottom and the rest of the rectangle is transparent + click-through.
const OVERLAY_PAD_X = 100
const OVERLAY_PAD_Y = 200
const OVERLAY_MIN_W = 240
const OVERLAY_MIN_H = 300
let stateUnsubs: Array<() => void> = []
let controlUnsub: (() => void) | null = null
let submitHandler: ((text: string) => void) | null = null
let openAppHandler: (() => void) | null = null
function currentPayload(): PetOverlayStatePayload {
return {
info: $petInfo.get(),
activity: $petActivity.get(),
busy: $busy.get(),
awaiting: $awaitingResponse.get(),
unread: $petUnread.get()
}
}
function pushNow(): void {
window.hermesDesktop?.petOverlay?.pushState(currentPayload())
}
/**
* Open the overlay window and start mirroring live state into it. The main
* process echoes back the actual screen bounds it used, which we persist so the
* pet reopens exactly where the user left it.
*/
function openOverlay(request: PetOverlayOpenRequest): void {
const api = window.hermesDesktop?.petOverlay
if (!api || stateUnsubs.length) {
return
}
$petOverlayActive.set(true)
void api.open(request).then(res => {
if (res?.bounds) {
saveBounds(res.bounds)
}
pushNow()
})
// Mirror live state into the overlay. subscribe() fires immediately, so the
// overlay also gets a first frame the moment it's ready (it asks via 'ready').
stateUnsubs = [
$petInfo.subscribe(pushNow),
$petActivity.subscribe(pushNow),
$busy.subscribe(pushNow),
$awaitingResponse.subscribe(pushNow),
$petUnread.subscribe(pushNow)
]
}
/**
* Pop the pet out of the window. `petRect` is the in-window sprite's viewport
* rect; we grow it to the padded overlay size and center the window on the
* pet's old spot (main.cjs adds the window's screen origin). If the user has
* popped out before, reopen at that remembered desktop spot instead.
*/
export function popOutPet(petRect: PetOverlayBounds): void {
if ($petOverlayActive.get() || stateUnsubs.length) {
return
}
const saved = loadSavedBounds()
if (saved) {
openOverlay({ bounds: saved, screen: true })
return
}
const width = Math.max(OVERLAY_MIN_W, Math.round(petRect.width + OVERLAY_PAD_X))
const height = Math.max(OVERLAY_MIN_H, Math.round(petRect.height + OVERLAY_PAD_Y))
const x = Math.round(petRect.x - (width - petRect.width) / 2)
const y = Math.round(petRect.y - (height - petRect.height) / 2)
openOverlay({ bounds: { height, width, x, y }, screen: false })
}
/**
* Restore the overlay on boot if the pet was popped out when the app last
* closed. Requires a remembered desktop spot without one we fall back to the
* in-window pet rather than spawning an orphan window at the origin.
*/
export function restorePetOverlay(): void {
if (!window.hermesDesktop?.petOverlay || !$petOverlayActive.get() || stateUnsubs.length) {
return
}
const saved = loadSavedBounds()
if (!saved) {
$petOverlayActive.set(false)
return
}
openOverlay({ bounds: saved, screen: true })
}
/** Pop the pet back into the window (closes the overlay window). */
export function popInPet(): void {
for (const off of stateUnsubs) {
off()
}
stateUnsubs = []
$petOverlayActive.set(false)
void window.hermesDesktop?.petOverlay?.close()
}
/** Register the handler that turns an overlay composer submit into a real send. */
export function setPetOverlaySubmitHandler(fn: ((text: string) => void) | null): void {
submitHandler = fn
}
/** Register the handler that opens the app to the most recent thread (mail icon). */
export function setPetOverlayOpenAppHandler(fn: (() => void) | null): void {
openAppHandler = fn
}
/**
* Wire the overlayrenderer control channel once. Returns a disposer. Idempotent
* a second call while already wired is a no-op.
*/
export function initPetOverlayBridge(): () => void {
const api = window.hermesDesktop?.petOverlay
if (!api || controlUnsub) {
return () => {}
}
controlUnsub = api.onControl(payload => {
if (payload?.type === 'pop-in') {
popInPet()
} else if (payload?.type === 'ready') {
// The overlay just mounted — hand it the current frame.
pushNow()
} else if (payload?.type === 'submit' && typeof payload.text === 'string') {
submitHandler?.(payload.text)
} else if (payload?.type === 'bounds' && payload.bounds) {
// The user dragged the overlay to a new desktop spot — remember it.
saveBounds(payload.bounds)
} else if (payload?.type === 'open-app') {
// Mail icon: surface the app on the most recent thread (main.cjs already
// focused the window before forwarding this) and mark it read.
clearPetUnread()
openAppHandler?.()
}
})
return () => {
controlUnsub?.()
controlUnsub = null
}
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { $petActivity, $petState, derivePetState, flashPetActivity, setPetActivity } from './pet'
describe('derivePetState', () => {
it('rests at idle by default and uses waiting when awaiting input', () => {
expect(derivePetState({})).toBe('idle')
expect(derivePetState({ awaitingInput: true })).toBe('waiting')
})
it('runs when busy or a tool is executing', () => {
expect(derivePetState({ busy: true })).toBe('run')
expect(derivePetState({ toolRunning: true })).toBe('run')
})
it('reviews while reasoning (below tool, above bare busy)', () => {
expect(derivePetState({ reasoning: true })).toBe('review')
expect(derivePetState({ reasoning: true, busy: true })).toBe('review')
expect(derivePetState({ reasoning: true, toolRunning: true })).toBe('run')
})
it('waits (blocked on the user) above the in-flight signals', () => {
expect(derivePetState({ awaitingInput: true, toolRunning: true, busy: true })).toBe('waiting')
// but a finish beat still wins over waiting
expect(derivePetState({ justCompleted: true, awaitingInput: true })).toBe('wave')
})
it('honors the full priority chain: error > celebrate > complete > tool', () => {
expect(derivePetState({ error: true, celebrate: true, busy: true })).toBe('failed')
expect(derivePetState({ celebrate: true, justCompleted: true, toolRunning: true })).toBe('jump')
expect(derivePetState({ justCompleted: true, toolRunning: true })).toBe('wave')
})
})
describe('flashPetActivity', () => {
it('clears stale sibling beats so a completion never inherits a prior error', () => {
// A turn errors (sad), then the next turn finishes cleanly. The celebrate
// beat must win — error is highest priority, so a merge-only flash would
// keep the pet on the failed pose.
setPetActivity({ error: true })
flashPetActivity({ celebrate: true })
expect($petActivity.get().error).toBe(false)
expect($petState.get()).toBe('jump')
setPetActivity({})
})
})
+147
View File
@@ -0,0 +1,147 @@
import { atom, computed } from 'nanostores'
import { $busy } from '@/store/session'
/**
* Petdex mascot state for the desktop floating pet.
*
* The spritesheet payload comes from the gateway `pet.info` RPC (shared with
* the TUI). The animation *state* is derived here from the same activity
* signals the chat already tracks, mirroring the priority order documented in
* `agent/pet/state.py` so the Python and TS surfaces never drift.
*/
export type PetState = 'idle' | 'wave' | 'run' | 'failed' | 'review' | 'jump' | 'waiting'
export interface PetInfo {
enabled: boolean
slug?: string
displayName?: string
mime?: string
spritesheetBase64?: string
frameW?: number
frameH?: number
framesPerState?: number
// Real (padding-trimmed) frame count per state row, from the engine. Lets the
// canvas step only frames that exist instead of a fixed framesPerState, which
// would animate into the transparent padding of ragged sheets (blank flash).
framesByState?: Record<string, number>
loopMs?: number
scale?: number
stateRows?: string[]
}
export interface PetActivity {
busy?: boolean
awaitingInput?: boolean
toolRunning?: boolean
reasoning?: boolean
error?: boolean
justCompleted?: boolean
celebrate?: boolean
}
/**
* Resolve the animation state from coarse activity signals.
*
* Priority (highest first) mirrors `agent.pet.state.derive_pet_state`:
* error celebrate justCompleted awaitingInput toolRunning reasoning
* busy idle. `awaitingInput` (a clarify/approval blocking on the user) outranks
* the in-flight signals because the turn is paused on you, not working.
*/
export function derivePetState(activity: PetActivity): PetState {
if (activity.error) {
return 'failed'
}
if (activity.celebrate) {
return 'jump'
}
if (activity.justCompleted) {
return 'wave'
}
if (activity.awaitingInput) {
return 'waiting'
}
if (activity.toolRunning) {
return 'run'
}
if (activity.reasoning) {
return 'review'
}
if (activity.busy) {
return 'run'
}
return 'idle'
}
export const $petInfo = atom<PetInfo>({ enabled: false })
export const $petActivity = atom<PetActivity>({})
/**
* Pet-local "you have a new message" flag, surfaced as the overlay's mail icon.
* Deliberately not real unread tracking: it flips on when a turn finishes while
* the app isn't focused, and off when the user opens the app via the mail icon
* (or returns to the window). No persistence it's a glance hint, not state.
*/
export const $petUnread = atom(false)
export const markPetUnread = () => $petUnread.set(true)
export const clearPetUnread = () => $petUnread.set(false)
/** Steady activity flags (toolRunning / reasoning) set + cleared by the stream. */
export const setPetActivity = (next: Partial<PetActivity>) =>
$petActivity.set({ ...$petActivity.get(), ...next })
let flashTimer: ReturnType<typeof setTimeout> | undefined
/** Fire a transient reaction beat (error / celebrate / justCompleted) that
* decays back to the steady state after `ms`.
*
* Each beat first clears its siblings so a stale one can't win the priority
* race: without this, a completion beat (`celebrate`) would merge on top of a
* lingering `error`, and `derivePetState` checks `error` first so a clean
* finish would render the sad/failed pose. */
export const flashPetActivity = (next: Partial<PetActivity>, ms = 1600) => {
setPetActivity({ celebrate: false, error: false, justCompleted: false, ...next })
clearTimeout(flashTimer)
flashTimer = setTimeout(
() => setPetActivity({ celebrate: false, error: false, justCompleted: false }),
ms
)
}
export const setPetInfo = (info: PetInfo) => $petInfo.set(info)
/**
* The live pet state. Derives from the dedicated activity atom, falling back to
* the always-present `$busy` chat signal so the pet reacts out of the box.
*
* `awaitingInput` (a clarify/approval blocking on the user) is an explicit flag
* on `$petActivity` set by the controller from `$attentionSessionIds` and
* mirrored to the pop-out overlay through the same atom, so both surfaces agree
* without the overlay needing the session list.
*/
export const $petState = computed(
[$petActivity, $busy],
(activity, busy): PetState => {
const live = activity.busy ?? busy
return derivePetState({
busy: live,
awaitingInput: activity.awaitingInput,
// Steady flags only count mid-turn — ignore stale ones once at rest so an
// interrupted turn can't pin the pet on `run`/`review`.
toolRunning: live && activity.toolRunning,
reasoning: live && activity.reasoning,
error: activity.error,
justCompleted: activity.justCompleted,
celebrate: activity.celebrate
})
}
)
-19
View File
@@ -218,23 +218,6 @@ export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageI
export const $freshDraftReady = atom(false)
export const $busy = atom(false)
export const $awaitingResponse = atom(false)
// Stored-session id whose most recent resume FAILED terminally (the gateway RPC
// rejected AND the REST transcript fallback also failed), leaving the window
// with no runtime and an empty transcript. Drives use-route-resume's self-heal:
// while this matches the routed session the loader would otherwise latch
// forever (messagesEmpty && !activeSessionId), so the hook re-attempts the
// resume on the next render/focus/reconnect instead of stranding the window.
// Null whenever the active route has a healthy (or in-flight) resume.
export const $resumeFailedSessionId = atom<string | null>(null)
// Stored-session id whose resume has EXHAUSTED its bounded auto-retries (the
// terminal-failure latch above kept failing through all MAX_RESUME_RETRIES
// attempts). Distinct from $resumeFailedSessionId, which is armed *during* the
// backoff window too: this fires only once auto-recovery has given up, so the
// chat view can swap the perpetual loader for an explicit error + manual Retry
// affordance. A fresh resumeSession() (manual Retry, reconnect, reselect)
// clears it and resets the retry counter. Null whenever the active route has a
// healthy, in-flight, or still-auto-retrying resume.
export const $resumeExhaustedSessionId = atom<string | null>(null)
export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '')
export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '')
export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '')
@@ -279,8 +262,6 @@ export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
export const setResumeFailedSessionId = (next: Updater<string | null>) => updateAtom($resumeFailedSessionId, next)
export const setResumeExhaustedSessionId = (next: Updater<string | null>) => updateAtom($resumeExhaustedSessionId, next)
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
+1 -56
View File
@@ -41,7 +41,7 @@ vi.mock('@/hermes', () => ({
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
}))
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply, reportBackendContract } = await import('./updates')
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply } = await import('./updates')
const { setConnection } = await import('./session')
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
@@ -95,61 +95,6 @@ describe('maybeNotifyUpdateAvailable', () => {
})
})
describe('reportBackendContract', () => {
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
dismissSpy.mockClear()
vi.useRealTimers()
})
it('dismisses the toast when the backend meets the contract', () => {
reportBackendContract(2)
expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew')
expect(notifySpy).not.toHaveBeenCalled()
})
it('warns when the backend is behind (or reports no contract)', () => {
reportBackendContract(undefined)
expect(notifySpy).toHaveBeenCalledTimes(1)
reportBackendContract(1)
expect(notifySpy).toHaveBeenCalledTimes(2)
})
it('stays quiet on later session opens once the user closed it', () => {
reportBackendContract(1)
lastToast().onDismiss() // user closes it → cooldown starts
notifySpy.mockClear()
// Opening another pre-existing session re-runs the check within cooldown.
reportBackendContract(1)
expect(notifySpy).not.toHaveBeenCalled()
})
it('reminds again after the cooldown elapses', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
reportBackendContract(1)
lastToast().onDismiss()
notifySpy.mockClear()
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
reportBackendContract(1)
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('clears the snooze once the backend catches up, so a regression warns again', () => {
reportBackendContract(1)
lastToast().onDismiss()
notifySpy.mockClear()
reportBackendContract(2) // backend updated → satisfied, snooze cleared
reportBackendContract(1) // a later regression must warn immediately
expect(notifySpy).toHaveBeenCalledTimes(1)
})
})
describe('checkBackendUpdates', () => {
beforeEach(() => {
storage.clear()
+3 -37
View File
@@ -91,60 +91,26 @@ function isUpdateToastSnoozed(): boolean {
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
const REQUIRED_BACKEND_CONTRACT = 2
const SKEW_TOAST_ID = 'backend-contract-skew'
// The contract check runs on every session.resume (applyRuntimeInfo), so
// without a snooze the warning re-popped on every thread the user opened, even
// right after they closed it. Mirror the update toast: persist a cooldown when
// the user dismisses it. It still reminds again after the window if the backend
// is still behind, and clears immediately once the backend catches up.
const SKEW_TOAST_SNOOZE_KEY = 'hermes:backend-skew-toast-snooze-until'
const SKEW_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
function snoozeSkewToast(): void {
persistString(SKEW_TOAST_SNOOZE_KEY, String(Date.now() + SKEW_TOAST_COOLDOWN_MS))
}
function isSkewToastSnoozed(): boolean {
const until = Number(storedString(SKEW_TOAST_SNOOZE_KEY) || 0)
return Number.isFinite(until) && Date.now() < until
}
/**
* Guard against a desktop GUI talking to a backend that predates its contract
* (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing
* cryptically downstream, surface a warning with a one-click align that runs
* the normal update flow (which self-heals to the right branch).
*
* Runs on every session open; closing the toast snoozes it for a cooldown so it
* doesn't nag on every thread switch.
* cryptically downstream, surface a persistent warning with a one-click align
* that runs the normal update flow (which self-heals to the right branch).
*/
export function reportBackendContract(contract: number | undefined): void {
if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) {
dismissNotification(SKEW_TOAST_ID)
// Backend caught up — forget any prior snooze so a future regression warns
// immediately rather than staying silent for the rest of the window.
persistString(SKEW_TOAST_SNOOZE_KEY, null)
return
}
if (isSkewToastSnoozed()) {
return
}
notify({
action: {
label: translateNow('notifications.updateHermes'),
onClick: () => {
snoozeSkewToast()
void applyBackendUpdate()
}
},
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() },
durationMs: 0,
id: SKEW_TOAST_ID,
kind: 'warning',
message: translateNow('notifications.backendOutOfDateMessage'),
onDismiss: () => snoozeSkewToast(),
title: translateNow('notifications.backendOutOfDateTitle')
})
}
+265 -677
View File
File diff suppressed because it is too large Load Diff
+61 -37
View File
@@ -28,13 +28,14 @@ as_hermes() { [ "$(id -u)" = 0 ] || { "$@"; return; }; s6-setuidgid hermes "$@";
# arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people
# used in the tini era to make container-written files match their host user).
#
# Under s6-overlay this no longer works: the bootstrap (UID remap, data-volume
# ownership, config seeding) requires root, and it is skipped when the container
# starts non-root. The baked install tree under /opt/hermes is intentionally
# root-owned and non-writable; mutable runtime state must live under
# $HERMES_HOME. An arbitrary `--user` UID therefore cannot repair or populate
# the data volume, and startup fails with EACCES. See #34837 for the
# supervision-tree side of this.
# Under s6-overlay this no longer works: the bootstrap (UID remap, volume +
# build-tree chown, config seeding) all require root, and they're skipped when
# the container starts non-root. The baked image trees (/opt/data, /opt/hermes/
# .venv, ui-tui, node_modules) stay owned by the hermes build UID (10000), so an
# arbitrary `--user` UID can't write them — the runtime then fails with EACCES
# on a bind mount, or hard-crashes on a named volume (Docker initialises the
# volume from the image as UID 10000, and the non-root start can't even `cd`
# into $HERMES_HOME). See #34837 for the supervision-tree side of this.
#
# The supported way to match host-side ownership is to start as root (the image
# default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the
@@ -52,10 +53,9 @@ if [ "$cur_uid" != 0 ] && [ "$cur_uid" != "$(id -u hermes)" ]; then
[stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID).
This is not supported under the s6-overlay image. The container bootstrap
(UID remap, data-volume ownership, config seeding) needs to start as root,
and the baked /opt/hermes install tree is intentionally root-owned and
non-writable, so a pinned --user UID cannot repair startup state — startup
will fail.
(UID remap, volume ownership, dependency installs) needs to start as root,
and the baked image directories are owned by the hermes user (UID $(id -u hermes)),
so a pinned --user UID cannot write them — startup will fail.
To make container-written files match your HOST user, DON'T use --user.
Start the container as root (the default) and pass your host UID/GID instead:
@@ -207,13 +207,49 @@ if [ "$needs_chown" = true ]; then
done
fi
# --- Immutable install tree ---
# Do not chown runtime code or dependency trees under $INSTALL_DIR back to the
# hermes user. Hosted/container instances keep mutable state under
# $HERMES_HOME (/opt/data) and run with PYTHONDONTWRITEBYTECODE plus
# HERMES_DISABLE_LAZY_INSTALLS=1. Keeping /opt/hermes root-owned and
# non-writable prevents an agent session from self-modifying the installed
# source, venv, TUI bundle, or node_modules and bricking the gateway.
# --- Fix ownership of build trees under $INSTALL_DIR ---
# Hermes-owned trees under $INSTALL_DIR must be re-chowned whenever the
# runtime hermes UID no longer owns them — otherwise:
# - .venv: lazy_deps.py cannot install platform packages (discord.py,
# telegram, slack, etc.) with EACCES (#15012, #21100)
# - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when
# the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD
# is set) and writes to ui-tui/dist/. Without this chown the new
# hermes UID can't write the build output (#28851).
# - gateway: Python writes __pycache__ and runtime artifacts beneath the
# gateway package on first import. After a UID remap those source-owned
# paths still belong to the build-time UID (10000) unless repaired here,
# producing EACCES for the supervised gateway (#27221).
# - node_modules: root-level dependencies (puppeteer, web tooling)
# that runtime code may walk/update.
# The set mirrors the build-time `chown -R hermes:hermes` line in the
# Dockerfile — keep them in sync if the Dockerfile chown set changes.
# These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount
# concern doesn't apply — recursive is fine.
#
# This MUST be gated independently of the $HERMES_HOME ownership check
# above. `usermod -u <new> hermes` re-chowns the hermes home dir
# ($HERMES_HOME == /opt/data) to the new UID as a side effect, so after a
# HERMES_UID/PUID remap `stat $HERMES_HOME` always already matches the new
# UID and `needs_chown` is false — but the build trees under /opt/hermes
# are NOT touched by usermod and remain owned by the build-time UID
# (10000). Gating them on $HERMES_HOME ownership (as #35027 did) silently
# skipped this chown on the common PUID/NAS path, regressing lazy installs
# and TUI rebuilds. Probe the build trees directly instead: chown only
# when the venv is not already owned by the runtime hermes UID. Idempotent
# and skips the expensive recursive chown on every restart once ownership
# is settled.
venv_owner=$(stat -c %u "$INSTALL_DIR/.venv" 2>/dev/null || echo "")
if [ -n "$venv_owner" ] && [ "$venv_owner" != "$actual_hermes_uid" ]; then
echo "[stage2] Fixing ownership of build trees under $INSTALL_DIR to hermes ($actual_hermes_uid)"
chown -R hermes:hermes \
"$INSTALL_DIR/.venv" \
"$INSTALL_DIR/ui-tui" \
"$INSTALL_DIR/gateway" \
"$INSTALL_DIR/node_modules" \
2>/dev/null || \
echo "[stage2] Warning: chown of build trees failed (rootless container?) — continuing"
fi
# Always reset ownership of $HERMES_HOME/profiles to hermes on every
# boot. Profile dirs and files can land owned by root when commands
@@ -291,25 +327,13 @@ as_hermes mkdir -p \
"$HERMES_HOME/pairing" \
"$HERMES_HOME/platforms/pairing"
# --- Install-method stamp ---
# The 'docker' stamp is baked into the immutable install tree at
# /opt/hermes/.install_method (see Dockerfile), NOT written here into
# $HERMES_HOME. detect_install_method() reads the code-scoped stamp first.
#
# Why we no longer stamp $HERMES_HOME: it is a shared DATA volume, commonly
# bind-mounted from the host (~/.hermes:/opt/data) and sometimes shared with a
# host-side Desktop/CLI install. Stamping 'docker' here clobbered that host
# install's marker, so its in-app updater read 'docker' and refused to run
# 'hermes update'. To heal homes already poisoned by older images, remove a
# stale 'docker' stamp from $HERMES_HOME if one is present (the host install's
# own installer re-creates its code-scoped stamp; a genuine container relies on
# the baked /opt/hermes stamp, so deleting the data-dir copy is safe).
if [ -f "$HERMES_HOME/.install_method" ]; then
stamped="$(tr -d '[:space:]' < "$HERMES_HOME/.install_method" 2>/dev/null || true)"
if [ "$stamped" = "docker" ]; then
rm -f "$HERMES_HOME/.install_method" 2>/dev/null || true
fi
fi
# --- Install-method stamp (read by detect_install_method() in hermes status) ---
# Preserved from the tini-era entrypoint (PR #27843). Must be written as
# the hermes user so ownership matches the file's documented owner.
# tee is invoked directly via s6-setuidgid (no `sh -c` wrapper) for the
# same shell-metacharacter safety described above.
printf 'docker\n' | as_hermes tee "$HERMES_HOME/.install_method" >/dev/null \
|| true
# --- Seed config files (only on first boot) ---
seed_one() {
-260
View File
@@ -1,260 +0,0 @@
# Relay ↔ Connector Contract (v1, EXPERIMENTAL)
> **Status:** EXPERIMENTAL. This contract MAY CHANGE without a deprecation
> cycle until at least two real Class-1 platforms (Discord + Telegram) have
> validated it. Evolution during the experimental phase is **additive-only**,
> gated by `contract_version`. A breaking change updates both repos in lockstep.
This document is the formal interface between the **Hermes gateway** (Python,
`gateway/relay/`) and the **connector** (Node/TypeScript,
`NousResearch/gateway-gateway`). The connector implementer's first action is to
read this file.
The gateway runs a generic `RelayAdapter` that dials **out** to the connector,
receives a `CapabilityDescriptor` at handshake, then exchanges normalized
`MessageEvent`s (inbound) and actions (outbound) over a per-turn bidirectional
WebSocket. The gateway never learns which concrete platform is fronting it; the
connector owns all platform-specific socket/identity logic.
---
## 1. Handshake
1. Gateway opens the transport (`connect`).
2. Gateway calls `handshake()`; connector returns a `CapabilityDescriptor`
(section 2) describing the platform this adapter instance fronts.
3. Gateway configures the adapter from the descriptor (char limit, length unit,
draft/edit/thread/markdown capabilities) and registers an inbound handler.
4. Connector then streams inbound events and accepts outbound actions.
`contract_version` (currently `1`) is carried in the descriptor. The gateway
ignores unknown descriptor fields (forward-compat) and fills missing optional
fields from defaults.
---
## 2. CapabilityDescriptor (handshake payload)
JSON object. Source of truth: `gateway/relay/descriptor.py`.
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `contract_version` | int | yes | Contract version (additive-only within a version). |
| `platform` | string | yes | Platform name (e.g. `"discord"`, `"telegram"`). |
| `label` | string | yes | Human-readable label. |
| `max_message_length` | int | yes | Char limit; gateway exposes as `MAX_MESSAGE_LENGTH`. 0 → treat as 4096. |
| `supports_draft_streaming` | bool | yes | Native draft-streaming preview support. |
| `supports_edit` | bool | yes | Edit-based streaming possible; if false, consumer degrades to one-message-per-segment. |
| `supports_threads` | bool | yes | `create_handoff_thread` capability. |
| `markdown_dialect` | string | yes | `"plain"`, `"markdown_v2"`, `"discord"`, … (drives `supports_code_blocks`). |
| `len_unit` | string | yes | `"chars"` (builtin len) or `"utf16"` (Telegram UTF-16 code units). |
| `emoji` | string | no | Display emoji (default 🔌). |
| `platform_hint` | string | no | System-prompt platform hint. |
| `pii_safe` | bool | no | Redact PII in session descriptions. |
Most fields are a projection of the gateway's existing `PlatformEntry`; the
runtime-only fields (`len_unit`, `supports_*`, `markdown_dialect`) come from the
live platform adapter's capability methods.
---
## 3. Inbound: `MessageEvent` envelope
The connector normalizes each platform wire event into a `MessageEvent`
(`gateway/platforms/base.py`) and delivers it to the gateway. **Inbound is
delivered over a signed HTTP POST, not the outbound `/relay` WebSocket** (see
the transport note below). The gateway keys the session via `build_session_key()`
from the embedded `SessionSource` — so populating the right discriminators is
the single highest-correctness responsibility of the connector.
### Inbound transport (signed HTTP POST, not the outbound WS)
The gateway dials **out** to the connector's `/relay` WebSocket for the
handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound,
however, is delivered the other way: the connector **POSTs** the normalized
event to the gateway's inbound endpoint (`HttpGatewayDelivery` on the connector;
`gateway/relay/inbound_receiver.py` on the gateway). The reason is
multi-instance: the connector instance that owns a platform's socket (and thus
produces inbound events) is generally **not** the instance a given gateway
dialed its outbound WS into, so inbound must target a tenant **endpoint** (which
may load-balance across gateway instances) rather than ride one gateway's
outbound socket. Each delivery is HMAC-signed with the per-tenant **delivery
key** (§6.1); the gateway verifies the signature over the exact raw bytes before
accepting the event. Two POST targets:
- `POST {gatewayEndpoint}``{"type":"message", "event": <MessageEvent>}`
- `POST {gatewayEndpoint}/interrupt``{"type":"interrupt", "session_key", "reason"?}` (§5)
> An earlier draft of this contract delivered inbound over the WS `inbound`
> frame. That only works single-instance and predates the multi-instance
> socket-ownership + channel-auth model; the signed-HTTP path above is the
> shipped design.
### SessionSource fields (the wire surface)
Source of truth: `SessionSource.to_dict()` in `gateway/session.py`. These are
every key the gateway accepts on the wire. `platform`, `chat_id`, `chat_type`,
`user_id`, `user_name`, `thread_id`, `chat_name`, and `chat_topic` are always
present (may be `null`); the rest are included only when set.
| Field | Type | Always sent | Meaning |
| --- | --- | --- | --- |
| `platform` | string | yes | Platform name (matches the descriptor's `platform`). |
| `chat_id` | string | yes | Primary conversation id (channel/chat). Session-key discriminator. |
| `chat_type` | string | yes | `dm` / `group` / `channel` / `thread` / `forum`. |
| `chat_name` | string\|null | yes | Human-readable chat name. |
| `user_id` | string\|null | yes | Message author id. Session-key discriminator. |
| `user_name` | string\|null | yes | Author display name. |
| `thread_id` | string\|null | yes | Thread/forum-topic id when in a thread. Session-key discriminator. |
| `chat_topic` | string\|null | yes | Channel topic/description (Discord, Slack). |
| `user_id_alt` | string | no | Platform-specific stable alt id (Signal UUID, Feishu union_id). |
| `chat_id_alt` | string | no | Alternate chat id (e.g. Signal group internal id). |
| `guild_id` | string | no | Discord guild / Slack workspace / Matrix server scope. **REQUIRED for Discord server isolation.** Session-key discriminator. |
| `parent_chat_id` | string | no | Parent channel when `chat_id` refers to a thread. |
| `message_id` | string | no | Id of the triggering message (for pin/reply/react). |
> `is_bot` (author-is-a-bot/webhook classification) exists on the gateway-side
> dataclass but is **intentionally NOT on the wire** in v1 — it is not part of
> `to_dict()`. Do not add it to the connector's `SessionSource` until it is
> first added here and to `to_dict()` (additive bump).
### SessionSource discriminators per platform
| Platform | chat_id | chat_type | user_id | thread_id | guild_id |
| --- | --- | --- | --- | --- | --- |
| **Discord** | channel id | `dm`/`group`/`thread` | author id | thread channel id (threads) | **guild id** (REQUIRED for server isolation) |
| **Telegram** | chat id | `dm`/`group`/`forum` | from id | forum topic id (forums) | — |
**Get Discord's `guild_id` wrong and two servers collide into one session.**
This is the #1 High-severity risk. The gateway's `build_session_key()` is the
conformance oracle: for a given `SessionSource`, the connector's normalization
must produce the same key the Python adapter would. (The Phase-1 stub tests
assert known-input → known-key.)
### Bot identity vs tenant (single-bot consolidation, Appendix A)
The envelope carries the **originating bot identity** as a field **distinct from
tenant**. Tenant is resolved from the event's own discriminator (Discord
`guild_id`, Telegram `chat_id`, webhook path/subdomain) — **never** from which
token/socket/process delivered it. This keeps one shared bot able to front many
tenants (Phase 6) without overloading an existing field.
---
## 4. Outbound: action set
The gateway calls the transport with action dicts. Source of truth:
`gateway/relay/transport.py` + `gateway/relay/adapter.py`.
| `op` | Fields | Result |
| --- | --- | --- |
| `send` | `chat_id`, `content`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` |
| `edit` | `chat_id`, `message_id`, `content`, `metadata?` | `{success: bool, error?}` |
| `typing` | `chat_id` | `{success: bool}` |
| `follow_up` | `session_key`, `kind`, `content`, `metadata?` | `{success: bool, message_id?, error?}` |
`get_chat_info(chat_id)` is a separate proxied call returning at least
`{name, type}`. Media actions follow the same envelope shape (deferred to a
later contract revision; additive).
**`follow_up` (A2 capability action).** Some inbound payloads carry a credential
that acts on the **shared** bot identity (e.g. a Discord interaction follow-up
token). Per §6 the connector strips that at the edge and binds it in its
capability vault keyed by the session; it **never reaches the gateway**. To use
it, the gateway issues `follow_up` naming the **session it is already in**
(`session_key`) plus the capability `kind` (e.g. `discord.interaction_token`) —
**never a token**. The connector resolves the real value from its vault,
enforces the tenant match (tenant B can never wield tenant A's capability), and
egresses. `success: false` when the capability is absent/expired or the tenant
doesn't match — the gateway has nothing to retry with, by design (a leaked
gateway holds zero capability material). Source of truth:
`gateway/relay/transport.py` (`send_follow_up`) + `gateway/relay/adapter.py`.
---
## 5. Interrupt (`/stop`) routing
- **Gateway → connector:** `send_interrupt(session_key, reason?)` egresses a
mid-turn `/stop` over the outbound WS. The connector MUST forward it to the
gateway instance running that `session_key` (the routing invariant).
- **Connector → gateway:** an inbound interrupt for a `session_key` is delivered
as a **signed HTTP POST** to `{gatewayEndpoint}/interrupt` (§3 transport note),
and bridged by the adapter's `on_interrupt(session_key, chat_id)` into the
existing per-session interrupt mechanism, cancelling exactly that turn
(siblings untouched).
The gateway→connector `/stop` rides the outbound WS; the connector→gateway
interrupt rides the same signed-HTTP inbound path as a normalized event.
---
## 6. Trust boundary & signed-body handling (A2)
**The connector is the sole crypto/identity boundary. The gateway re-validates
nothing.**
Webhook signatures (Discord ed25519, Twilio HMAC, WeCom BizMsgCrypt) are
computed over exact raw bytes, and some payloads are *encrypted* with a shared
secret. The connector fronts a **shared** bot for many tenants and holds every
tenant's platform secrets, so it:
- **verifies / decrypts at the edge** (the only place the secrets live),
- **normalizes** the payload into a tenant-scoped `MessageEvent` (§3),
- **strips any shared-identity capability** out of the payload and binds it in
its capability vault, keyed by the session (see §4 `follow_up`),
- **forwards only the sanitized `MessageEvent`** — never the raw signed body.
The gateway therefore performs **no** platform signature/crypto verification on
the relay path; it trusts the normalized event. This is an enforced invariant on
the gateway side (`tests/gateway/relay/test_relay_sheds_crypto.py`: the relay
package imports/calls no platform-crypto).
**Why not "forward the signed body byte-for-byte so the gateway re-validates"?**
That earlier model is incoherent under an untrusted, disposable tenant gateway:
- Re-validating Twilio HMAC / WeCom crypto would require handing the gateway the
**shared signing secret** — which is itself the leak, and on a shared bot it's
a *cross-tenant* leak.
- WeCom payloads are encrypted with the shared secret; the connector must decrypt
at the edge just to route, so forwarding ciphertext would again require giving
the gateway the secret.
- A Discord interaction token lives **inside** the signed JSON body — you cannot
both preserve the bytes and strip the credential; they are the same bytes.
So byte-preservation is abandoned deliberately: the connector re-serializes the
sanitized event and the gateway trusts it. This also unifies the passthrough and
relay planes — both are "verify at the edge → emit a normalized event," differing
only in transport. See `docs/capability-trust-boundary.md` (connector repo:
`gateway-gateway`) for the full A2 rationale and the connector-side vault.
### 6.1 Channel authentication (the connector⇄gateway link itself)
A2 makes the connector the sole holder of platform secrets while the gateway may
be **customer-managed and internet-exposed**, so the connector⇄gateway channel
is itself authenticated. The gateway holds two enrollment-issued credentials
(`hermes gateway enroll` → connector `/relay/enroll`): a **per-gateway secret**
and a **per-tenant delivery key**. Both are HMAC-SHA256 schemes with a
multi-secret rotation verify list (gateway side: `gateway/relay/auth.py`;
connector side: `src/core/relayAuthToken.ts` + `src/core/deliverySigning.ts`).
| Leg | Credential | Mechanism |
|-----|-----------|-----------|
| Gateway → connector WS upgrade | per-gateway secret | An `Authorization` bearer header on the `/relay` upgrade. The token is `base64url(payload:exp:sig)` where `payload = gatewayId` and `sig = HMAC(payload:exp, secret)`. Connector verifies and rejects the upgrade (**close 4401**) on mismatch/absence/revocation. The authenticated tenant comes from the connector's store, never the `hello` frame. |
| Connector → gateway inbound POST | per-tenant delivery key | Two headers: `x-relay-timestamp` (unix seconds) and `x-relay-signature` (hex `HMAC(ts.rawBody, deliveryKey)`). Gateway verifies over the **exact raw bytes** within a ±300s replay window before accepting the event; rejects **401** otherwise. |
This is the **channel** authenticator — distinct from platform crypto, which the
relay path still sheds entirely (§6). The gateway holds zero platform secrets;
these two keys authenticate only the connector link. Full threat model +
enrollment/rotation/kill-switch design: `docs/connector-gateway-auth-design.md`
(connector repo).
---
## 7. Versioning policy
- `contract_version` is an int; bump **only** for additive changes during the
experimental phase (new optional fields, new `op`s).
- A breaking change (renamed/removed field, changed semantics) requires a
coordinated update of both repos and a version bump.
- The connector's first PR references the commit SHA of this file it implements
against.
-8
View File
@@ -164,7 +164,6 @@ class Platform(Enum):
BLUEBUBBLES = "bluebubbles"
QQBOT = "qqbot"
YUANBAO = "yuanbao"
RELAY = "relay" # generic relay adapter fronted by the connector (EXPERIMENTAL)
@classmethod
def _missing_(cls, value):
"""Accept unknown platform names only for known plugin adapters.
@@ -493,13 +492,6 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
),
# Relay dials OUT to a connector; it is "connected" once an endpoint URL is
# configured (extra["relay_url"] or extra["url"]). The capability descriptor
# is negotiated at handshake time, so the URL is the only config-level
# signal in the experimental phase. EXPERIMENTAL — may change.
Platform.RELAY: lambda cfg: bool(
cfg.extra.get("relay_url") or cfg.extra.get("url")
),
}
-166
View File
@@ -1,166 +0,0 @@
"""Helpers for rendering gateway message timestamps exactly once.
Gateway messages need timestamps in the LLM context for temporal awareness, but
persisted message content should stay clean so replay does not accumulate
``[timestamp] [timestamp] ...`` prefixes across turns.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Any, Optional, Tuple
# Current gateway format: [Tue 2026-04-28 13:40:53 CEST]
_HUMAN_TIMESTAMP_RE = re.compile(
r"^\[(?P<dow>[A-Z][a-z]{2}) "
r"(?P<date>\d{4}-\d{2}-\d{2}) "
r"(?P<time>\d{2}:\d{2}:\d{2})"
r"(?: (?P<tz>[A-Za-z0-9_+\-/:]+))?\]\s*"
)
# Older gateway format: [2026-04-13T17:02:06+0200] or [+02:00]
_ISO_TIMESTAMP_RE = re.compile(
r"^\[(?P<iso>\d{4}-\d{2}-\d{2}T[^\]]+)\]\s*"
)
def coerce_message_timestamp(ts_value: Any, tz=None) -> Optional[float]:
"""Coerce a timestamp-like value to Unix epoch seconds.
Accepts Unix epoch numbers, datetime objects, ISO strings, and the gateway's
bracketed human-readable timestamp format. Returns ``None`` when the value
cannot be interpreted.
"""
if ts_value is None:
return None
if isinstance(ts_value, (int, float)):
return float(ts_value)
if hasattr(ts_value, "timestamp"):
try:
return float(ts_value.timestamp())
except Exception:
return None
if isinstance(ts_value, str):
text = ts_value.strip()
if not text:
return None
parsed = _parse_timestamp_prefix(text, tz=tz)
if parsed is not None:
return parsed
try:
return float(text)
except (TypeError, ValueError):
pass
try:
dt = datetime.fromisoformat(text)
except (TypeError, ValueError):
try:
dt = datetime.strptime(text, "%Y-%m-%dT%H:%M:%S%z")
except (TypeError, ValueError):
return None
if dt.tzinfo is None:
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())
return None
def format_message_timestamp(ts_value: Any, tz=None) -> str:
"""Format a timestamp value as ``[Tue 2026-04-28 13:40:53 CEST]``."""
epoch = coerce_message_timestamp(ts_value, tz=tz)
if epoch is None:
return ""
if tz is not None:
dt = datetime.fromtimestamp(epoch, tz=tz)
else:
dt = datetime.fromtimestamp(epoch).astimezone()
return "[" + dt.strftime("%a %Y-%m-%d %H:%M:%S %Z") + "]"
def strip_leading_message_timestamps(content: str, tz=None) -> Tuple[str, Optional[float]]:
"""Strip one or more leading gateway timestamp prefixes from ``content``.
Returns ``(clean_content, embedded_epoch)``. If multiple timestamp prefixes
are present, the timestamp closest to the actual message text wins. That
preserves the original platform-send time for legacy contaminated rows like
``[processing time] [platform time] [sender] message``.
"""
if not isinstance(content, str) or not content:
return content, None
text = content
embedded_epoch: Optional[float] = None
while True:
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
if not match:
break
parsed = _parse_timestamp_match(match, tz=tz)
if parsed is not None:
embedded_epoch = parsed
text = text[match.end():]
return text, embedded_epoch
def render_user_content_with_timestamp(content: str, ts_value: Any = None, tz=None) -> str:
"""Render a user message for LLM context with exactly one timestamp prefix.
Existing leading timestamp prefixes are removed first. If such a prefix was
present, its parsed time wins over ``ts_value``; otherwise ``ts_value`` is
formatted and prepended. If no timestamp is available, the cleaned content is
returned unchanged.
"""
clean_content, embedded_epoch = strip_leading_message_timestamps(content, tz=tz)
effective_ts = embedded_epoch if embedded_epoch is not None else ts_value
prefix = format_message_timestamp(effective_ts, tz=tz)
if not prefix:
return clean_content
if clean_content:
return f"{prefix} {clean_content}"
return prefix
def _parse_timestamp_prefix(text: str, tz=None) -> Optional[float]:
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
if not match:
return None
return _parse_timestamp_match(match, tz=tz)
def _parse_timestamp_match(match: re.Match, tz=None) -> Optional[float]:
if "iso" in match.groupdict() and match.group("iso"):
iso_text = match.group("iso")
try:
dt = datetime.fromisoformat(iso_text)
except ValueError:
try:
dt = datetime.strptime(iso_text, "%Y-%m-%dT%H:%M:%S%z")
except ValueError:
return None
if dt.tzinfo is None:
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())
date_part = match.group("date")
time_part = match.group("time")
try:
dt = datetime.strptime(f"{date_part} {time_part}", "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())

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