feat(themes): agent-authored skins switch live via a gateway skin watcher

A skin Hermes activates (`hermes config set display.skin X`) or recolors in
place now goes live on every surface (CLI, TUI, desktop) within ~half a
second, on its own — no `/skin`, no tool-hook timing, no user action.

A gateway daemon polls the resolved skin signature `(name, active-file mtime)`
every 0.5s and broadcasts `skin.changed` on any real move — a name switch OR a
live color edit to the active skin. It routes through the SAME path `/skin`
uses, so all surfaces repaint identically. The watcher seeds its baseline at
gateway.ready (stdio + ws) so it only fires on a real change; the `/skin` RPC
seeds the baseline too so it never double-broadcasts.

Subsumes the desktop's post-turn `config.get skin` poll (its skin.changed
handler already applies).
This commit is contained in:
Brooklyn Nicholson 2026-07-21 16:03:37 -05:00
parent 91c5c0c1a6
commit eb454919b2
6 changed files with 117 additions and 40 deletions

View file

@ -1,10 +1,8 @@
import type { HermesSkin } from '@hermes/shared/skin'
import { type MutableRefObject, useCallback, useRef, useState } from 'react'
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
import { normalize } from '@/lib/text'
import { $gateway } from '@/store/gateway'
import {
$currentCwd,
getComposerSelectionGeneration,
@ -18,9 +16,6 @@ import {
setIntroPersonality
} from '@/store/session'
import { applyAutoSpeakFromConfig } from '@/store/voice-prefs'
// Leaf import (not the `@/themes` barrel) so this hook doesn't drag in the
// ThemeProvider/profile module graph — keeps it decoupled and test-mockable.
import { ingestBackendSkin } from '@/themes/backend-sync'
const DEFAULT_VOICE_SECONDS = 120
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
@ -116,16 +111,6 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
applyAutoSpeakFromConfig(config)
// Cross-surface skin sync: a skin Hermes authors/activates from a prompt
// edits config.yaml directly, which never emits `skin.changed`. The
// post-turn config refresh is our catch-all — fetch the resolved palette
// and repaint if the active skin name actually changed (guarded upstream).
void $gateway
.get()
?.request<{ skin?: HermesSkin }>('config.get', { key: 'skin' })
.then(res => ingestBackendSkin(res?.skin, { apply: true }))
.catch(() => undefined)
} catch {
// Config is nice-to-have; chat still works without it.
}

View file

@ -23,6 +23,8 @@ editors or ship built-in presets.
- The user asks for a custom look ("make me a synthwave theme", "dark forest
vibes", "match my brand colors") for Hermes itself.
- The user wants the CLI/TUI/desktop to share one coordinated palette.
- The user wants to iterate live ("that coral is too loud, make it teal") — edit
the active skin's YAML and every surface repaints as your tool finishes.
## Prerequisites
@ -68,21 +70,18 @@ full schema in `hermes_cli/skin_engine.py`.
`ui_ok`/`ui_warn`/`ui_error` recognizably green/amber/red.
2. **Write the file** to `<hermes-home>/skins/<name>.yaml`. Every top-level
`colors` key from the template should be present.
3. **Activate — never hand-edit `config.yaml`.** Persist the choice with the safe
writer via `terminal`:
3. **Apply it yourself — never hand-edit `config.yaml`.** Run the safe writer via
`terminal`:
```
hermes config set display.skin <name>
```
This is the source of truth all surfaces read; it writes valid YAML so it
can't corrupt the file (a bad hand-edit can break the running gateway,
including the `/` menu).
- **Desktop**: repaints automatically after the current turn, and the skin
appears in Appearance / `Cmd-K` / `/skin`.
- **CLI / TUI**: a running session does not hot-reload a config-file change —
you can't switch it live from a tool call. **Tell the user to run
`/skin <name>`** for an instant switch (it also persists); otherwise it
loads on next start.
4. **Confirm** and tell the user how to switch back: `/skin default`.
The gateway's skin watcher notices the change and **repaints every surface live
within ~a second** — CLI, TUI, and desktop — and the skin appears in
Appearance / `Cmd-K` / `/skin`. You apply it; do NOT tell the user to run
`/skin` (they still can, but it's your job). The writer emits valid YAML — a
hand-edit can corrupt the file and break the live gateway (including `/`).
4. **Confirm the new look landed** and tell the user how to revert: run
`hermes config set display.skin default` (or they can `/skin default`).
## Pitfalls
@ -98,14 +97,13 @@ full schema in `hermes_cli/skin_engine.py`.
- **Never hand-edit `config.yaml` to activate.** Use `hermes config set
display.skin <name>` — a stray indent in a manual edit corrupts the file and
can break the live gateway (including `/`). One command, always valid.
- **A tool call can't live-switch a running CLI/TUI.** Only `/skin <name>`
(typed by the user) or a restart applies it in-session — say so instead of
claiming it switched.
- **You apply it, not the user.** `hermes config set display.skin <name>` is
enough — the gateway's watcher repaints every surface within ~a second. Don't
defer to "type /skin yourself"; that's the old behavior.
## Verification
- `read_file` the written `<hermes-home>/skins/<name>.yaml` and confirm valid
YAML with the intended `name` and `colors`.
- Run `hermes config get display.skin` and confirm it reports `<name>`.
- Ask the user to confirm the new look (desktop repaints on the next turn; CLI/TUI
after `/skin <name>` or restart).
- The repaint lands as this turn ends — ask the user to confirm the new look.

View file

@ -2015,3 +2015,21 @@ def test_slow_completion_does_not_block_fast_handler(completion_method, server):
assert fast_elapsed < 2.0, f"fast handler blocked for {fast_elapsed:.2f}s behind {completion_method}"
released.set()
def test_broadcast_skin_if_changed_on_any_signature_move(server, monkeypatch):
"""A skin the agent changes mid-turn goes live once per real move: a name
switch (incl. switch-then-revert) OR an in-place color edit to the active skin
(same name, new file mtime). An unchanged signature never re-broadcasts."""
emitted = []
# switch, no-op, switch, then a color edit (same name, bumped mtime).
sigs = iter([("neon", 1.0), ("neon", 1.0), ("forest", 1.0), ("forest", 2.0)])
monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload)))
monkeypatch.setattr(server, "_last_skin_sig", None, raising=False)
monkeypatch.setattr(server, "_skin_sig", lambda: next(sigs))
monkeypatch.setattr(server, "resolve_skin", lambda: {"name": "x", "colors": {}})
for _ in range(4):
server._broadcast_skin_if_changed()
assert [ev for ev, _ in emitted] == ["skin.changed"] * 3

View file

@ -398,6 +398,9 @@ def main():
_log_exit("startup write failed (broken stdout pipe before first event)")
sys.exit(0)
# Live-apply skins Hermes activates mid-conversation.
server._ensure_skin_watcher()
while True:
raw = sys.stdin.readline()
if not raw:

View file

@ -2422,6 +2422,80 @@ def resolve_skin() -> dict:
return {}
# Signature of the last skin broadcast: (name, active user-file mtime). Lets the
# per-tool reconcile fire ``skin.changed`` on any real move — a name switch OR a
# live color edit to the active skin — and nothing else.
_last_skin_sig: tuple[str, float | None] | None = None
def _skin_sig() -> tuple[str, float | None]:
"""(active skin name, its user-file mtime). Built-ins have no file, so only
their name moves; a user skin's mtime lets an in-place color edit repaint too."""
name = str((_load_cfg().get("display") or {}).get("skin") or "default")
override = get_hermes_home_override()
home = override if isinstance(override, str) and override else _hermes_home
try:
mtime: float | None = (Path(home) / "skins" / f"{name}.yaml").stat().st_mtime
except OSError:
mtime = None
return name, mtime
def _note_skin_broadcast() -> None:
"""Sync the reconcile baseline after the /skin RPC emits, so the per-tool
check doesn't re-broadcast the skin /skin just applied."""
global _last_skin_sig
try:
_last_skin_sig = _skin_sig()
except Exception:
pass
def _broadcast_skin_if_changed() -> None:
"""Emit ``skin.changed`` when the active skin moved — the agent switched it
(``hermes config set display.skin``) OR edited the active skin's colors in
place ("I don't like that coral" tweak the YAML).
Routes through the SAME live path as ``/skin`` so every surface (TUI + desktop)
repaints, no slash command. The signature check is a dict lookup + one stat,
so polling it is ~free.
"""
global _last_skin_sig
try:
sig = _skin_sig()
except Exception:
return
if sig == _last_skin_sig:
return
_last_skin_sig = sig
try:
_emit("skin.changed", "", resolve_skin())
except Exception:
pass
_skin_watcher_started = False
def _ensure_skin_watcher() -> None:
"""Poll the config for skin changes and broadcast ``skin.changed`` — so a skin
Hermes activates (``hermes config set display.skin``) or recolors goes live on
every surface within ~half a second, on its own, with no tool-hook or slash
command in the loop. Idempotent; started at gateway.ready."""
global _skin_watcher_started
if _skin_watcher_started:
return
_skin_watcher_started = True
_note_skin_broadcast() # seed the baseline so only a real change repaints
def _loop() -> None:
while True:
time.sleep(0.5)
_broadcast_skin_if_changed()
threading.Thread(target=_loop, name="hermes-skin-watcher", daemon=True).start()
def _resolve_model() -> str:
env = (
os.environ.get("HERMES_MODEL", "")
@ -11917,6 +11991,9 @@ def _(rid, params: dict) -> dict:
nv = value
if key == "skin":
_emit("skin.changed", "", resolve_skin())
# Keep the reconcile baseline in sync so the per-tool check
# doesn't re-broadcast the skin the /skin RPC just applied.
_note_skin_broadcast()
resp = {"key": key, "value": nv}
if key == "personality":
resp["history_reset"] = history_reset
@ -12556,15 +12633,8 @@ def _(rid, params: dict) -> dict:
if key == "prompt":
return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")})
if key == "skin":
# `value` is the active skin name (back-compat, used by the TUI). `skin`
# carries the full resolved palette so cross-surface consumers (the
# desktop) can rebuild the theme without their own YAML loader.
return _ok(
rid,
{
"value": (_load_cfg().get("display") or {}).get("skin", "default"),
"skin": resolve_skin(),
},
rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")}
)
if key == "indicator":
# Normalize so a hand-edited config.yaml with stray casing or

View file

@ -326,6 +326,9 @@ async def handle_ws(ws: Any) -> None:
},
}
)
if ready_ok:
# Live-apply skins Hermes activates mid-conversation.
server._ensure_skin_watcher()
if not ready_ok:
disconnect_reason = "ready_send_failed"
send_failures += 1