Compare commits

..

25 Commits

Author SHA1 Message Date
Brooklyn Nicholson
d30e9b9fb5 feat(skins): add bunnny — barbie-pink coquette theme ♡
Adds a built-in 'bunnny' skin preset with a hot-pink coquette palette:

- Hot pink (#FF3366) borders with Barbie-pink (#FF69B4) accents
- Lavender-blush (#FFF0F5) text on deep-plum (#2A0E1E) surfaces
- Coquette spinner verbs (sparkling, twirling, tying a little bow)
- Heart/sparkle/flower spinner faces (♡ ✧ ✿ ❀ ෆ)
- Heart (♡) prompt symbol and tool prefix
- (ノ◕ヮ◕)ノ*:・゚✧ kaomoji in welcome + help header
- Custom HERMES <3 banner_logo in pink gradient
- banner_hero of twin coquette bunnies holding paws, framed with
  floating sparkles, hearts, and flowers to fill the banner width

Skin is cosmetic only — agent_name stays 'Hermes Agent'. Adds entry
to the skins.md docs table and ignores .venv/ in .gitignore.
2026-04-29 19:23:31 -05:00
Teknium
1d4218be56 feat(review): active-update bias, loaded-skill-first, support-file variants (#17213)
The background skill-review prompts (_SKILL_REVIEW_PROMPT and the **Skills**
half of _COMBINED_REVIEW_PROMPT) steered the reviewer toward passive
behavior — most passes concluded 'Nothing to save.' even when the session
produced real lessons. User-preference corrections (style, format,
legibility, verbosity) were especially lost: they were read as memory
signals only, so skills never carried the fix.

This rewrite changes the stance:

- **Active-update bias.** The reviewer now treats inaction as a missed
  learning opportunity. 'Nothing to save.' remains an explicit escape
  but is no longer framed as the most-common outcome.

- **User-preference corrections are first-class skill signals.** Style,
  tone, format, legibility, verbosity complaints — and the actual
  phrasings users use ('stop doing X', 'this is too verbose', 'I hate
  when you Y', 'remember this') — now warrant patching the skill that
  governs the task, not just writing to memory.

- **Loaded-skill-first preference order.** When a skill was loaded via
  /skill-name or skill_view during the session, the reviewer patches
  THAT one first. It was in play; it's the right place.

- **Four-step ladder: patch-loaded → patch-umbrella → support-file →
  create.** Support files are explicitly enumerated as three kinds:
    * references/<topic>.md — session-specific detail OR condensed
      knowledge banks (quoted research, API docs excerpts, domain notes)
    * templates/<name>.<ext> — starter files to copy and modify
    * scripts/<name>.<ext>  — statically re-runnable actions

- **Name-veto for CREATE.** New skill names MUST be class-level — no PR
  numbers, error strings, codenames, library-alone names, or session
  artifacts ('fix-X / debug-Y / audit-Z-today'). If the proposed name
  only fits today's task, fall back to one of the patch/support-file
  options.

- **Memory scope clarified.** 'who the user is and what the current
  situation and state of your operations are' — MEMORY.md is
  situational/state, USER.md is identity/preferences.

- **Curator handoff.** Reviewer flags overlap; the background curator
  handles consolidation at scale. Single-session reviewer doesn't
  attempt umbrella-rebalancing.

Tests: tests/run_agent/test_review_prompt_class_first.py upgraded to
assert the new behavioral contracts (active bias, user-correction
signals, loaded-skill-first, support-file kinds, name-veto, memory
framing, curator handoff). 17 tests, all pass.

Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 21:11:48 -07:00
Teknium
8c892c1453 refactor(redact): canonical mask_secret helper; fix status.py DIM drift (#17207)
Three modules independently implemented the same "preserve head+tail of
a secret, mask the middle" logic with slightly different behaviors that
had started to drift:

  hermes_cli/config.py redact_key  — 12-char floor, 4+4, DIM '(not set)'
  hermes_cli/status.py redact_key  — 12-char floor, 4+4, plain '(not set)'  ← drift
  hermes_cli/dump.py _redact       — 12-char floor, 4+4, empty string

The visible bug: 'hermes status' displayed the '(not set)' placeholder
in plain text while 'hermes config' showed it in dim text. Same concept,
inconsistent UI.

Introduces mask_secret() in agent/redact.py as the canonical helper,
with head/tail/floor/placeholder/empty kwargs. The three call sites
become one-line wrappers that differ only in the 'empty' handling:

  config.redact_key  → mask_secret(k, empty=color('(not set)', Colors.DIM))
  status.redact_key  → mask_secret(k, empty=color('(not set)', Colors.DIM))
  dump._redact       → mask_secret(v)  # empty → ''

agent.redact._mask_token (log redactor, different policy: 18-char floor,
6+4 visible, '***' on empty) also ports to mask_secret but retains its
own empty-case handling to preserve the historical '***' return.

Net: the three display-time redactors now agree on formatting, the
canonical helper lives in one place, and future tweaks (e.g. adding
bullet-point masking, changing the head/tail widths) happen once.

Verified:
- 3/3 tests/hermes_cli/test_web_server.py::TestRedactKey pass
- 89/89 agent/tests/test_redact.py + tests/tools/test_browser_secret_exfil.py
  + tests/hermes_cli/test_redact_config_bridge.py pass
- Live 'hermes status', 'hermes config', 'hermes dump' all render the
  same way they did before (verified against actual env with real
  keys: OpenRouter, Firecrawl, Browserbase, FAL, Tinker all show
  'prefix...suffix'; Kimi shows '***' at <12 chars; unset shows
  '(not set)' uniformly).

Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 21:04:35 -07:00
brooklyn!
6e9691ff12 Merge pull request #17237 from NousResearch/bb/tui-paste-watchdog
fix(tui): stabilize sticky prompts and paste recovery
2026-04-28 20:22:44 -07:00
Brooklyn Nicholson
10ad7006b6 fix(tui): use paste timeout when rearming paste watchdog
Match the buffered-stdin rearm cadence to IN_PASTE state so large pastes do not spin the normal escape timeout while waiting for readable data to drain.
2026-04-28 22:21:44 -05:00
Brooklyn Nicholson
f542d17b00 style(tui): apply npm run fix
Run the TUI lint autofix and formatter on the PR branch after the sticky prompt and paste recovery changes.
2026-04-28 22:18:26 -05:00
Brooklyn Nicholson
d7ae8dfd0a style(tui): remove steer queued emoji
Keep the /steer acknowledgement plain text so it reads like the rest of the TUI status copy.
2026-04-28 22:15:57 -05:00
Brooklyn Nicholson
ce2cc7302e fix(tui): stabilize sticky prompt tracking
Keep the latest prompt sticky while the viewport is in live assistant output beyond history, and clear stale sticky state at the real bottom using fresh scroll height.
2026-04-28 22:10:40 -05:00
Brooklyn Nicholson
afb20a1d67 fix(tui): recover from stuck paste mode
Prevent unterminated bracketed paste input from swallowing future keystrokes, and avoid rendering an empty Thinking panel before reasoning arrives.
2026-04-28 22:06:27 -05:00
Teknium
cd7150a195 perf(approval): precompile DANGEROUS_PATTERNS and HARDLINE_PATTERNS (#17206)
detect_dangerous_command() and detect_hardline_command() were calling
re.search(pattern, text, re.IGNORECASE | re.DOTALL) inline — Python's
re._cache (512 patterns) amortizes compile cost on the warm path, but:

  1. The first terminal() call per process pays the full compile fan-out
     for all 59 patterns (12 HARDLINE + 47 DANGEROUS). Measured at
     ~2.6 ms per detect_dangerous_command() call after re.purge().
  2. The re._cache is LRU — unrelated regex work elsewhere in the agent
     (response parsing, text normalization, etc.) can evict our patterns
     and silently re-compile them on the next terminal() call.

Precompiling at module load eliminates both costs:

  detect_dangerous_command:
    cold  2.613 ms  →  0.298 ms   (-88%)
    warm  0.042 ms  →  0.004 ms   (-90%)
  detect_hardline_command:
    cold  ~0.6 ms   →  0.006 ms
    warm  0.011 ms  →  0.002 ms

Savings are per terminal() call. Agents with heavy terminal use see
compound savings; the bigger value is the stability guarantee (no
re._cache eviction can silently re-introduce the 2.6 ms cold cost
mid-session).

Implementation:
- HARDLINE_PATTERNS_COMPILED and DANGEROUS_PATTERNS_COMPILED built at
  module load from the existing (pattern, description) tuples, using
  shared _RE_FLAGS = re.IGNORECASE | re.DOTALL.
- detect_* functions now iterate the compiled list and call pattern_re.search(text).
- Original HARDLINE_PATTERNS and DANGEROUS_PATTERNS lists kept as-is
  (other code in the file uses them for key derivation /
  _PATTERN_KEY_ALIASES).

Verified:
- 160/161 tests/tools/test_approval*.py pass (1 pre-existing heartbeat
  test flake on main).
- 349/349 tests/tools/ 'approval or terminal or dangerous' pass.
- Live hermes chat smoke: 3 benign terminal commands + 1 rm -rf /tmp/
  (clarify prompt fired — approval path still works) + 1 sudo (sudo
  password prompt fired — DANGEROUS pattern match still works). 23
  log lines in the smoke window, zero errors.

Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:44:14 -07:00
Teknium
adef1f33ab chore(release): map scott@scotttrinh.com -> scotttrinh (#17203)
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:28:49 -07:00
Teknium
fe295f9836 docs(hooks): tutorial — build a BOOT.md startup checklist (#17202)
Replace the removed built-in boot-md hook (#17093) with a how-to that
shows users how to wire up the same behavior themselves via the hooks
system. Uses _resolve_gateway_model() + _resolve_runtime_agent_kwargs()
so the example works against custom endpoints and OAuth providers,
not just the aggregator defaults that the old built-in silently assumed.

Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:27:48 -07:00
Scott Trinh
fd943461ca fix(doctor): accept catalog provider aliases
Validate configured providers against both Hermes runtime provider ids and
catalog-normalized provider ids. This keeps providers like ai-gateway from
being rejected after catalog resolution maps them to models.dev ids.

Keep credential checks and vendor-slug warnings anchored to the runtime id
so doctor reports actionable provider names in follow-up diagnostics.
2026-04-28 18:27:42 -07:00
Teknium
9f004b6d94 perf(tools): memoize get_tool_definitions + TTL-cache check_fn results (#17098)
Two amplifying optimizations to per-turn overhead in the gateway:

1. get_tool_definitions() memoization (model_tools.py)
   Keyed on (frozenset(enabled), frozenset(disabled),
   registry._generation, config.yaml mtime+size). Only active when
   quiet_mode=True (which is every hot-path caller — gateway,
   AIAgent.__init__); quiet_mode=False keeps the existing print side
   effects. Cached path returns a shallow-copy list sharing read-only
   schema dicts.

   Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway
   constructs fresh AIAgent per message, so this saves ~7 ms/turn before
   any LLM work.

2. check_fn() TTL cache (tools/registry.py)
   check_fn callables like check_terminal_requirements probe external
   state (Docker daemon, Modal SDK, playwright binary). For a long-lived
   process, hitting them on every get_definitions() pass was pure waste
   — external state changes on human timescales. 30 s TTL so env-var
   flips (hermes tools enable X) propagate within a turn or two without
   explicit invalidation.

   Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate);
   subsequent calls ~0.01ms via the upstream memoization.

Invalidation surface:
- registry._generation bumps on register/deregister/register_toolset_alias,
  invalidating the memoized definitions automatically.
- config.yaml mtime in the cache key captures user-visible config edits
  affecting dynamic schemas (execute_code mode, discord allowlist).
- invalidate_check_fn_cache() exposed for explicit flushes (e.g. after
  hermes tools enable/disable).
- tests/conftest.py autouse fixture clears both caches before every test
  so env-var monkeypatches don't see stale results.

Also fixes a regression from PR #17046 that I missed:
- tools/web_tools.py — Firecrawl was removed from module scope by the
  lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'.
  Applied the same _FirecrawlProxy pattern used in auxiliary_client/
  run_agent for OpenAI (module-level proxy that looks like the class
  but imports the SDK on first call/isinstance; patch() replaces the
  attribute as usual).

Verified:
- 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main)
- 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in
  the full suite due to check_fn TTL cross-test pollution; fixed by
  the autouse fixture)
- 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp
  dynamic discovery, 5 mcp structured content — all confirmed on main)
- 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails)
- 868/868 tests/run_agent/ (excluding test_run_agent.py which has
  pre-existing suite-level issues)
- Live smoke: 2 turns + /model switch + tool calls, zero errors in
  agent.log session window.

Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:20:17 -07:00
brooklyn!
188eaa57c4 fix(tui): honor documented mouse_tracking config key (#17188)
* fix(tui): honor documented mouse_tracking config key

The TUI runtime was reading display.tui_mouse while docs and user-facing
examples pointed users at display.mouse_tracking. That made persistent
mouse-disable config look like a no-op for users trying to restore native
terminal selection/copy behavior on Linux/SSH/tmux terminals.

Use display.mouse_tracking as the canonical key, keep display.tui_mouse as
a legacy fallback, and have /mouse write the documented key. Both gateway
config.get and client-side config sync now share the same precedence: the
canonical key wins, then the legacy key, then default on.

* review(copilot): align mouse tracking config coercion

- Load gateway config once before deriving display.mouse_tracking state.
- Use key-presence precedence on the TUI client too, so canonical
  mouse_tracking wins over legacy tui_mouse even when the value is null.
- Treat numeric 0 as disabled on both gateway and client, matching the
  existing string "0" handling.
- Widen ConfigDisplayConfig mouse fields because config.get full returns raw
  YAML, not normalized booleans.
2026-04-28 17:39:07 -07:00
brooklyn!
6b09df39be fix(tui): restore macOS copy behavior and theme polish (#17131)
This PR groups the TUI fixes that restore macOS Terminal usability and clean up the theme/composer regressions:

- copy transcript selections on macOS drag-release so Terminal.app users can copy while mouse tracking is enabled
- copy composer selections on macOS drag-release; composer selection is internal to TextInput and does not use the global Ink selection bus
- keep IDE Cmd+C forwarding setup macOS-only, and make keybinding conflict checks respect simple when-clause overlap/negation
- force truecolor before chalk initializes (unless NO_COLOR / FORCE_COLOR / HERMES_TUI_TRUECOLOR opt-outs apply) so the default banner keeps its gold/amber/bronze gradient in Terminal.app
- move TUI surfaces onto semantic theme tokens and preserve skin prompt symbols as bare tokens with renderer-owned spacing
- render focused placeholders as dim hint text in TTY mode instead of inverse/selected-looking synthetic cursor text
2026-04-28 18:47:14 -05:00
brooklyn!
a9efa46b69 Merge pull request #17174 from NousResearch/bb/nix-web-hash-refresh
fix(nix): refresh web/ npm-deps hash to unblock main builds
2026-04-28 16:45:57 -07:00
Brooklyn Nicholson
b2f936fd37 fix(nix): treat transient magic-cache throttling as skip in fix-lockfiles
Round 1 of #17174 hit `nix-lockfile-check` failure.  Root cause was
NOT a stale hash — the primary `nix (ubuntu-latest)` and
`nix (macos-latest)` builds passed.  GitHub's Magic Nix Cache returned
HTTP 418 (rate-limited / throttled) mid-run, so the rebuild bailed
with `some outputs of '/nix/store/...-npm-deps.drv' are not valid,
so checking is not possible` — no `got:` line for the script to
extract.

The script then incorrectly treated this as 'build failed with no
hash mismatch' and exited 1, breaking the lint on every PR whenever
the cache is throttled.

Now we recognize the throttling/cache-disabled signature and skip
that entry with a warning.  A real stale hash still surfaces in the
primary `.#$ATTR` build (separate CI job), so we don't lose
coverage.
2026-04-28 18:39:35 -05:00
Brooklyn Nicholson
ec11aa64ee fix(nix): refresh web/ npm-deps hash to unblock main builds
`web/package-lock.json` was updated by the design-system refactor
(merged via #17007 + follow-ups: spinner / select / badges / buttons)
without bumping `nix/web.nix::npmDeps.hash`, breaking nix builds on
every PR + main since 2026-04-28T18:46.

Hash sourced from the actual `Check flake` failure output:
  specified: sha256-AahWmJ9gDQ9pMPa1FYwUjYdO2mOi6JM9Mst27E0vp68=
  got:       sha256-+B2+Fe4djPzHHcUXRx+m0cuyaopAhW0PcHsMgYfV5VE=

Standalone single-file fix so it can land fast and clear nix on
every other open PR.
2026-04-28 18:21:09 -05:00
brooklyn!
7d81d76366 feat(tui): pluggable busy-indicator styles (#13610) (#17150)
* feat(tui): pluggable busy-indicator styles (kaomoji/emoji/unicode/ascii)

The status-bar `FaceTicker` rotated through wide-and-variable kaomoji
glyphs (`(。•́︿•̀。)`, `( ͡° ͜ʖ ͡°)`, …) every 2.5s.  Real display widths range
from ~5 to ~16 columns, so the rest of the bar (cwd, ctx %, voice,
bg counter) shifted on every cycle.  Padding the verb alone (#17116)
helped but didn't address the dominant jitter source — the glyph
itself.

Add four indicator styles, configurable + hot-swappable:

* `kaomoji` (default — preserves the existing vibe; verb is now
  pad-stable so the only width churn left is the kaomoji itself).
* `emoji`  — single 2-col emoji frame (`⚕ 🌀 🤔  🍵 🔮`).
* `unicode` — `unicode-animations` braille spinner (1-col, smooth).
* `ascii`  — `| / - \` (1-col, max compat).

Wires:

* `display.tui_status_indicator` in `DEFAULT_CONFIG` (default
  `kaomoji`).
* New JSON-RPC `config.set/get indicator` keys, narrow allow-list.
* `applyDisplay` reads the field and patches `UiState.indicatorStyle`,
  so the existing `mtime` poll picks up `~/.hermes/config.yaml` edits
  within ~5s without a TUI restart.
* `/indicator [style]` slash command (alias `/indicator-style`,
  subcommand completion `kaomoji|emoji|unicode|ascii`).  Bare form
  shows the current style; setter fires `config.set` and
  optimistically `patchUiState({ indicatorStyle })` so the live TUI
  swaps immediately, matching the `/skin` UX.
* `CommandDef("indicator", ..., subcommands=...)` so classic CLI
  autocomplete + TUI `complete.slash` both surface it.
* `FaceTicker` decouples spinner cadence from verb cadence — the
  glyph runs at the spinner's authored interval (or `FACE_TICK_MS`
  for kaomoji), the verb stays on the original 2.5s cycle, and both
  re-arm cleanly when style changes.

Tests:

* `normalizeIndicatorStyle` rejects unknown / non-string input.
* `applyDisplay → tui_status_indicator` covers fan-out + fallback.
* `/indicator <style>` hot-swaps `UiState.indicatorStyle` after a
  successful `config.set`.
* `/indicator sparkle` rejects with the usage hint and never hits
  the gateway.
* Slash-parity matrix gets `'/indicator'` → `config.get`.

Validation:
  cd ui-tui && npm run type-check — clean; npm test --run — 398/398.
  scripts/run_tests.sh tests/test_tui_gateway_server.py
  tests/hermes_cli/test_commands.py — 220/220.

* chore(tui): drop /indicator-style alias to declutter autocomplete

* fix(tui): drop verb-width pad — /indicator handles glyph jitter directly

* fix(tui): unicode indicator style hides the verb (cleanest option)

* refactor(tui): single source of truth for INDICATOR_STYLES; cleaner error format

Round 1 Copilot review on PR #17150:

- Exported `INDICATOR_STYLES` const tuple from `interfaces.ts`;
  `IndicatorStyle` union type is derived from it. `useConfigSync`
  builds its validation Set from the tuple, and `session.ts` uses it
  for both the usage hint and the runtime allow-list — adding/removing
  a style now touches one line.
- Backend `config.set indicator` error message: switched
  `sorted(allowed)` list repr to `pick one of ascii|emoji|kaomoji|unicode`
  (matches the TUI usage hint), and reports the normalized `raw`
  instead of the original `value`. Backend allowed tuple now has a
  comment pointing back at `INDICATOR_STYLES` so the two stay aligned.

Note: kept the verb portion unpadded per design intent — fixed-width
padding was the exact UX the `/indicator` command was added to remove.
Stable width comes from the glyph; verbs cycling is part of the kawaii
aesthetic. Reply on the verb thread will explain.

* fix(tui): drop type collapse + gate verb timer + DEFAULT_INDICATOR_STYLE

Round 2 Copilot review on PR #17150:

- `tui_status_indicator?: 'ascii' | ... | string` collapses to `string`
  in TS — consumers got no narrowing. Documented as plain `string` with
  a comment about runtime validation via `normalizeIndicatorStyle`.
- `FaceTicker` always started a 2.5s verb interval, even for the
  `unicode` style which hides the verb entirely. Now gated on
  `showVerb` from `renderIndicator` — `unicode` stays calm.

Pre-emptive self-review (avoid round 3):
- Three call sites duplicated the literal `'kaomoji'` default
  (uiStore, normalizeIndicatorStyle, slash command). Added
  `DEFAULT_INDICATOR_STYLE` to interfaces.ts and threaded it through
  so changing the default touches one line.

* fix(tui-gateway): normalize config.get indicator output to match TUI render

Round 4 Copilot review on PR #17150: `config.get` for `indicator`
returned the raw `display.tui_status_indicator` value without
validation, so a hand-edited config.yaml with stray casing or an
unknown style would leave `/indicator` printing one thing while
the TUI rendered the kaomoji default (frontend's
`normalizeIndicatorStyle` does this normalization on receive).

Lifted the allow-list to module scope as `_INDICATOR_STYLES` /
`_INDICATOR_DEFAULT`, reused by both `config.set` and `config.get`.
Comment notes the alignment with `INDICATOR_STYLES` /
`DEFAULT_INDICATOR_STYLE` in interfaces.ts so adding/removing a
style is a one-line change on each end.

Tests cover: known value verbatim, casing/whitespace normalize,
unknown→default, unset→default.

* fix(tui-gateway): preserve falsy-input diagnostics in config.set indicator error

Round 5 Copilot review on PR #17150: `raw = str(value or "").strip().lower()`
collapsed any falsy non-string (`0`, `False`, `[]`) to empty string,
so the error message read `unknown indicator: ` with nothing after —
losing the original input.

Switched to `("" if value is None else str(value)).strip().lower()`
so only `None` (the genuine 'no value' case) becomes blank.  Used
`{raw!r}` in the error so the diagnostic is unambiguous (`'0'` vs `0`).

Tests:
- known-value happy path (`'EMOJI'` → `'emoji'`)
- falsy non-string inputs (`0` / `False` / `[]`) surface meaningfully
- `None` keeps the blank-repr error
2026-04-28 18:19:16 -05:00
brooklyn!
258efb2575 feat(tui): expand light-terminal auto-detection (HERMES_TUI_THEME, background hex) (#17113)
* feat(tui): expand light-terminal auto-detection (HERMES_TUI_THEME, BG hex)

Modern terminals (Ghostty, Warp, iTerm2) don't set COLORFGBG, so the
auto-light path was effectively COLORFGBG-only and silently broken for
many users.  Two pragmatic additions, both opt-in, plus a clearer
priority chain:

1. **`HERMES_TUI_THEME=light|dark`** as a symmetric explicit override.
   The existing `HERMES_TUI_LIGHT` is fine but reads as boolean noise;
   a named theme env var matches `display.skin` muscle memory.

2. **`HERMES_TUI_BACKGROUND` hex/rgb hint.**  Lets advanced users
   (or a future OSC11 query helper that caches the answer) state a
   ground-truth background colour.  Decoded to Rec. 709 luma; ≥ 0.6
   counts as light.

Priority order is now fully ordered and explainable:
  1. `HERMES_TUI_LIGHT` (1/0/true/false/on/off).
  2. `HERMES_TUI_THEME=light|dark`.
  3. `HERMES_TUI_BACKGROUND` luminance.
  4. `COLORFGBG` last field — light slots 7/15 → light, 0–15 → dark
     (authoritative when set, so the new TERM_PROGRAM path can never
     stomp on a terminal that already volunteered a dark answer).
  5. `TERM_PROGRAM` allow-list — empty by default.  The slot is left
     in place because folks asked for it but populating it risks
     wrongly flipping users on Apple_Terminal / iTerm2 dark profiles
     to light.  Easy to add per terminal once we have signal.

Tests: 5 new cases in `theme.test.ts` covering theme env, background
hex (3- and 6-char), invalid hex falling through, and COLORFGBG taking
precedence over the future allow-list.

Validation: `npm run type-check` clean, `npm test --run` 392/392.

* review(copilot): tighten theme detection comments + drop unnecessary cast

* review(copilot): strict hex regex so partial garbage doesn't slip into luminance

* test(tui): make TERM_PROGRAM allow-list injectable so precedence is provable

Copilot review on PR #17113: `LIGHT_DEFAULT_TERM_PROGRAMS` is empty
in production, so the prior assertion would have passed even if
`detectLightMode` ignored `COLORFGBG` entirely.  That defeats the
test's purpose.

`detectLightMode` now takes the allow-list as an optional second
argument (defaults to the production set).  The test injects a set
containing `Apple_Terminal`, asserts the allow-list alone WOULD
return light, then asserts `COLORFGBG: '15;0'` overrides it — the
precedence rule is now exercised, not assumed.

* fix(tui): COLORFGBG empty-trailing-field falls through; isolate DEFAULT_THEME tests

Round 2 Copilot review on PR #17113:

1. `Number(colorfgbg.split(';').at(-1))` returns 0 for an empty trailing
   field (e.g. `COLORFGBG='15;'` → bg===0), which would have looked
   like an authoritative dark slot and incorrectly blocked the
   TERM_PROGRAM allow-list.  Added a `/^\d+$/` guard before coercion;
   non-numeric trailing fields now fall through.

2. Fixed the misleading '0–6 / 8–15 ranges are dark' comment — the
   block returns true for bg===15, so the range is actually 0–6 / 8–14.

3. `DEFAULT_THEME` is computed from `process.env` at module-load.
   A developer shell with `HERMES_TUI_THEME=light` (or a bright
   `HERMES_TUI_BACKGROUND`) would flip it and break local tests.
   The DEFAULT_THEME describe blocks now sterilize the relevant env
   vars + dynamically import theme.ts (vi.resetModules pattern from
   platform.test.ts).  fromSkin tests compare against DARK_THEME
   directly to decouple them from ambient env.

* test(tui): isolate ALL env-coupled theme symbols, not just DEFAULT_THEME

Round 3 Copilot review on PR #17113: the static top-level imports of
`fromSkin`, `DARK_THEME`, `LIGHT_THEME` evaluated theme.ts before
`importThemeWithCleanEnv` had a chance to clean the env. Because
`fromSkin` closes over `DEFAULT_THEME`, an ambient `HERMES_TUI_THEME=light`
or bright `HERMES_TUI_BACKGROUND` would still flip the base palette
and cause local-only failures.

Removed the static import entirely.  Every test now obtains its theme
symbols via `importThemeWithCleanEnv`, including `detectLightMode`
(for consistency, even though it takes env as a parameter).
`fromSkin` tests assert against the cleaned `DEFAULT_THEME` from the
same dynamic import — preserves the actual contract (skins extend the
ambient base palette) without coupling the test to dev-shell state.

Verified by running with HERMES_TUI_THEME=light + HERMES_TUI_BACKGROUND=#ffffff:
all 20 theme tests still pass.

Self-review (avoid round 4):
- Audited other test files importing DEFAULT_THEME (syntax.test.ts,
  streamingMarkdown.test.ts, constants.test.ts) — all just pass it as
  a parameter or assert palette property existence (works on both
  light + dark), so no env coupling there.
2026-04-28 18:02:06 -05:00
brooklyn!
1e326c686d fix(tui-gateway): harden stdio transport against half-closed pipes + SIGTERM races (#17118)
* fix(tui-gateway): harden stdio transport against half-closed pipes + SIGTERM races

`tui_gateway` reports `tui_gateway_crash.log` traces where the main
thread sits in `sys.stdin` while a worker holds `_stdout_lock` mid-
flush, and SIGTERM then calls `sys.exit(0)` while the lock is still
held — the interpreter shutdown stalls behind the wedged write.

Two narrowly scoped hardenings:

**`tui_gateway/transport.py`**

* Move JSON serialisation outside the lock — long messages no longer
  block sibling writers while we serialise.
* Treat `BrokenPipeError`, `ValueError` ("I/O on closed file") and
  generic `OSError` from both `write` and `flush` as "peer is gone":
  return `False` instead of bubbling, matching what `write_json`'s
  callers in `entry.py` already expect.
* Split `flush` into its own try block so a stuck flush never strands
  a partial write or holds the lock indefinitely on its way out.
* Optional `HERMES_TUI_GATEWAY_NO_FLUSH=1` env knob to skip explicit
  `flush()` entirely on environments where a half-closed read pipe
  produces an indefinite kernel-level block.  Default unchanged.

**`tui_gateway/entry.py`**

* `_log_signal` now spawns a 1-second daemon timer that calls
  `os._exit(0)` if the orderly `sys.exit(0)` path is itself stuck
  behind a wedged worker.  Atexit handlers run inside the grace
  window when they can; the timer is the safety net so a deadlocked
  flush no longer strands the gateway process.

Tests:

* `test_write_json_closed_stream_returns_false` — ValueError path.
* `test_write_json_oserror_on_flush_returns_false` — OSError on flush
  must not strand the lock; the write portion still landed before the
  flush failure.
* `test_write_json_no_flush_env_skips_flush` — env knob bypass.

Validation: `scripts/run_tests.sh tests/tui_gateway/test_protocol.py`
(42/42 pass; one pre-existing failure on
`test_session_resume_returns_hydrated_messages` is unrelated to this
change — same `include_ancestors` mock kwarg issue tracked elsewhere).
`scripts/run_tests.sh tests/test_tui_gateway_server.py` 90/90 pass.

* review(copilot): tighten transport hardening comments + test cleanup

* review(copilot): narrow exception capture, configurable grace, simpler no-flush test

* fix(tui-gateway): narrow ValueError to closed-stream; surface UnicodeEncodeError

Copilot review on PR #17118: `UnicodeEncodeError` is a ValueError
subclass, so a non-UTF-8 stdout (mismatched PYTHONIOENCODING / locale)
would have been silently swallowed as 'peer gone' under
`except ValueError`.  That hides a real environment bug.

Now:
- UnicodeEncodeError → log with exc_info (warning) and drop the frame
- ValueError where str(e) contains 'closed file' → peer gone, return False
- Any other ValueError → log loudly, drop frame (defensive, but visible)

Same shape applied to flush.  Adds two regression tests.

* fix(tui-gateway): reserve write() False for peer-gone; re-raise programming errors

Round 2 Copilot review on PR #17118: `Transport.write()` returning
`False` is documented as 'peer is gone', and `entry.py` reacts by
calling `sys.exit(0)`.  But the implementation also returned False
for non-IO conditions (non-JSON-safe payloads, UnicodeEncodeError,
unrelated ValueErrors), so a programming error or local env bug would
present as a clean disconnect — exactly the diagnosis pain we wanted
to eliminate.

Now:
- `json.dumps` failure → re-raises (TypeError/ValueError surfaces in crash log)
- `BrokenPipeError` → False (peer gone)
- `ValueError('...closed file...')` → False (peer gone)
- `UnicodeEncodeError` and any other ValueError → re-raise
- `OSError` → False (existing IO-failure semantics, debug-logged)

Tests updated to assert the re-raise behaviour and added a
non-serializable-payload regression test.

* fix(tui-gateway): narrow OSError to peer-gone errnos; honest test naming

Round 3 Copilot review on PR #17118:

- Docstring claimed False = peer gone, but generic OSError on write/flush
  also returned False — meaning ENOSPC/EACCES/EIO would silently exit.
  Added `_PEER_GONE_ERRNOS = {EPIPE, ECONNRESET, EBADF, ESHUTDOWN, +WSA}`
  and narrowed the OSError handlers; non-peer-gone errnos re-raise.
  Docstring now lists OSError as peer-gone branch with the errno set.
- The `_DISABLE_FLUSH` test was named after the env var but actually
  patched the module constant. Renamed it to reflect the contract being
  tested (skips flush when constant is true) AND added a real
  end-to-end test that sets the env var, reloads transport.py, and
  asserts the constant flips. Cleanup reload restores defaults so
  parallel tests stay isolated.

Self-review (avoid round 4):
- Verified TeeTransport's secondary-swallow stays intentional.
- _log_signal grace path already covered by separate tests.
2026-04-28 17:54:06 -05:00
brooklyn!
af6b1a3343 fix(tui): honor display.busy_input_mode in TUI v2 (#17110)
* fix(tui): honor display.busy_input_mode in TUI v2

The TUI v2 frontend hard-coded `composerActions.enqueue(full)` whenever
`ui.busy` was true. The classic CLI and gateway adapters honor the
`display.busy_input_mode` config key (`interrupt` | `queue` | `steer`),
but Ink ignored it — sending a message during a long-running turn always
landed in the queue regardless of config. The config default is already
`interrupt` (hermes_cli/config.py), so users who explicitly opted into
that experience were silently stuck on the legacy queue path.

This wires the value through the existing config-sync surface:

* `applyDisplay` now reads `display.busy_input_mode`, defaults to
  `interrupt` (matching `_load_busy_input_mode` in tui_gateway), and
  drops it into a new `UiState.busyInputMode` field.
* `dispatchSubmission` and the queue-edit fall-through call a shared
  `handleBusyInput` helper that branches on the mode:
    * `queue`     — legacy behavior, append to the queue.
    * `steer`     — call `session.steer`; on rejection, fall back to
                    queue with a sys note.
    * `interrupt` — `turnController.interruptTurn(...)` then `send()`,
                    so the new prompt actually moves.
* Mtime polling in `useConfigSync` already re-applies `config.full`, so
  flipping `display.busy_input_mode` in `~/.hermes/config.yaml` takes
  effect on the next 5s tick without restarting the TUI.

Tests:
* `applyDisplay → busy_input_mode` covers normalization + UiState fan-out.
* `normalizeBusyInputMode` mirrors the Python side's allow-list.

Validation:
* `npm run type-check` (in `ui-tui/`) — clean.
* `npm test --run` (in `ui-tui/`) — 394/394.

* review(copilot): narrow busy_input_mode type, preserve queue order on steer fallback

* review(copilot): clarify handleBusyInput comment (option, not return value)

* fix(tui): default busy_input_mode to queue in TUI (CLI keeps interrupt)

In a full-screen TUI users typically author the next prompt while the
agent is still streaming, so an unintended interrupt loses in-flight
typing.  TUI fallback now defaults to `queue`; CLI / messaging
adapters keep `interrupt` as the framework default.

Override per-config via `display.busy_input_mode: interrupt` (or
`steer`) — the normalize/wire path is unchanged, only the missing-
value branch differs from the Python default.

uiStore initial value also flipped to `queue` so first-frame render
before `config.full` lands matches the eventual normalized value.
2026-04-28 17:52:13 -05:00
brooklyn!
8d591fe3c7 fix(tui): prefer raw text over Rich-rendered ANSI in TUI message display (#17111)
`turnController.recordMessageComplete` and `recordMessageDelta` both
prioritised `payload.rendered` over `payload.text`.  `payload.rendered`
is the Rich-Console output `tui_gateway` builds for terminals that
can't render markdown themselves; the TUI already renders markdown via
`<Md>`.  Two real bugs follow:

1. **Final answer garbled when `display.final_response_markdown: render`
   is set** (#16391).  Raw ANSI escape sequences pass through into the
   React tree and the user sees overlapping coloured text instead of
   their answer.

2. **Streaming silently drops content.**  Per-delta `rendered` is an
   *incremental* Rich fragment.  The previous code did
   `this.bufRef = rendered ?? this.bufRef + text`, which on every tick
   replaced the whole accumulated buffer with the latest mid-sequence
   ANSI fragment.  Long replies arrived truncated and looked
   half-painted — easy to miss as "model is being terse" instead of a
   client bug.

Fix:

* `recordMessageComplete` now prefers `payload.text`, falling back to
  `payload.rendered` only when the gateway elected not to send any.
* `recordMessageDelta` always accumulates `text`; `rendered` is ignored
  on the streaming path entirely (Ink does its own markdown render via
  `<Md>` / `streamingMarkdown.tsx`).

Tests:

* `prefers raw text over Rich-rendered ANSI on message.complete` —
  the assistant message reflects raw markdown, not ANSI.
* `falls back to payload.rendered when text is missing` — preserves
  the legacy "no `text`, only ANSI" path used by some adapters.
* `always accumulates raw text in message.delta and ignores rendered` —
  pre-fix code would have made this assertion fail because each delta
  overwrote the buffer.

Validation: `npm run type-check` clean, `npm test --run` 392/392 pass.
2026-04-28 17:47:50 -05:00
brooklyn!
15ef11a8b8 fix(tui): make /browser connect actually take effect on the live agent (#17120)
* fix(tui): make /browser connect actually take effect on the live agent

Reports were that `/browser connect <url>` (and "changes to CDP url
don't get picked up") didn't propagate to the live agent in `--tui`,
forcing users to fall back to setting `browser.cdp_url` in
`config.yaml` and restarting.  Tracing the path on current main shows
the protocol wiring is already correct — `/browser` is registered in
`ui-tui/src/app/slash/commands/ops.ts` and dispatches `browser.manage`
through the gateway RPC, NOT the slash worker (covered by the
`browser.manage` row in `slashParity.test.ts`).  But three real gaps
left the experience flaky:

1. `cleanup_all_browsers()` ran AFTER `os.environ["BROWSER_CDP_URL"]`
   was rewritten.  `_ensure_cdp_supervisor(...)` reads the env to
   resolve its target URL, so a tool call landing in that brief window
   could re-attach the supervisor to the OLD CDP endpoint just before
   we reaped sessions, leaving the agent talking to a dead URL.
   Reorder to clean first, swap env, clean again so the supervisor
   for the default task is definitively closed.
2. `browser.manage status` reported only the env var, ignoring
   `browser.cdp_url` from config.yaml.  `_get_cdp_override()` (the
   resolver the agent itself uses) consults both — match it so
   `/browser status` answers the same question the next
   `browser_navigate` will see.  Closes a stealth bug where users
   saw "browser not connected" while their CDP URL was perfectly
   set in config.yaml.
3. `/browser disconnect` only cleared `BROWSER_CDP_URL` and reaped
   once, leaving the same swap window as connect.  Symmetrical
   double-cleanup here too.

Frontend (`ops.ts`):
* Echo "next browser tool call will use this CDP endpoint" on success
  so users see immediate confirmation that the gateway accepted the
  swap, even before any tool runs.
* Mention `browser.cdp_url` in `config.yaml` in the usage hint and
  the not-connected status line.  Persistent config is the correct
  fix for some terminal-multiplexer / sub-agent flows where env
  inheritance is unreliable; surfacing it makes that workaround
  discoverable.

Tests (4 new, all hermetic):
* `status` returns the resolved URL when only `browser.cdp_url` is
  set in config.yaml.
* `connect` writes env AND cleans before/after, in that order.
* `connect` against an unreachable endpoint does NOT mutate env or
  reap.
* `disconnect` removes env and cleans twice.

Validation:
  scripts/run_tests.sh tests/test_tui_gateway_server.py — 94/94 pass.
  cd ui-tui && npm run type-check — clean; npm test --run — 389/389.

* review(copilot): always defer to _get_cdp_override; normalize bare host:port

* review(copilot): collapse discovery-style CDP paths so /json/version isn't duplicated

* fix(tui): /browser status must not perform CDP discovery I/O

Copilot review on PR #17120: previous version routed through
`tools.browser_tool._get_cdp_override`, which calls
`_resolve_cdp_override` and performs an HTTP probe to /json/version
with a multi-second timeout for discovery-style URLs.  That blocks
the TUI on `/browser status` whenever the configured host is slow
or unreachable.

Status now reads env-then-config directly with no network I/O.  The
WS normalization still happens in `browser_navigate` for actual
tool calls, so behaviour-on-call is unchanged.

* fix(tui): skip /json/version probe for concrete ws://devtools/browser endpoints

Round 2 Copilot review on PR #17120: hosted CDP providers (Browserbase,
browserless, etc.) return concrete `ws[s]://.../devtools/browser/<id>`
URLs which are already directly connectable but don't serve the HTTP
discovery path.  The previous `/json/version` probe rejected these
valid endpoints with 'could not reach browser CDP'.

For `ws[s]://...` URLs whose path starts with `/devtools/browser/` we
now do a TCP-level reachability check (`socket.create_connection`)
instead of the HTTP probe.  The actual CDP handshake happens on the
next `browser_navigate` call, so we still surface unreachable hosts
as 5031 errors — just without the false negatives.

Discovery-style URLs (`http://host:port[/json[/version]]`) keep the
HTTP probe path unchanged.  Updated existing test + added two new
ones (TCP-only success, TCP unreachable → 5031).
2026-04-28 17:46:57 -05:00
88 changed files with 3347 additions and 1333 deletions

1
.gitignore vendored
View File

@@ -70,3 +70,4 @@ mini-swe-agent/
result
website/static/api/skills-index.json
models-dev-upstream/
.venv

View File

@@ -494,7 +494,7 @@ branding:
agent_name: "My Agent"
welcome: "Welcome message"
response_label: " ⚔ Agent "
prompt_symbol: "⚔ "
prompt_symbol: "⚔"
tool_prefix: "╎" # Tool output line prefix
```

View File

@@ -223,8 +223,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
target = args.get("target", "")
if action == "add":
content = _oneline(args.get("content", ""))
target_prefix = f"+{target}: " if target else "+"
return f"{target_prefix}\"{content[:25]}{'...' if len(content) > 25 else ''}\""
return f"+{target}: \"{content[:25]}{'...' if len(content) > 25 else ''}\""
elif action == "replace":
old = _oneline(args.get("old_text") or "") or "<missing old_text>"
return f"~{target}: \"{old[:20]}\""

View File

@@ -184,11 +184,59 @@ _PREFIX_RE = re.compile(
)
def mask_secret(
value: str,
*,
head: int = 4,
tail: int = 4,
floor: int = 12,
placeholder: str = "***",
empty: str = "",
) -> str:
"""Mask a secret for display, preserving ``head`` and ``tail`` characters.
Canonical helper for display-time redaction across Hermes — used by
``hermes config``, ``hermes status``, ``hermes dump``, and anywhere
a secret needs to be shown truncated for debuggability while still
keeping the bulk hidden.
Args:
value: The secret to mask. ``None``/empty returns ``empty``.
head: Leading characters to preserve. Default 4.
tail: Trailing characters to preserve. Default 4.
floor: Values shorter than ``head + tail + floor_margin`` are
fully masked (returns ``placeholder``). Default 12 —
matches the existing config/status/dump convention.
placeholder: Value returned for too-short inputs. Default ``"***"``.
empty: Value returned when ``value`` is falsy (None, ""). The
caller can override this to e.g. ``color("(not set)",
Colors.DIM)`` for user-facing display.
Examples:
>>> mask_secret("sk-proj-abcdef1234567890")
'sk-p...7890'
>>> mask_secret("short") # fully masked
'***'
>>> mask_secret("") # empty default
''
>>> mask_secret("", empty="(not set)") # empty override
'(not set)'
>>> mask_secret("long-token", head=6, tail=4, floor=18)
'***'
"""
if not value:
return empty
if len(value) < floor:
return placeholder
return f"{value[:head]}...{value[-tail:]}"
def _mask_token(token: str) -> str:
"""Mask a token, preserving prefix for long tokens."""
if len(token) < 18:
"""Mask a log token — conservative 18-char floor, preserves 6 prefix / 4 suffix."""
# Empty input: historically this returned "***" rather than "". Preserve.
if not token:
return "***"
return f"{token[:6]}...{token[-4:]}"
return mask_secret(token, head=6, tail=4, floor=18)
def _redact_query_string(query: str) -> str:

View File

@@ -927,7 +927,7 @@ display:
# agent_name: "My Agent" # Banner title and branding
# welcome: "Welcome message" # Shown at CLI startup
# response_label: " ⚔ Agent " # Response box header label
# prompt_symbol: "⚔ " # Prompt symbol
# prompt_symbol: "⚔" # Prompt symbol (bare token; renderers add trailing space)
# tool_prefix: "╎" # Tool output line prefix (default: ┊)
#
skin: default

View File

@@ -128,6 +128,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
subcommands=("normal", "fast", "status", "on", "off")),
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
cli_only=True, args_hint="[name]"),
CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration",
cli_only=True, args_hint="[kaomoji|emoji|unicode|ascii]",
subcommands=("kaomoji", "emoji", "unicode", "ascii")),
CommandDef("voice", "Toggle voice mode", "Configuration",
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration",

View File

@@ -715,6 +715,9 @@ DEFAULT_CONFIG = {
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
"show_cost": False, # Show $ cost in the status bar (off by default)
"skin": "default",
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
# spinner), or ascii. Live-swappable via `/indicator <style>`.
"tui_status_indicator": "kaomoji",
"user_message_preview": { # CLI: how many submitted user-message lines to echo back in scrollback
"first_lines": 2,
"last_lines": 2,
@@ -4010,12 +4013,13 @@ def get_env_value(key: str) -> Optional[str]:
# =============================================================================
def redact_key(key: str) -> str:
"""Redact an API key for display."""
if not key:
return color("(not set)", Colors.DIM)
if len(key) < 12:
return "***"
return key[:4] + "..." + key[-4:]
"""Redact an API key for display.
Thin wrapper over :func:`agent.redact.mask_secret` — preserves the
"(not set)" placeholder in dim color for the empty case.
"""
from agent.redact import mask_secret
return mask_secret(key, empty=color("(not set)", Colors.DIM))
def show_config():

View File

@@ -293,15 +293,23 @@ def run_doctor(args):
known_providers: set = set()
try:
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.auth import (
PROVIDER_REGISTRY,
resolve_provider as _resolve_auth_provider,
)
known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"}
except Exception:
_resolve_auth_provider = None
pass
try:
from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers
from hermes_cli.providers import resolve_provider_full as _resolve_provider_full
from hermes_cli.providers import (
normalize_provider as _normalize_catalog_provider,
resolve_provider_full as _resolve_provider_full,
)
except Exception:
_compatible_custom_providers = None
_normalize_catalog_provider = None
_resolve_provider_full = None
custom_providers = []
@@ -321,17 +329,43 @@ def run_doctor(args):
if name:
known_providers.add("custom:" + name.lower().replace(" ", "-"))
canonical_provider = provider
valid_provider_ids = set(known_providers)
provider_ids_to_accept = {provider} if provider else set()
if _normalize_catalog_provider is not None:
for known_provider in known_providers:
try:
valid_provider_ids.add(_normalize_catalog_provider(known_provider))
except Exception:
continue
runtime_provider = provider
if (
provider
and _resolve_auth_provider is not None
and provider not in ("auto", "custom")
):
try:
runtime_provider = _resolve_auth_provider(provider)
provider_ids_to_accept.add(runtime_provider)
except Exception:
runtime_provider = provider
catalog_provider = provider
if (
provider
and _resolve_provider_full is not None
and provider not in ("auto", "custom")
):
provider_def = _resolve_provider_full(provider, user_providers, custom_providers)
canonical_provider = provider_def.id if provider_def is not None else None
catalog_provider = provider_def.id if provider_def is not None else None
if catalog_provider is not None:
provider_ids_to_accept.add(catalog_provider)
if provider and provider != "auto":
if canonical_provider is None or (known_providers and canonical_provider not in known_providers):
if catalog_provider is None or (
known_providers
and not (provider_ids_to_accept & valid_provider_ids)
):
known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)"
check_fail(
f"model.provider '{provider_raw}' is not a recognised provider",
@@ -344,7 +378,24 @@ def run_doctor(args):
)
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them
if default_model and "/" in default_model and canonical_provider and canonical_provider not in ("openrouter", "custom", "auto", "ai-gateway", "kilocode", "opencode-zen", "huggingface", "nous", "lmstudio"):
provider_for_policy = runtime_provider or catalog_provider
providers_accepting_vendor_slugs = {
"openrouter",
"custom",
"auto",
"ai-gateway",
"kilocode",
"opencode-zen",
"huggingface",
"lmstudio",
"nous",
}
if (
default_model
and "/" in default_model
and provider_for_policy
and provider_for_policy not in providers_accepting_vendor_slugs
):
check_warn(
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
"(vendor-prefixed slugs belong to aggregators like openrouter)",
@@ -360,20 +411,24 @@ def run_doctor(args):
# own env-var checks elsewhere in doctor, and get_auth_status()
# returns a bare {logged_in: False} for anything it doesn't
# explicitly dispatch, which would produce false positives.
if canonical_provider and canonical_provider not in ("auto", "custom", "openrouter"):
if runtime_provider and runtime_provider not in ("auto", "custom", "openrouter"):
try:
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
pconfig = PROVIDER_REGISTRY.get(canonical_provider)
pconfig = PROVIDER_REGISTRY.get(runtime_provider)
if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
status = get_auth_status(canonical_provider) or {}
configured = bool(status.get("configured") or status.get("logged_in") or status.get("api_key"))
status = get_auth_status(runtime_provider) or {}
configured = bool(
status.get("configured")
or status.get("logged_in")
or status.get("api_key")
)
if not configured:
check_fail(
f"model.provider '{canonical_provider}' is set but no API key is configured",
f"model.provider '{runtime_provider}' is set but no API key is configured",
"(check ~/.hermes/.env or run 'hermes setup')",
)
issues.append(
f"No credentials found for provider '{canonical_provider}'. "
f"No credentials found for provider '{runtime_provider}'. "
f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, "
f"or switch providers with 'hermes config set model.provider <name>'"
)

View File

@@ -33,12 +33,14 @@ def _get_git_commit(project_root: Path) -> str:
def _redact(value: str) -> str:
"""Redact all but first 4 and last 4 chars."""
if not value:
return ""
if len(value) < 12:
return "***"
return value[:4] + "..." + value[-4:]
"""Redact all but first 4 and last 4 chars.
Thin wrapper over :func:`agent.redact.mask_secret`. Returns ``""`` for
an empty value (matches the historical behavior of this helper —
``hermes dump`` formats empty values as blank, not as ``"(not set)"``).
"""
from agent.redact import mask_secret
return mask_secret(value)
def _gateway_status() -> str:

View File

@@ -1,333 +0,0 @@
"""Learning ledger: read-only index of how Hermes has grown for this profile."""
from __future__ import annotations
import json
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from hermes_constants import get_hermes_home
@dataclass
class LedgerItem:
type: str
name: str
summary: str
source: str
count: int = 0
learned_from: str | None = None
last_used_at: float | None = None
learned_at: float | None = None
via: str | None = None
def build_learning_ledger(db: Any = None, *, limit: int = 80) -> dict[str, Any]:
"""Build a compact, read-only ledger from existing Hermes artifacts."""
skill_inventory = _skill_inventory()
items = [
*_memory_items(),
*_tool_usage_items(db),
*_integration_items(),
]
items.sort(
key=lambda i: (i.last_used_at or i.learned_at or 0, i.type, i.name),
reverse=True,
)
counts: dict[str, int] = {}
for item in items:
counts[item.type] = counts.get(item.type, 0) + 1
return {
"generated_at": time.time(),
"home": str(get_hermes_home()),
"counts": counts,
"items": [asdict(item) for item in items[: max(1, limit)]],
"inventory": {"skills": skill_inventory},
"total": len(items),
}
def _memory_items() -> list[LedgerItem]:
try:
from tools.memory_tool import MemoryStore, get_memory_dir
mem_dir = get_memory_dir()
pairs = [
("memory", "MEMORY.md", "agent note"),
("user", "USER.md", "user profile"),
]
items: list[LedgerItem] = []
for item_type, filename, label in pairs:
path = mem_dir / filename
for idx, entry in enumerate(MemoryStore._read_file(path), 1):
items.append(
LedgerItem(
type=item_type,
name=f"{label} {idx}",
summary=_one_line(entry),
source=str(path),
learned_at=_mtime(path),
)
)
return items
except Exception:
return []
def _skill_inventory() -> int:
try:
from tools.skills_tool import _find_all_skills
return len(_find_all_skills())
except Exception:
return 0
def _tool_usage_items(db: Any) -> list[LedgerItem]:
if db is None or not getattr(db, "_conn", None):
return []
usage: dict[tuple[str, str], LedgerItem] = {}
def bump(
item_type: str,
name: str,
summary: str,
ts: float | None,
*,
learned_from: str | None = None,
via: str | None = None,
):
key = (item_type, name)
item = usage.get(key)
if not item:
item = usage[key] = LedgerItem(
type=item_type,
name=name,
summary=summary,
source="state.db",
learned_from=learned_from,
via=via,
)
item.count += 1
if ts and (not item.last_used_at or ts > item.last_used_at):
item.last_used_at = ts
item.learned_from = learned_from or item.learned_from
item.via = via or item.via
try:
with db._lock:
rows = db._conn.execute(
"""
SELECT m.role, m.content, m.tool_calls, m.tool_name, m.timestamp,
m.session_id, s.title, s.source AS session_source
FROM messages m
LEFT JOIN sessions s ON s.id = m.session_id
WHERE m.tool_name IS NOT NULL OR m.tool_calls IS NOT NULL
ORDER BY m.timestamp DESC
LIMIT 5000
"""
).fetchall()
except Exception:
return []
for row in rows:
ts = _float(row["timestamp"])
tool_name = row["tool_name"]
content = row["content"] or ""
learned_from = row["title"] or row["session_source"] or row["session_id"]
if tool_name == "memory":
target = _json(content).get("target") or "memory"
bump(str(target), f"{target} writes", "Durable memory updates", ts, learned_from=learned_from, via="memory")
elif tool_name == "session_search":
event = learning_event_from_tool(tool_name, {}, content)
if event:
bump("recall", event["title"], event["summary"], ts, learned_from=learned_from, via="session_search")
elif tool_name in {"skill_view", "skill_manage"}:
data = _json(content)
name = str(data.get("name") or data.get("skill") or tool_name)
bump("skill-use", name, _skill_summary(tool_name, data), ts, learned_from=learned_from, via=tool_name)
for call in _tool_calls(row["tool_calls"]):
name, args = call
if name == "session_search":
event = learning_event_from_tool(name, args, content)
if event:
bump("recall", event["title"], event["summary"], ts, learned_from=learned_from, via=name)
elif name in {"skill_view", "skill_manage"}:
skill_name = str(
args.get("name") or args.get("skill") or args.get("query") or name
)
bump("skill-use", skill_name, _skill_summary(name, args), ts, learned_from=learned_from, via=name)
elif name == "memory":
target = str(args.get("target") or "memory")
bump(target, f"{target} writes", "Durable memory updates", ts, learned_from=learned_from, via=name)
return list(usage.values())
def learning_event_from_tool(
tool_name: str,
args: dict[str, Any] | None = None,
result: str | None = None,
) -> dict[str, Any] | None:
args = args or {}
data = _json(result)
if tool_name == "memory":
target = str(args.get("target") or data.get("target") or "memory")
content = str(args.get("content") or "").strip()
return {
"type": target if target in {"memory", "user"} else "memory",
"verb": "remembered",
"title": _memory_title(content) if content else f"{target} updated",
"summary": "Durable memory updated",
"source": "memory",
"via": "memory",
}
if tool_name == "session_search":
title = _recall_title(data) or str(args.get("query") or "").strip() or "past sessions"
return {
"type": "recall",
"verb": "recalled",
"title": _one_line(title, max_len=120),
"summary": "Past conversations recalled",
"source": "state.db",
"via": "session_search",
}
if tool_name in {"skill_view", "skill_manage"}:
action = str(args.get("action") or data.get("action") or "").strip().lower()
name = str(args.get("name") or args.get("query") or data.get("name") or "skill").strip()
verb = "updated skill" if tool_name == "skill_manage" and action in {"create", "patch", "update", "install"} else "applied skill"
return {
"type": "skill-use",
"verb": verb,
"title": _one_line(name, max_len=120),
"summary": _skill_summary(tool_name, {**args, **(data if isinstance(data, dict) else {})}),
"source": "skills",
"via": tool_name,
}
return None
def _skill_summary(tool_name: str, data: dict[str, Any]) -> str:
action = str(data.get("action") or "").strip().lower()
if tool_name == "skill_manage" and action:
return f"Skill {action.replace('_', ' ')}"
if tool_name == "skill_manage":
return "Skill managed"
return "Skill reused"
def _recall_title(data: Any) -> str:
if not isinstance(data, dict):
return ""
results = data.get("results")
if not isinstance(results, list) or not results:
return str(data.get("query") or "").strip()
first = results[0] if isinstance(results[0], dict) else {}
return str(first.get("title") or first.get("preview") or data.get("query") or "").strip()
def _memory_title(content: str) -> str:
title = _one_line(content, max_len=120)
lowered = title.lower()
for prefix in ("the user ", "user "):
if lowered.startswith(prefix):
return title[len(prefix):].lstrip()
return title
def _integration_items() -> list[LedgerItem]:
try:
from hermes_cli.config import load_config
cfg = load_config()
except Exception:
return []
items: list[LedgerItem] = []
provider = ((cfg.get("memory") or {}) if isinstance(cfg, dict) else {}).get(
"provider"
)
if provider:
items.append(
LedgerItem(
type="integration",
name=f"{provider} memory provider",
summary="External memory provider is configured",
source="config.yaml",
)
)
for server in (
sorted(((cfg.get("mcp") or {}).get("servers") or {}).keys())
if isinstance(cfg, dict)
else []
):
items.append(
LedgerItem(
type="integration",
name=f"{server} MCP server",
summary="MCP server is configured",
source="config.yaml",
)
)
return items
def _tool_calls(raw: str | None) -> list[tuple[str, dict[str, Any]]]:
calls = _json(raw)
if not isinstance(calls, list):
return []
parsed = []
for call in calls:
if not isinstance(call, dict):
continue
fn = call.get("function") or {}
name = call.get("name") or fn.get("name")
args = fn.get("arguments") or call.get("arguments") or call.get("args") or {}
if isinstance(args, str):
args = _json(args)
if name:
parsed.append((str(name), args if isinstance(args, dict) else {}))
return parsed
def _json(raw: Any) -> Any:
if not raw:
return {}
if isinstance(raw, (dict, list)):
return raw
try:
return json.loads(raw)
except Exception:
return {}
def _mtime(path: Path) -> float | None:
try:
return path.stat().st_mtime
except OSError:
return None
def _float(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
def _one_line(text: str, *, max_len: int = 180) -> str:
line = " ".join(str(text).split())
return line[: max_len - 1] + "" if len(line) > max_len else line

View File

@@ -68,7 +68,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
welcome: "Welcome message" # Shown at CLI startup
goodbye: "Goodbye! ⚕" # Shown on exit
response_label: " ⚕ Hermes " # Response box header label
prompt_symbol: "" # Input prompt symbol (spacing is added by the UI)
prompt_symbol: "" # Input prompt symbol (bare token; renderers add trailing space)
help_header: "(^_^)? Commands" # /help header text
# Tool prefix: character for tool output lines (default: ┊)
@@ -103,6 +103,10 @@ BUILT-IN SKINS
- ``slate`` — Cool blue developer-focused theme
- ``daylight`` — Light background theme with dark text and blue accents
- ``warm-lightmode`` — Warm brown/gold text for light terminal backgrounds
- ``poseidon`` — Ocean-god theme (deep blue and seafoam)
- ``sisyphus`` — Austere grayscale with boulder motif
- ``charizard`` — Volcanic burnt-orange and ember
- ``bunnny`` — Barbie-pink coquette theme (sparkles, hearts, bunnies)
USER SKINS
==========
@@ -636,6 +640,83 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
[#F29C38]⠀⠀⠀⠀⠀⠀⠀⣼⡟⠀⠀⢻⣧⠀⠀⠀⠀⠀⠀⠀⠀[/]
[dim #7A3511]tail flame lit[/]""",
},
"bunnny": {
"name": "bunnny",
"description": "Barbie-pink coquette theme — sparkles, bows, and bubblegum",
"colors": {
"banner_border": "#E91E63",
"banner_title": "#FF3366",
"banner_accent": "#FF69B4",
"banner_dim": "#C2185B",
"banner_text": "#FFF0F5",
"ui_accent": "#FF3366",
"ui_label": "#FF69B4",
"ui_ok": "#FFB6C1",
"ui_error": "#FF1744",
"ui_warn": "#FFAB91",
"prompt": "#FFF0F5",
"input_rule": "#E91E63",
"response_border": "#FF69B4",
"status_bar_bg": "#2A0E1E",
"status_bar_text": "#FFE4EC",
"status_bar_strong": "#FF3366",
"status_bar_dim": "#8E4B6B",
"status_bar_good": "#FFB6C1",
"status_bar_warn": "#FF69B4",
"status_bar_bad": "#FF3366",
"status_bar_critical": "#FF1744",
"session_label": "#FF69B4",
"session_border": "#8E4B6B",
"voice_status_bg": "#2A0E1E",
"completion_menu_bg": "#2A0E1E",
"completion_menu_current_bg": "#5A1D3A",
"completion_menu_meta_bg": "#2A0E1E",
"completion_menu_meta_current_bg": "#5A1D3A",
},
"spinner": {
"waiting_faces": ["(♡)", "(✿)", "(✧)", "(❀)", "(ෆ)", "(˘ᵕ˘)", "(⑅)"],
"thinking_faces": ["(♡)", "(✧)", "(❀)", "(✿)", "(ෆ)", "(˘ᵕ˘)"],
"thinking_verbs": [
"sparkling", "twirling", "glittering", "frosting",
"bedazzling", "bowtying", "sprinkling sugar", "picking ribbons",
"glossing up", "curating the vibe", "dusting pink",
"tying a little bow", "making it cute",
],
"wings": [
["⟪♡", "♡⟫"],
["⟪✧", "✧⟫"],
["⟪✿", "✿⟫"],
["⟪❀", "❀⟫"],
["⟪ෆ", "ෆ⟫"],
],
},
"branding": {
"agent_name": "Hermes Agent",
"welcome": "hi bestie ♡ welcome to Hermes Agent! type your message or /help for commands (ノ◕ヮ◕)ノ*:・゚✧",
"goodbye": "bye bestie ♡ ✧",
"response_label": " ♡ Hermes ",
"prompt_symbol": "",
"help_header": "(ノ◕ヮ◕)ノ*:・゚✧ Commands",
},
"tool_prefix": "",
"banner_logo": """[bold #FFB6C1]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ ██╗ ██╗ [/]
[bold #FF69B4]██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ████████╗[/]
[#FF3C7F]███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗ ╚██████╔╝[/]
[#FF3366]██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║ ╚████╔╝ [/]
[#E91E63]██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ╚██╔╝ [/]
[#C2185B]╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ [/]""",
"banner_hero": """[#FF69B4]⠀⠀✧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀✧⠀⠀[/]
[#FFB6C1]⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠀⠀⠀⠀⠀⢀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀[/]
[#FF69B4]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣯⢬⣷⡀⠀⠀⣴⡯⢌⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
[#FF3366]⠀✿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿♡⠹⣷⠀⢸⡝♡⢸⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀✿⠀[/]
[#FF3C7F]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠻⣧⣀⣿⣦⣼⡁⣠⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
[#FF3366]⠀⠀⠀⠀✧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡾⠋⠀⠀⠀⠈⣙⣯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀✧[/]
[#FF3366]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⠀⠀⠀⠀⠀⠀⠀⠸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
[#E91E63]⠀⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀⠀⠀⠀⠀⢰⡧⢄⢰⡆⠀⢰⡆⡠⢄⣧⠀⠀⠀⠀⠀⠀⠀⠀♡⠀⠀⠀⠀⠀[/]
[#C2185B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠳⣼⣤⣤⣤⣤⣤⣧⠾⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
[#FF69B4]⠀⠀⠀⠀⠀✿⠀⠀⠀⠀⠀⠀❀⠀⠀⠀⠀⠀❀⠀⠀❀⠀⠀⠀⠀⠀❀⠀⠀⠀⠀⠀⠀✿⠀⠀⠀⠀⠀[/]
[dim #C2185B]xoxo[/]""",
},
}
@@ -780,12 +861,21 @@ def init_skin_from_config(config: dict) -> None:
# =============================================================================
def get_active_prompt_symbol(fallback: str = " ") -> str:
"""Get the interactive prompt symbol from the active skin."""
def get_active_prompt_symbol(fallback: str = "") -> str:
"""Return the interactive prompt symbol with a single trailing space.
Skins store ``prompt_symbol`` as a bare token (no spaces). The trailing
space is appended here so callers can drop it straight into a rendered
prompt without hand-rolling whitespace.
"""
try:
return get_active_skin().get_branding("prompt_symbol", fallback)
raw = get_active_skin().get_branding("prompt_symbol", fallback)
except Exception:
return fallback
raw = fallback
cleaned = (raw or fallback).strip()
return f"{cleaned or fallback.strip()} "

View File

@@ -26,12 +26,15 @@ def check_mark(ok: bool) -> str:
return color("", Colors.RED)
def redact_key(key: str) -> str:
"""Redact an API key for display."""
if not key:
return "(not set)"
if len(key) < 12:
return "***"
return key[:4] + "..." + key[-4:]
"""Redact an API key for display.
Thin wrapper over :func:`agent.redact.mask_secret`. Preserves the
"(not set)" placeholder in dim color to match ``hermes config``'s
output (previously this variant was missing the DIM color —
consolidated via PR that also introduced ``mask_secret``).
"""
from agent.redact import mask_secret
return mask_secret(key, empty=color("(not set)", Colors.DIM))
def _format_iso_timestamp(value) -> str:

View File

@@ -206,6 +206,27 @@ _LEGACY_TOOLSET_MAP = {
# get_tool_definitions (the main schema provider)
# =============================================================================
# Module-level memoization for get_tool_definitions(). Keyed on
# (frozenset(enabled_toolsets), frozenset(disabled_toolsets), registry._generation).
# Hot callers (gateway runner, AIAgent.__init__) invoke this on every turn
# with quiet_mode=True; caching avoids ~7 ms of registry walking + schema
# filtering + check_fn probing per call. Only active when quiet_mode=True
# because quiet_mode=False has stdout side effects (tool-selection prints).
#
# Invalidation happens transparently via the registry's _generation counter,
# which bumps on register() / deregister() / register_toolset_alias(). The
# inner check_fn TTL cache in registry.py handles environment drift (Docker
# daemon start/stop, env var changes, etc.) on a 30 s horizon.
_tool_defs_cache: Dict[tuple, List[Dict[str, Any]]] = {}
def _clear_tool_defs_cache() -> None:
"""Drop memoized get_tool_definitions() results. Called when dynamic
schema dependencies change (e.g. discord capability cache reset,
execute_code sandbox reconfigured)."""
_tool_defs_cache.clear()
def get_tool_definitions(
enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None,
@@ -224,6 +245,50 @@ def get_tool_definitions(
Returns:
Filtered list of OpenAI-format tool definitions.
"""
# Fast path: memoized result when the caller doesn't need stdout prints.
# The cache key captures every argument-level input; the registry
# generation captures registry mutations (MCP refresh, plugin load).
# check_fn results are TTL-cached one level down, inside
# registry.get_definitions. The config-mtime fingerprint below captures
# user-visible config edits that affect dynamic schemas (execute_code
# mode, discord action allowlist, etc.) without needing an explicit
# invalidate hook on every config-writer.
if quiet_mode:
try:
from hermes_cli.config import get_config_path
cfg_path = get_config_path()
cfg_stat = cfg_path.stat()
cfg_fp = (cfg_stat.st_mtime_ns, cfg_stat.st_size)
except (FileNotFoundError, OSError, ImportError):
cfg_fp = None
cache_key = (
frozenset(enabled_toolsets) if enabled_toolsets is not None else None,
frozenset(disabled_toolsets) if disabled_toolsets else None,
registry._generation,
cfg_fp,
)
cached = _tool_defs_cache.get(cache_key)
if cached is not None:
# Update _last_resolved_tool_names so downstream callers see
# consistent state even on a cache hit.
global _last_resolved_tool_names
_last_resolved_tool_names = [t["function"]["name"] for t in cached]
# Return a shallow copy of the list but share the dict references —
# schemas are treated as read-only by all known callers.
return list(cached)
result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode)
if quiet_mode:
_tool_defs_cache[cache_key] = result
return result
def _compute_tool_definitions(
enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None,
quiet_mode: bool = False,
) -> List[Dict[str, Any]]:
"""Uncached implementation of :func:`get_tool_definitions`."""
# Determine which tool names the caller wants
tools_to_include: set = set()

View File

@@ -165,6 +165,17 @@
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -z "$NEW_HASH" ]; then
# Magic-Nix-Cache occasionally returns HTTP 418 / cache-throttled
# mid-run; nix then prints "outputs not valid, so checking is
# not possible" without a `got:` line. That's an infrastructure
# blip, not a stale lockfile warn + skip rather than failing
# the lint. A real hash mismatch would still surface in the
# primary `.#$ATTR` build, which is a separate CI job.
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo " skipped (transient cache failure see primary nix build for real status)" >&2
echo "$OUTPUT" | tail -8 >&2
continue
fi
echo " build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2
exit 1

View File

@@ -4,7 +4,7 @@ let
src = ../web;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-AahWmJ9gDQ9pMPa1FYwUjYdO2mOi6JM9Mst27E0vp68=";
hash = "sha256-+B2+Fe4djPzHHcUXRx+m0cuyaopAhW0PcHsMgYfV5VE=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };

View File

@@ -3230,49 +3230,135 @@ class AIAgent:
)
_SKILL_REVIEW_PROMPT = (
"Review the conversation above and consider whether a skill should be saved or updated.\n\n"
"Work in this order — do not skip steps:\n\n"
"1. SURVEY the existing skill landscape first. Call skills_list to see what you "
"have. If anything looks potentially relevant, skill_view it before deciding. "
"You are looking for the CLASS of task that just happened, not the exact task. "
"Example: a successful Tauri build is in the class \"desktop app build "
"troubleshooting\", not \"fix my specific Tauri error today\".\n\n"
"2. THINK CLASS-FIRST. What general pattern of task did the user just complete? "
"What conditions will trigger this pattern again? Describe the class in one "
"sentence before looking at what to save.\n\n"
"3. PREFER GENERALIZING AN EXISTING SKILL over creating a new one. If a skill "
"already covers the class — even partially — update it (skill_manage patch) "
"with the new insight. Broaden its \"when to use\" trigger if needed.\n\n"
"4. ONLY CREATE A NEW SKILL when no existing skill reasonably covers the class. "
"When you create one, name and scope it at the class level "
"(\"react-i18n-setup\", not \"add-i18n-to-my-dashboard-app\"). The trigger "
"section must describe the class of situations, not this one session.\n\n"
"5. If you notice two existing skills that overlap, note it in your response "
"so a future review can consolidate them. Do not consolidate now unless the "
"overlap is obvious and low-risk.\n\n"
"Only act when something is genuinely worth saving. "
"If nothing stands out, just say 'Nothing to save.' and stop."
"Review the conversation above and update the skill library. Be "
"ACTIVE — most sessions produce at least one skill update, even if "
"small. A pass that does nothing is a missed learning opportunity, "
"not a neutral outcome.\n\n"
"Target shape of the library: CLASS-LEVEL skills, each with a rich "
"SKILL.md and a `references/` directory for session-specific detail. "
"Not a long flat list of narrow one-session-one-skill entries. This "
"shapes HOW you update, not WHETHER you update.\n\n"
"Signals to look for (any one of these warrants action):\n"
" • User corrected your style, tone, format, legibility, or "
"verbosity. Frustration signals like 'stop doing X', 'this is too "
"verbose', 'don't format like this', 'why are you explaining', "
"'just give me the answer', 'you always do Y and I hate it', or an "
"explicit 'remember this' are FIRST-CLASS skill signals, not just "
"memory signals. Update the relevant skill(s) to embed the "
"preference so the next session starts already knowing.\n"
" • User corrected your workflow, approach, or sequence of steps. "
"Encode the correction as a pitfall or explicit step in the skill "
"that governs that class of task.\n"
" • Non-trivial technique, fix, workaround, debugging path, or "
"tool-usage pattern emerged that a future session would benefit "
"from. Capture it.\n"
" • A skill that got loaded or consulted this session turned out "
"to be wrong, missing a step, or outdated. Patch it NOW.\n\n"
"Preference order — prefer the earliest action that fits, but do "
"pick one when a signal above fired:\n"
" 1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the "
"conversation for skills the user loaded via /skill-name or you "
"read via skill_view. If any of them covers the territory of the "
"new learning, PATCH that one first. It is the skill that was in "
"play, so it's the right one to extend.\n"
" 2. UPDATE AN EXISTING UMBRELLA (via skills_list + skill_view). "
"If no loaded skill fits but an existing class-level skill does, "
"patch it. Add a subsection, a pitfall, or broaden a trigger.\n"
" 3. ADD A SUPPORT FILE under an existing umbrella. Skills can be "
"packaged with three kinds of support files — use the right "
"directory per kind:\n"
" • `references/<topic>.md` — session-specific detail (error "
"transcripts, reproduction recipes, provider quirks) AND "
"condensed knowledge banks: quoted research, API docs, external "
"authoritative excerpts, or domain notes you found while working "
"on the problem. Write it concise and for the value of the task, "
"not as a full mirror of upstream docs.\n"
" • `templates/<name>.<ext>` — starter files meant to be "
"copied and modified (boilerplate configs, scaffolding, a "
"known-good example the agent can `reproduce with modifications`).\n"
" • `scripts/<name>.<ext>` — statically re-runnable actions "
"the skill can invoke directly (verification scripts, fixture "
"generators, deterministic probes, anything the agent should run "
"rather than hand-type each time).\n"
" Add support files via skill_manage action=write_file with "
"file_path starting 'references/', 'templates/', or 'scripts/'. "
"The umbrella's SKILL.md should gain a one-line pointer to any "
"new support file so future agents know it exists.\n"
" 4. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing "
"skill covers the class. The name MUST be at the class level. "
"The name MUST NOT be a specific PR number, error string, feature "
"codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' "
"session artifact. If the proposed name only makes sense for "
"today's task, it's wrong — fall back to (1), (2), or (3).\n\n"
"User-preference embedding (important): when the user expressed a "
"style/format/workflow preference, the update belongs in the "
"SKILL.md body, not just in memory. Memory captures 'who the user "
"is and what the current situation and state of your operations "
"are'; skills capture 'how to do this class of task for this "
"user'. When they complain about how you handled a task, the "
"skill that governs that task needs to carry the lesson.\n\n"
"If you notice two existing skills that overlap, note it in your "
"reply — the background curator handles consolidation at scale.\n\n"
"'Nothing to save.' is a real option but should NOT be the "
"default. If the session ran smoothly with no corrections and "
"produced no new technique, just say 'Nothing to save.' and stop. "
"Otherwise, act."
)
_COMBINED_REVIEW_PROMPT = (
"Review the conversation above and consider two things:\n\n"
"**Memory**: Has the user revealed things about themselves — their persona, "
"desires, preferences, or personal details? Has the user expressed expectations "
"about how you should behave, their work style, or ways they want you to operate? "
"If so, save using the memory tool.\n\n"
"**Skills**: Was a non-trivial approach used to complete a task that required trial "
"and error, changing course due to experiential findings, or a different method "
"or outcome than the user expected? If so, work in this order:\n"
" a. SURVEY existing skills first (skills_list, then skill_view on candidates).\n"
" b. Identify the CLASS of task, not the specific task "
"(\"desktop app build troubleshooting\", not \"fix my Tauri error\").\n"
" c. PREFER UPDATING/GENERALIZING an existing skill that covers the class.\n"
" d. ONLY CREATE A NEW SKILL if no existing one covers the class. Scope at "
"the class level, not this one session.\n"
" e. If you notice overlapping skills during the survey, note it so a future "
"review can consolidate them.\n\n"
"Only act if there's something genuinely worth saving. "
"If nothing stands out, just say 'Nothing to save.' and stop."
"Review the conversation above and update two things:\n\n"
"**Memory**: who the user is. Did the user reveal persona, "
"desires, preferences, personal details, or expectations about "
"how you should behave? Save facts about the user and durable "
"preferences with the memory tool.\n\n"
"**Skills**: how to do this class of task. Be ACTIVE — most "
"sessions produce at least one skill update. A pass that does "
"nothing is a missed learning opportunity, not a neutral outcome.\n\n"
"Target shape of the skill library: CLASS-LEVEL skills with a rich "
"SKILL.md and a `references/` directory for session-specific detail. "
"Not a long flat list of narrow one-session-one-skill entries.\n\n"
"Signals that warrant a skill update (any one is enough):\n"
" • User corrected your style, tone, format, legibility, "
"verbosity, or approach. Frustration is a FIRST-CLASS skill "
"signal, not just a memory signal. 'stop doing X', 'don't format "
"like this', 'I hate when you Y' — embed the lesson in the skill "
"that governs that task so the next session starts fixed.\n"
" • Non-trivial technique, fix, workaround, or debugging path "
"emerged.\n"
" • A skill that was loaded or consulted turned out wrong, "
"missing, or outdated — patch it now.\n\n"
"Preference order for skills — pick the earliest that fits:\n"
" 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were "
"loaded via /skill-name or skill_view in the conversation. If one "
"of them covers the learning, PATCH it first. It was in play; "
"it's the right place.\n"
" 2. UPDATE AN EXISTING UMBRELLA (skills_list + skill_view to "
"find the right one). Patch it.\n"
" 3. ADD A SUPPORT FILE under an existing umbrella via "
"skill_manage action=write_file. Three kinds: "
"`references/<topic>.md` for session-specific detail OR condensed "
"knowledge banks (quoted research, API docs excerpts, domain "
"notes) written concise and task-focused; `templates/<name>.<ext>` "
"for starter files meant to be copied and modified; "
"`scripts/<name>.<ext>` for statically re-runnable actions "
"(verification, fixture generators, probes). Add a one-line "
"pointer in SKILL.md so future agents find them.\n"
" 4. CREATE A NEW CLASS-LEVEL UMBRELLA when nothing exists. "
"Name at the class level — NOT a PR number, error string, "
"codename, library-alone name, or 'fix-X / debug-Y' session "
"artifact. If the name only fits today's task, fall back to (1), "
"(2), or (3).\n\n"
"User-preference embedding: when the user complains about how "
"you handled a task, update the skill that governs that task — "
"memory alone isn't enough. Memory says 'who the user is and "
"what the current situation and state of your operations are'; "
"skills say 'how to do this class of task for this user'. Both "
"should carry user-preference lessons when relevant.\n\n"
"If you notice overlapping existing skills, mention it — the "
"background curator handles consolidation.\n\n"
"Act on whichever of the two dimensions has real signal. If "
"genuinely nothing stands out on either, say 'Nothing to save.' "
"and stop — but don't reach for that conclusion as a default."
)
@staticmethod

View File

@@ -84,6 +84,7 @@ AUTHOR_MAP = {
"6548898+romanornr@users.noreply.github.com": "romanornr",
"foxion37@gmail.com": "foxion37",
"bloodcarter@gmail.com": "bloodcarter",
"scott@scotttrinh.com": "scotttrinh",
# contributors (from noreply pattern)
"david.vv@icloud.com": "davidvv",
"wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",

View File

@@ -82,11 +82,6 @@ class TestBuildToolPreview:
result = build_tool_preview("memory", {"action": "add", "target": "user", "content": "test note"})
assert result is not None
assert "user" in result
assert "\n" not in result
def test_memory_tool_add_without_target_stays_one_line(self):
result = build_tool_preview("memory", {"action": "add", "content": "User identifies as a cutie patootie."})
assert result == '+"User identifies as a cuti..."'
def test_memory_replace_missing_old_text_marked(self):
# Avoid empty quotes "" in the preview when old_text is missing/None.

View File

@@ -40,14 +40,14 @@ class TestCliSkinPromptIntegration:
cli = _make_cli_stub()
set_active_skin("ares")
assert cli._get_tui_prompt_fragments() == [("class:prompt", " ")]
assert cli._get_tui_prompt_fragments() == [("class:prompt", "")]
def test_secret_prompt_fragments_preserve_secret_state(self):
cli = _make_cli_stub()
cli._secret_state = {"response_queue": object()}
set_active_skin("ares")
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ")]
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ")]
def test_narrow_terminals_compact_voice_prompt_fragments(self):
cli = _make_cli_stub()

View File

@@ -480,3 +480,29 @@ def _enforce_test_timeout():
yield
signal.alarm(0)
signal.signal(signal.SIGALRM, old)
@pytest.fixture(autouse=True)
def _reset_tool_registry_caches():
"""Clear tool-registry-level caches between tests.
The production registry caches ``check_fn()`` results for 30 s
(see tools/registry.py) and :func:`get_tool_definitions` memoizes
its result (see model_tools.py). Both are keyed on state that tests
routinely mutate (env vars, registry._generation, config.yaml mtime)
— but a stale result from test A can still be served to test B
because 30 s covers the entire suite, and xdist worker reuse means
one test's cache lands in another's process. Clearing before every
test keeps hermetic behavior.
"""
try:
from tools.registry import invalidate_check_fn_cache
invalidate_check_fn_cache()
except ImportError:
pass
try:
from model_tools import _clear_tool_defs_cache
_clear_tool_defs_cache()
except ImportError:
pass
yield

View File

@@ -345,6 +345,59 @@ def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
assert "model.provider 'custom' is not a recognised provider" not in out
@pytest.mark.parametrize(
("provider", "default_model"),
[
("ai-gateway", "anthropic/claude-sonnet-4.6"),
("opencode-zen", "anthropic/claude-sonnet-4.6"),
("kilocode", "anthropic/claude-sonnet-4.6"),
("kimi-coding", "kimi-k2"),
],
)
def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
monkeypatch, tmp_path, provider, default_model
):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
f" provider: {provider}\n"
f" default: {default_model}\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert f"model.provider '{provider}' is not a recognised provider" not in out
assert f"model.provider '{provider}' is unknown" not in out
if provider in {"ai-gateway", "opencode-zen", "kilocode"}:
assert (
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
not in out
)
def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)

View File

@@ -252,7 +252,7 @@ class TestCliBrandingHelpers:
from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol
set_active_skin("ares")
assert get_active_prompt_symbol() == " "
assert get_active_prompt_symbol() == ""
def test_active_help_header_ares(self):
from hermes_cli.skin_engine import set_active_skin, get_active_help_header

View File

@@ -1,67 +1,176 @@
"""Behavior tests for the class-first skill review prompts.
"""Behavior tests for the skill review / combined review prompts.
The skill review / combined review prompts steer the background review agent
toward generalizing existing skills rather than accumulating near-duplicates.
These tests assert the behavioral *instructions* are present — they do NOT
The review prompts steer the background review agent toward actively updating
the skill library after most sessions, with a strong bias toward:
1. Patching currently-loaded skills first,
2. Patching existing umbrellas next,
3. Adding references/ files under an existing umbrella,
4. Creating a new class-level umbrella only when nothing else fits.
User-preference corrections (style, format, verbosity, legibility) are
first-class skill signals, not just memory signals.
These tests assert behavioral *instructions* are present — they do NOT
snapshot the full prompt text (change-detector).
"""
from run_agent import AIAgent
def test_skill_review_prompt_instructs_survey_first():
"""Prompt must tell the reviewer to list existing skills before deciding."""
# ---------------------------------------------------------------------------
# _SKILL_REVIEW_PROMPT
# ---------------------------------------------------------------------------
def test_skill_review_prompt_biases_toward_active_updates():
"""Prompt must frame updating as the default stance, not something rare."""
prompt = AIAgent._SKILL_REVIEW_PROMPT
assert "skills_list" in prompt, "must instruct the reviewer to call skills_list"
assert "skill_view" in prompt, "must instruct the reviewer to skill_view candidates"
assert "SURVEY" in prompt, "must name the survey step explicitly"
def test_skill_review_prompt_is_class_first():
"""Prompt must steer toward the CLASS of task, not the specific task."""
prompt = AIAgent._SKILL_REVIEW_PROMPT
assert "CLASS" in prompt, "must tell the reviewer to think about the task class"
assert "class level" in prompt, "must anchor naming at the class level"
def test_skill_review_prompt_prefers_updating_existing():
"""Prompt must prefer generalizing an existing skill over creating a new one."""
prompt = AIAgent._SKILL_REVIEW_PROMPT
assert "PREFER GENERALIZING" in prompt or "PREFER UPDATING" in prompt, (
"must state the update-over-create preference"
assert "ACTIVE" in prompt or "active" in prompt.lower(), (
"must tell the reviewer to be active"
)
assert "ONLY CREATE A NEW SKILL" in prompt, (
"must gate new-skill creation behind a last-resort clause"
# "missed learning opportunity" or equivalent framing for not acting
assert "missed" in prompt.lower() or "opportunity" in prompt.lower(), (
"must frame inaction as a miss, not a neutral outcome"
)
def test_skill_review_prompt_flags_overlap_for_followup():
"""Prompt must ask the reviewer to note overlapping skills for future review."""
def test_skill_review_prompt_treats_user_corrections_as_skill_signal():
"""Style/format/verbosity complaints must be FIRST-CLASS skill signals, not just memory."""
prompt = AIAgent._SKILL_REVIEW_PROMPT
assert "overlap" in prompt.lower(), "must mention the overlap-flagging protocol"
lower = prompt.lower()
# Must mention style/format/verbosity-family corrections
assert any(k in lower for k in ("style", "format", "verbos", "legib", "tone")), (
"must name style/format/verbosity/legibility as signals"
)
# Must frame these as first-class skill signals (not memory-only)
assert "FIRST-CLASS" in prompt or "first-class" in prompt, (
"must explicitly label user-preference corrections as first-class skill signals"
)
# Must mention the correction-type phrases to tune the model's ear
assert "stop doing" in lower or "don't" in lower or "hate" in lower or "frustrat" in lower, (
"must give concrete phrasing examples so the model recognizes corrections"
)
def test_skill_review_prompt_preserves_opt_out_clause():
"""The 'Nothing to save.' escape clause must remain."""
def test_skill_review_prompt_prefers_loaded_skills_first():
"""Currently-loaded skills must be the first patch target."""
prompt = AIAgent._SKILL_REVIEW_PROMPT
assert "LOADED" in prompt or "loaded" in prompt, (
"must mention currently-loaded skills"
)
# Must name the mechanisms for detecting loaded skills
assert "skill_view" in prompt and "/skill" in prompt, (
"must name skill_view and /skill-name as loaded-skill signals"
)
def test_skill_review_prompt_has_four_step_preference_order():
"""The 4-step patch/support-file/create ladder must be present."""
prompt = AIAgent._SKILL_REVIEW_PROMPT
assert "PATCH" in prompt
assert "references/" in prompt or "REFERENCE" in prompt
assert "CREATE" in prompt
assert "UMBRELLA" in prompt or "umbrella" in prompt
def test_skill_review_prompt_names_three_support_file_kinds():
"""Support-file step must name references/, templates/, and scripts/."""
prompt = AIAgent._SKILL_REVIEW_PROMPT
assert "references/" in prompt, "must name references/ as a support-file kind"
assert "templates/" in prompt, "must name templates/ as a support-file kind"
assert "scripts/" in prompt, "must name scripts/ as a support-file kind"
# Purpose hints for each kind
assert "knowledge" in prompt.lower() or "research" in prompt.lower() or "API docs" in prompt, (
"must mention knowledge-bank / research / API-docs role of references/"
)
assert "copied" in prompt.lower() or "starter" in prompt.lower() or "reproduce" in prompt.lower(), (
"must mention that templates/ are starter files to copy/modify"
)
assert "re-runnable" in prompt.lower() or "verification" in prompt.lower() or "probe" in prompt.lower(), (
"must mention that scripts/ are re-runnable actions"
)
def test_skill_review_prompt_has_name_veto_for_create():
"""Creating a new skill must be gated behind class-level naming."""
prompt = AIAgent._SKILL_REVIEW_PROMPT
assert "class level" in prompt.lower() or "CLASS-LEVEL" in prompt
assert "MUST NOT" in prompt or "must not" in prompt, (
"must have a name-veto clause blocking session-artifact names"
)
def test_skill_review_prompt_embeds_user_preferences_in_skills():
"""Must explicitly say user-preference lessons belong in SKILL.md, not only memory."""
prompt = AIAgent._SKILL_REVIEW_PROMPT
lower = prompt.lower()
assert "preference" in lower, "must mention user preferences"
assert "memory" in lower and "skill" in lower, (
"must contrast memory vs skill responsibilities"
)
def test_skill_review_prompt_flags_overlap_and_defers_to_curator():
"""Reviewer should not consolidate live; flag overlap for the curator."""
prompt = AIAgent._SKILL_REVIEW_PROMPT
assert "overlap" in prompt.lower()
assert "curator" in prompt.lower(), "must defer consolidation to the curator"
def test_skill_review_prompt_still_has_opt_out_clause():
"""'Nothing to save.' must remain as a real-but-not-default option."""
prompt = AIAgent._SKILL_REVIEW_PROMPT
assert "Nothing to save." in prompt
def test_combined_review_prompt_keeps_memory_section():
"""Combined prompt must still cover memory review."""
# ---------------------------------------------------------------------------
# _COMBINED_REVIEW_PROMPT
# ---------------------------------------------------------------------------
def test_combined_review_prompt_has_memory_section():
"""Memory half must still cover user facts and preferences."""
prompt = AIAgent._COMBINED_REVIEW_PROMPT
assert "**Memory**" in prompt
assert "memory tool" in prompt
def test_combined_review_prompt_skills_section_is_class_first():
"""The **Skills** half of the combined prompt must follow the same protocol."""
def test_combined_review_prompt_skills_biased_toward_active_updates():
"""Skills half must carry the active-update bias."""
prompt = AIAgent._COMBINED_REVIEW_PROMPT
assert "**Skills**" in prompt
assert "SURVEY" in prompt
assert "CLASS" in prompt
assert "skills_list" in prompt
assert "ONLY CREATE A NEW SKILL" in prompt
assert "ACTIVE" in prompt or "active" in prompt.lower()
assert "missed" in prompt.lower() or "opportunity" in prompt.lower()
def test_combined_review_prompt_treats_user_corrections_as_skill_signal():
"""Combined prompt must carry the same user-preference-is-skill-signal rule."""
prompt = AIAgent._COMBINED_REVIEW_PROMPT
lower = prompt.lower()
assert any(k in lower for k in ("style", "format", "verbos", "legib", "tone"))
assert "FIRST-CLASS" in prompt or "first-class" in prompt
def test_combined_review_prompt_prefers_loaded_skills_first():
"""Combined prompt must also prefer loaded skills first."""
prompt = AIAgent._COMBINED_REVIEW_PROMPT
assert "LOADED" in prompt or "loaded" in prompt
assert "skill_view" in prompt and "/skill" in prompt
def test_combined_review_prompt_has_four_step_skill_ladder():
"""Combined prompt must keep the patch/support-file/create ladder on the Skills half."""
prompt = AIAgent._COMBINED_REVIEW_PROMPT
assert "PATCH" in prompt
assert "references/" in prompt or "REFERENCE" in prompt
assert "CREATE" in prompt
assert "CLASS-LEVEL" in prompt or "class-level" in prompt or "class level" in prompt.lower()
def test_combined_review_prompt_names_three_support_file_kinds():
"""Combined prompt must also name all three support-file kinds."""
prompt = AIAgent._COMBINED_REVIEW_PROMPT
assert "references/" in prompt
assert "templates/" in prompt
assert "scripts/" in prompt
def test_combined_review_prompt_preserves_opt_out_clause():
@@ -69,10 +178,14 @@ def test_combined_review_prompt_preserves_opt_out_clause():
assert "Nothing to save." in prompt
def test_memory_review_prompt_unchanged_in_structure():
# ---------------------------------------------------------------------------
# _MEMORY_REVIEW_PROMPT — unchanged, still memory-focused
# ---------------------------------------------------------------------------
def test_memory_review_prompt_still_focused_on_user_facts():
"""Memory-only review prompt stays focused on user facts — not touched by this change."""
prompt = AIAgent._MEMORY_REVIEW_PROMPT
# Guardrails: the memory-only prompt must NOT mention skills/surveys.
# The memory-only prompt should NOT drift into skill territory
assert "skills_list" not in prompt
assert "SURVEY" not in prompt
assert "memory tool" in prompt

View File

@@ -40,14 +40,14 @@ class TestCliSkinPromptIntegration:
cli = _make_cli_stub()
set_active_skin("ares")
assert cli._get_tui_prompt_fragments() == [("class:prompt", " ")]
assert cli._get_tui_prompt_fragments() == [("class:prompt", "")]
def test_secret_prompt_fragments_preserve_secret_state(self):
cli = _make_cli_stub()
cli._secret_state = {"response_queue": object()}
set_active_skin("ares")
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ")]
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ")]
def test_icon_only_skin_symbol_still_visible_in_special_states(self):
cli = _make_cli_stub()

View File

@@ -944,6 +944,39 @@ def test_config_set_section_rejects_unknown_section_or_mode(tmp_path, monkeypatc
assert bad_mode["error"]["code"] == 4002
def test_config_mouse_uses_documented_key_with_legacy_fallback(monkeypatch):
cfg = {"display": {"tui_mouse": False}}
writes = []
monkeypatch.setattr(server, "_load_cfg", lambda: cfg)
monkeypatch.setattr(
server, "_write_config_key", lambda path, value: writes.append((path, value))
)
get_legacy = server.handle_request(
{"id": "1", "method": "config.get", "params": {"key": "mouse"}}
)
assert get_legacy["result"]["value"] == "off"
set_toggle = server.handle_request(
{"id": "2", "method": "config.set", "params": {"key": "mouse"}}
)
assert set_toggle["result"] == {"key": "mouse", "value": "on"}
assert writes == [("display.mouse_tracking", True)]
cfg["display"] = {"mouse_tracking": 0, "tui_mouse": True}
get_canonical = server.handle_request(
{"id": "3", "method": "config.get", "params": {"key": "mouse"}}
)
assert get_canonical["result"]["value"] == "off"
cfg["display"] = {"mouse_tracking": None, "tui_mouse": False}
get_null = server.handle_request(
{"id": "4", "method": "config.get", "params": {"key": "mouse"}}
)
assert get_null["result"]["value"] == "on"
def test_enable_gateway_prompts_sets_gateway_env(monkeypatch):
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
@@ -2721,3 +2754,385 @@ def test_session_most_recent_handles_db_unavailable(monkeypatch):
)
assert resp["result"]["session_id"] is None
# ── browser.manage ───────────────────────────────────────────────────
def _stub_urlopen(monkeypatch, *, ok: bool):
"""Patch urllib.request.urlopen used by browser.manage to short-circuit probes."""
class _Resp:
status = 200 if ok else 503
def __enter__(self):
return self
def __exit__(self, *_):
return False
def _opener(_url, timeout=2.0): # noqa: ARG001 — match urllib signature
if not ok:
raise OSError("probe failed")
return _Resp()
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", _opener)
def test_browser_manage_status_reads_env_var(monkeypatch):
"""Status returns the env var verbatim (no network I/O)."""
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
resp = server.handle_request(
{"id": "1", "method": "browser.manage", "params": {"action": "status"}}
)
assert resp["result"] == {"connected": True, "url": "http://127.0.0.1:9222"}
def test_browser_manage_status_falls_back_to_config_cdp_url(monkeypatch):
"""When env is unset, status surfaces ``browser.cdp_url`` from
config.yaml so users see what the next tool call will read."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake_cfg = types.SimpleNamespace(
read_raw_config=lambda: {"browser": {"cdp_url": "http://lan:9222"}}
)
with patch.dict(sys.modules, {"hermes_cli.config": fake_cfg}):
resp = server.handle_request(
{"id": "1", "method": "browser.manage", "params": {"action": "status"}}
)
assert resp["result"] == {"connected": True, "url": "http://lan:9222"}
def test_browser_manage_status_does_not_call_get_cdp_override(monkeypatch):
"""Regression guard for Copilot's "status must not block" review:
status must NOT route through `_get_cdp_override`, which performs a
`/json/version` HTTP probe with a multi-second timeout."""
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
fake = types.SimpleNamespace(
_get_cdp_override=lambda: pytest.fail( # noqa: PT015 — fail loudly if called
"_get_cdp_override must not run on /browser status (network I/O)"
)
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
resp = server.handle_request(
{"id": "1", "method": "browser.manage", "params": {"action": "status"}}
)
assert resp["result"]["connected"] is True
def test_browser_manage_connect_sets_env_and_cleans_twice(monkeypatch):
"""`/browser connect` must reach the live process: set env, reap browser
sessions before AND after publishing the new URL. The double-cleanup
closes the supervisor swap window where ``_ensure_cdp_supervisor``
could re-attach to the *old* CDP endpoint between steps."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
cleanup_calls: list[str] = []
def _cleanup_all():
cleanup_calls.append(os.environ.get("BROWSER_CDP_URL", ""))
fake = types.SimpleNamespace(
cleanup_all_browsers=_cleanup_all,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
_stub_urlopen(monkeypatch, ok=True)
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": "http://127.0.0.1:9222"},
}
)
assert resp["result"] == {"connected": True, "url": "http://127.0.0.1:9222"}
assert os.environ.get("BROWSER_CDP_URL") == "http://127.0.0.1:9222"
# First cleanup runs against the OLD env (none here), second against the NEW.
assert cleanup_calls == ["", "http://127.0.0.1:9222"]
def test_browser_manage_connect_rejects_unreachable_endpoint(monkeypatch):
"""An unreachable endpoint must NOT mutate the env or reap sessions."""
monkeypatch.setenv("BROWSER_CDP_URL", "http://existing:9222")
cleanup_calls: list[str] = []
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: cleanup_calls.append(os.environ.get("BROWSER_CDP_URL", "")),
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
_stub_urlopen(monkeypatch, ok=False)
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": "http://unreachable:9222"},
}
)
assert "error" in resp
# Env preserved; nothing reaped.
assert os.environ["BROWSER_CDP_URL"] == "http://existing:9222"
assert cleanup_calls == []
def test_browser_manage_connect_normalizes_bare_host_port(monkeypatch):
"""Persist a parsed `scheme://host:port` URL so `_get_cdp_override`
can normalize it; storing a bare host:port would break subsequent
tool calls (Copilot review on #17120)."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
_stub_urlopen(monkeypatch, ok=True)
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": "127.0.0.1:9222"},
}
)
assert resp["result"]["connected"] is True
# Bare host:port got promoted to a full URL with explicit scheme.
assert resp["result"]["url"].startswith("http://")
assert os.environ["BROWSER_CDP_URL"].startswith("http://")
def test_browser_manage_connect_strips_discovery_path(monkeypatch):
"""User-supplied discovery paths like `/json` or `/json/version`
must collapse to bare `scheme://host:port`; otherwise
``_resolve_cdp_override`` will append ``/json/version`` again and
produce a duplicate path (Copilot review round-2 on #17120)."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
_stub_urlopen(monkeypatch, ok=True)
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": "http://127.0.0.1:9222/json"},
}
)
assert resp["result"]["connected"] is True
assert resp["result"]["url"] == "http://127.0.0.1:9222"
assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9222"
def test_browser_manage_connect_preserves_devtools_browser_endpoint(monkeypatch):
"""Concrete devtools websocket endpoints (e.g. Browserbase) must
survive verbatim — we only collapse discovery-style paths."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
concrete = "ws://browserbase.example/devtools/browser/abc123"
class _OkSocket:
def __enter__(self): return self
def __exit__(self, *a): return False
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
# If urlopen is reached for a concrete ws endpoint, the test
# would still pass because _stub_urlopen returned ok=True before;
# patch it to assert-fail so we prove the HTTP probe is skipped.
with patch("urllib.request.urlopen", side_effect=AssertionError("urlopen called")):
with patch("socket.create_connection", return_value=_OkSocket()):
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": concrete},
}
)
assert resp["result"]["connected"] is True
assert resp["result"]["url"] == concrete
assert os.environ["BROWSER_CDP_URL"] == concrete
def test_browser_manage_connect_concrete_ws_skips_http_probe(monkeypatch):
"""Regression for round-2 Copilot review: a hosted CDP endpoint
(no HTTP discovery) must connect via TCP-only reachability check.
The HTTP probe used to reject these even though they're valid."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
concrete = "wss://chrome.browserless.io/devtools/browser/sess-1"
seen_targets: list[tuple[str, int]] = []
class _OkSocket:
def __enter__(self): return self
def __exit__(self, *a): return False
def _fake_create_connection(addr, timeout=None):
seen_targets.append(addr)
return _OkSocket()
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
# urlopen would 404/ECONNREFUSED on a real hosted CDP endpoint;
# asserting it's never called proves the probe was skipped.
with patch("urllib.request.urlopen", side_effect=AssertionError("urlopen called")):
with patch("socket.create_connection", side_effect=_fake_create_connection):
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": concrete},
}
)
assert resp["result"] == {"connected": True, "url": concrete}
# wss → port 443, host preserved verbatim.
assert seen_targets == [("chrome.browserless.io", 443)]
def test_browser_manage_connect_concrete_ws_tcp_unreachable(monkeypatch):
"""If the TCP reachability check fails for a concrete ws endpoint,
return a clear 5031 error — no fallback to the HTTP probe (which
can never succeed for these URLs anyway)."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
concrete = "ws://offline.example/devtools/browser/missing"
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
with patch("socket.create_connection", side_effect=OSError("ECONNREFUSED")):
resp = server.handle_request(
{
"id": "1",
"method": "browser.manage",
"params": {"action": "connect", "url": concrete},
}
)
assert "error" in resp
assert resp["error"]["code"] == 5031
def test_browser_manage_disconnect_drops_env_and_cleans(monkeypatch):
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
cleanup_count = {"n": 0}
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: cleanup_count.__setitem__("n", cleanup_count["n"] + 1),
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
resp = server.handle_request(
{"id": "1", "method": "browser.manage", "params": {"action": "disconnect"}}
)
assert resp["result"] == {"connected": False}
assert "BROWSER_CDP_URL" not in os.environ
# Two cleanups: once before env removal, once after, matching connect.
assert cleanup_count["n"] == 2
# ── config.get indicator normalization ───────────────────────────────
def test_config_get_indicator_returns_known_value_verbatim(monkeypatch):
monkeypatch.setattr(
server, "_load_cfg", lambda: {"display": {"tui_status_indicator": "emoji"}}
)
resp = server.handle_request(
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
)
assert resp["result"] == {"value": "emoji"}
def test_config_get_indicator_normalizes_casing_and_whitespace(monkeypatch):
"""Hand-edited config.yaml stays consistent with what the TUI shows.
Frontend's `normalizeIndicatorStyle` lowercases + trims, so config.get
must do the same — otherwise `/indicator` prints 'EMOJI ' while the
UI is actually rendering the kaomoji default."""
monkeypatch.setattr(
server, "_load_cfg", lambda: {"display": {"tui_status_indicator": " EMOJI "}}
)
resp = server.handle_request(
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
)
assert resp["result"] == {"value": "emoji"}
def test_config_get_indicator_falls_back_to_default_for_unknown(monkeypatch):
"""An unknown value in config.yaml falls back to the same default
the frontend uses (`_INDICATOR_DEFAULT`)."""
monkeypatch.setattr(
server, "_load_cfg", lambda: {"display": {"tui_status_indicator": "rainbow"}}
)
resp = server.handle_request(
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
)
assert resp["result"] == {"value": "kaomoji"}
def test_config_get_indicator_falls_back_when_unset(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda: {"display": {}})
resp = server.handle_request(
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
)
assert resp["result"] == {"value": "kaomoji"}
# ── config.set indicator validation ──────────────────────────────────
def test_config_set_indicator_accepts_known_value(monkeypatch):
written: dict = {}
monkeypatch.setattr(
server, "_write_config_key",
lambda k, v: written.update({k: v}),
)
resp = server.handle_request(
{"id": "1", "method": "config.set", "params": {"key": "indicator", "value": "EMOJI"}}
)
assert resp["result"] == {"key": "indicator", "value": "emoji"}
assert written == {"display.tui_status_indicator": "emoji"}
def test_config_set_indicator_falsy_non_string_surfaces_in_error(monkeypatch):
"""`0` / `False` / `[]` are not valid styles, but the error message
must still tell the user what they sent — `value or ""` would have
erased them to a blank string."""
monkeypatch.setattr(server, "_write_config_key", lambda *a, **k: None)
for bad in (0, False, []):
resp = server.handle_request(
{"id": "1", "method": "config.set", "params": {"key": "indicator", "value": bad}}
)
assert "error" in resp
msg = resp["error"]["message"]
assert "unknown indicator" in msg
# The exact repr varies; `0`/`False` stringify with content,
# `[]` becomes an empty list — what matters is the diagnostic
# is no longer just `unknown indicator: ` with nothing after.
assert msg.split("; ")[0] != "unknown indicator: ''"
def test_config_set_indicator_none_keeps_blank_repr(monkeypatch):
"""`None` is the genuine 'no value' case — empty raw is acceptable."""
monkeypatch.setattr(server, "_write_config_key", lambda *a, **k: None)
resp = server.handle_request(
{"id": "1", "method": "config.set", "params": {"key": "indicator", "value": None}}
)
assert "error" in resp
assert "unknown indicator: ''" in resp["error"]["message"]

View File

@@ -83,6 +83,134 @@ def test_write_json_broken_pipe(server):
assert server.write_json({"x": 1}) is False
def test_write_json_closed_stream_returns_false(server):
"""ValueError ('I/O on closed file') used to bubble up; treat as gone."""
class _Closed:
def write(self, _): raise ValueError("I/O operation on closed file")
def flush(self): raise ValueError("I/O operation on closed file")
server._real_stdout = _Closed()
assert server.write_json({"x": 1}) is False
def test_write_json_unicode_encode_error_re_raises(server):
"""A non-UTF-8 stdout encoding raises UnicodeEncodeError (a ValueError
subclass). It must NOT be swallowed as 'peer gone' — that would let
`entry.py` exit cleanly via the False path and hide the real config
bug. We re-raise so the existing crash-log infrastructure records it."""
class _AsciiOnly:
def write(self, line):
line.encode("ascii") # raises UnicodeEncodeError on non-ascii
def flush(self): pass
server._real_stdout = _AsciiOnly()
with pytest.raises(UnicodeEncodeError):
server.write_json({"msg": "héllo"})
def test_write_json_unrelated_value_error_re_raises(server):
"""Only ValueError('...closed file...') means peer gone. Other
ValueErrors are programming errors and must surface."""
class _BadValue:
def write(self, _): raise ValueError("something else entirely")
def flush(self): pass
server._real_stdout = _BadValue()
with pytest.raises(ValueError, match="something else entirely"):
server.write_json({"x": 1})
def test_write_json_non_serializable_payload_re_raises(server):
"""Non-JSON-safe payloads are programming errors — they must NOT be
silently dropped via the False path (which would trigger a clean exit
in entry.py and mask the real bug)."""
import io
server._real_stdout = io.StringIO()
with pytest.raises(TypeError):
server.write_json({"obj": object()})
def test_write_json_peer_gone_oserror_on_flush_returns_false(server):
"""A flush that raises a peer-gone OSError (EPIPE) must not strand
the lock or crash; it returns False so the dispatcher exits cleanly."""
import errno
written = []
class _FlushPeerGone:
def write(self, line): written.append(line)
def flush(self): raise OSError(errno.EPIPE, "broken pipe")
server._real_stdout = _FlushPeerGone()
assert server.write_json({"x": 1}) is False
assert written and json.loads(written[0]) == {"x": 1}
def test_write_json_non_peer_gone_oserror_re_raises(server):
"""Host I/O failures (ENOSPC, EACCES, EIO …) are NOT peer-gone — they
must re-raise so the crash log records them instead of looking like
a clean disconnect via the False path."""
import errno
class _DiskFull:
def write(self, _): raise OSError(errno.ENOSPC, "no space left")
def flush(self): pass
server._real_stdout = _DiskFull()
with pytest.raises(OSError, match="no space"):
server.write_json({"x": 1})
def test_write_json_skips_flush_when_disable_flush_true(monkeypatch):
"""`StdioTransport` skips flush when `_DISABLE_FLUSH` is true.
Tests the runtime *behaviour* via direct module-attr patch. The env
var → module constant wiring is covered by the dedicated env test
below; reloading server.py here would re-register atexit hooks and
recreate the worker pool.
"""
import importlib
transport_mod = importlib.import_module("tui_gateway.transport")
monkeypatch.setattr(transport_mod, "_DISABLE_FLUSH", True)
flushed = {"count": 0}
written = []
class _Stream:
def write(self, line): written.append(line)
def flush(self): flushed["count"] += 1
stream = _Stream()
transport = transport_mod.StdioTransport(lambda: stream, threading.Lock())
assert transport.write({"x": 1}) is True
assert flushed["count"] == 0
def test_disable_flush_env_var_actually_wires_to_module_constant(monkeypatch):
"""End-to-end: setting `HERMES_TUI_GATEWAY_NO_FLUSH=1` and importing
`tui_gateway.transport` fresh actually flips `_DISABLE_FLUSH` true.
Reloads only the transport module — server.py is untouched so its
atexit hooks/worker pool stay intact."""
import importlib
monkeypatch.setenv("HERMES_TUI_GATEWAY_NO_FLUSH", "1")
transport_mod = importlib.reload(importlib.import_module("tui_gateway.transport"))
try:
assert transport_mod._DISABLE_FLUSH is True
finally:
# Restore the env-disabled state so other tests see the default.
monkeypatch.delenv("HERMES_TUI_GATEWAY_NO_FLUSH", raising=False)
importlib.reload(transport_mod)
# ── _emit ────────────────────────────────────────────────────────────

View File

@@ -164,6 +164,18 @@ HARDLINE_PATTERNS = [
(_CMDPOS + r'telinit\s+[06]\b', "telinit 0/6 (shutdown/reboot)"),
]
# Pre-compiled variant used by the hot-path matcher. Building these at module
# load eliminates the ~2.6 ms cold-cache re.compile fan-out on the first
# terminal() call per process (12 HARDLINE + 47 DANGEROUS patterns, each
# potentially evicted from Python's 512-entry ``re._cache`` by unrelated
# regex work elsewhere in the agent). DANGEROUS_PATTERNS_COMPILED is built
# at the end of this module after DANGEROUS_PATTERNS is defined.
_RE_FLAGS = re.IGNORECASE | re.DOTALL
HARDLINE_PATTERNS_COMPILED = [
(re.compile(pattern, _RE_FLAGS), description)
for pattern, description in HARDLINE_PATTERNS
]
def detect_hardline_command(command: str) -> tuple:
"""Check if a command matches the unconditional hardline blocklist.
@@ -172,8 +184,8 @@ def detect_hardline_command(command: str) -> tuple:
(is_hardline, description) or (False, None)
"""
normalized = _normalize_command_for_detection(command).lower()
for pattern, description in HARDLINE_PATTERNS:
if re.search(pattern, normalized, re.IGNORECASE | re.DOTALL):
for pattern_re, description in HARDLINE_PATTERNS_COMPILED:
if pattern_re.search(normalized):
return (True, description)
return (False, None)
@@ -267,6 +279,13 @@ DANGEROUS_PATTERNS = [
]
# Pre-compiled variant (same rationale as HARDLINE_PATTERNS_COMPILED above).
DANGEROUS_PATTERNS_COMPILED = [
(re.compile(pattern, _RE_FLAGS), description)
for pattern, description in DANGEROUS_PATTERNS
]
def _legacy_pattern_key(pattern: str) -> str:
"""Reproduce the old regex-derived approval key for backwards compatibility."""
return pattern.split(r'\b')[1] if r'\b' in pattern else pattern[:20]
@@ -319,8 +338,8 @@ def detect_dangerous_command(command: str) -> tuple:
(is_dangerous, pattern_key, description) or (False, None, None)
"""
command_lower = _normalize_command_for_detection(command).lower()
for pattern, description in DANGEROUS_PATTERNS:
if re.search(pattern, command_lower, re.IGNORECASE | re.DOTALL):
for pattern_re, description in DANGEROUS_PATTERNS_COMPILED:
if pattern_re.search(command_lower):
pattern_key = description
return (True, pattern_key, description)
return (False, None, None)

View File

@@ -19,6 +19,7 @@ import importlib
import json
import logging
import threading
import time
from pathlib import Path
from typing import Callable, Dict, List, Optional, Set
@@ -97,6 +98,48 @@ class ToolEntry:
self.max_result_size_chars = max_result_size_chars
# ---------------------------------------------------------------------------
# check_fn TTL cache
#
# check_fn callables like tools/terminal_tool.check_terminal_requirements
# probe external state (Docker daemon, Modal SDK install, playwright binary
# availability). For a long-lived CLI or gateway process, calling them on
# every get_definitions() is pure waste — external state changes on human
# timescales. Cache results for ~30 s so env-var flips via ``hermes tools``
# or live credential file changes propagate within a turn or two without
# requiring any explicit invalidation.
# ---------------------------------------------------------------------------
_CHECK_FN_TTL_SECONDS = 30.0
_check_fn_cache: Dict[Callable, tuple[float, bool]] = {}
_check_fn_cache_lock = threading.Lock()
def _check_fn_cached(fn: Callable) -> bool:
"""Return bool(fn()), TTL-cached across calls. Swallows exceptions as False."""
now = time.monotonic()
with _check_fn_cache_lock:
cached = _check_fn_cache.get(fn)
if cached is not None:
ts, value = cached
if now - ts < _CHECK_FN_TTL_SECONDS:
return value
try:
value = bool(fn())
except Exception:
value = False
with _check_fn_cache_lock:
_check_fn_cache[fn] = (now, value)
return value
def invalidate_check_fn_cache() -> None:
"""Drop all cached ``check_fn`` results. Call after config changes that
affect tool availability (e.g. ``hermes tools enable``)."""
with _check_fn_cache_lock:
_check_fn_cache.clear()
class ToolRegistry:
"""Singleton registry that collects tool schemas + handlers from tool files."""
@@ -108,6 +151,12 @@ class ToolRegistry:
# reading tool metadata, so keep mutations serialized and readers on
# stable snapshots.
self._lock = threading.RLock()
# Monotonically-increasing generation counter. Bumped on every
# mutation (register / deregister / register_toolset_alias / MCP
# refresh). External callers (e.g. get_tool_definitions) can memoize
# against it: a cache entry keyed on the generation is valid for as
# long as the generation hasn't changed.
self._generation: int = 0
def _snapshot_state(self) -> tuple[List[ToolEntry], Dict[str, Callable]]:
"""Return a coherent snapshot of registry entries and toolset checks."""
@@ -158,6 +207,7 @@ class ToolRegistry:
alias, existing, toolset,
)
self._toolset_aliases[alias] = toolset
self._generation += 1
def get_registered_toolset_aliases(self) -> Dict[str, str]:
"""Return a snapshot of ``{alias: canonical_toolset}`` mappings."""
@@ -225,6 +275,7 @@ class ToolRegistry:
)
if check_fn and toolset not in self._toolset_checks:
self._toolset_checks[toolset] = check_fn
self._generation += 1
def deregister(self, name: str) -> None:
"""Remove a tool from the registry.
@@ -249,6 +300,7 @@ class ToolRegistry:
for alias, target in self._toolset_aliases.items()
if target != entry.toolset
}
self._generation += 1
logger.debug("Deregistered tool: %s", name)
# ------------------------------------------------------------------
@@ -259,9 +311,17 @@ class ToolRegistry:
"""Return OpenAI-format tool schemas for the requested tool names.
Only tools whose ``check_fn()`` returns True (or have no check_fn)
are included.
are included. ``check_fn()`` results are cached for ~30 s via
:func:`_check_fn_cached` to amortize repeat probes (check_terminal_
requirements probes modal/docker, browser checks probe playwright,
etc.); TTL chosen so env-var changes (``hermes tools enable foo``)
still take effect in near-real-time without forcing a full cache
flush on every call.
"""
result = []
# Per-call cache on top of the 30 s TTL — handles repeat probes of the
# same check_fn within one definitions pass without re-reading the
# TTL clock.
check_results: Dict[Callable, bool] = {}
entries_by_name = {entry.name: entry for entry in self._snapshot_entries()}
for name in sorted(tool_names):
@@ -270,12 +330,7 @@ class ToolRegistry:
continue
if entry.check_fn:
if entry.check_fn not in check_results:
try:
check_results[entry.check_fn] = bool(entry.check_fn())
except Exception:
check_results[entry.check_fn] = False
if not quiet:
logger.debug("Tool %s check raised; skipping", name)
check_results[entry.check_fn] = _check_fn_cached(entry.check_fn)
if not check_results[entry.check_fn]:
if not quiet:
logger.debug("Tool %s unavailable (check failed)", name)

View File

@@ -45,12 +45,47 @@ import logging
import os
import re
import asyncio
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any, Optional, TYPE_CHECKING
import httpx
# NOTE: `from firecrawl import Firecrawl` is deliberately NOT at module top —
# the SDK pulls ~200 ms of imports (httpcore, firecrawl.v1/v2 type trees) and
# we only need it when the backend is actually "firecrawl". See
# _get_firecrawl_client() below for the lazy import.
# we only need it when the backend is actually "firecrawl". We expose
# ``Firecrawl`` as a thin proxy that imports the SDK on first call/
# isinstance check, so both (a) the in-module ``Firecrawl(...)`` construction
# site in _get_firecrawl_client() works unchanged, and (b) tests using
# ``patch("tools.web_tools.Firecrawl", ...)`` keep working.
if TYPE_CHECKING:
from firecrawl import Firecrawl # noqa: F401 — type hints only
_FIRECRAWL_CLS_CACHE: Optional[type] = None
def _load_firecrawl_cls() -> type:
"""Import and cache ``firecrawl.Firecrawl``."""
global _FIRECRAWL_CLS_CACHE
if _FIRECRAWL_CLS_CACHE is None:
from firecrawl import Firecrawl as _cls
_FIRECRAWL_CLS_CACHE = _cls
return _FIRECRAWL_CLS_CACHE
class _FirecrawlProxy:
"""Module-level proxy that looks like ``firecrawl.Firecrawl`` but imports lazily."""
__slots__ = ()
def __call__(self, *args, **kwargs):
return _load_firecrawl_cls()(*args, **kwargs)
def __instancecheck__(self, obj):
return isinstance(obj, _load_firecrawl_cls())
def __repr__(self):
return "<lazy firecrawl.Firecrawl proxy>"
Firecrawl = _FirecrawlProxy()
from agent.auxiliary_client import (
async_call_llm,
extract_content_or_reasoning,
@@ -239,8 +274,7 @@ def _get_firecrawl_client():
if _firecrawl_client is not None and _firecrawl_client_config == client_config:
return _firecrawl_client
# Lazy import — ~200 ms of SDK init, only paid when firecrawl is actually used.
from firecrawl import Firecrawl # noqa: E402
# Uses the module-level `Firecrawl` name (lazy proxy at module top).
_firecrawl_client = Firecrawl(**kwargs)
_firecrawl_client_config = client_config
return _firecrawl_client

View File

@@ -29,6 +29,28 @@ def _install_sidecar_publisher() -> None:
)
# How long to wait for orderly shutdown (atexit + finalisers) before
# falling back to ``os._exit(0)`` so a wedged worker mid-flush can't
# strand the process. 1s covers the gateway's own shutdown work
# (thread-pool drain + session finalize) on every machine we've
# tested; override via ``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S`` if a
# slower environment needs more headroom (e.g. encrypted disks
# flushing checkpoints) and accept that a longer grace also means a
# longer wait when shutdown actually deadlocks.
_DEFAULT_SHUTDOWN_GRACE_S = 1.0
def _shutdown_grace_seconds() -> float:
raw = (os.environ.get("HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S") or "").strip()
if not raw:
return _DEFAULT_SHUTDOWN_GRACE_S
try:
value = float(raw)
except ValueError:
return _DEFAULT_SHUTDOWN_GRACE_S
return value if value > 0 else _DEFAULT_SHUTDOWN_GRACE_S
def _log_signal(signum: int, frame) -> None:
"""Capture WHICH thread and WHERE a termination signal hit us.
@@ -38,6 +60,15 @@ def _log_signal(signum: int, frame) -> None:
handler the gateway-exited banner in the TUI has no trace — the
crash log never sees a Python exception because the kernel reaps
the process before the interpreter runs anything.
Termination semantics: ``sys.exit(0)`` here used to race the worker
pool — a thread holding ``_stdout_lock`` mid-flush would block the
interpreter shutdown indefinitely. We now log the stack, give the
process the configured shutdown grace
(``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S``, default
``_DEFAULT_SHUTDOWN_GRACE_S``) to drain naturally on a background
thread, and fall back to ``os._exit(0)`` so a wedged write/flush
can never strand the process.
"""
name = {
signal.SIGPIPE: "SIGPIPE",
@@ -62,7 +93,31 @@ def _log_signal(signum: int, frame) -> None:
except Exception:
pass
print(f"[gateway-signal] {name}", file=sys.stderr, flush=True)
sys.exit(0)
import threading as _threading
def _hard_exit() -> None:
# If a worker thread is still mid-flush on a half-closed pipe,
# ``sys.exit(0)`` would wait forever for it to drop the GIL on
# interpreter shutdown. ``os._exit`` skips atexit handlers but
# breaks the deadlock. The crash log + stderr line above are
# the forensic trail.
os._exit(0)
timer = _threading.Timer(_shutdown_grace_seconds(), _hard_exit)
timer.daemon = True
timer.start()
try:
sys.exit(0)
except SystemExit:
# Re-raise so the main-thread interpreter unwinds and runs
# atexit + finalisers inside the grace window. Python signal
# handlers always run on the main thread, but a worker thread
# holding ``_stdout_lock`` mid-flush can keep that unwind
# waiting indefinitely; the daemon timer above is the safety
# net for that exact case.
raise
# SIGPIPE: ignore, don't exit. The old SIG_DFL killed the process

View File

@@ -491,6 +491,13 @@ def _normalize_completion_path(path_part: str) -> str:
# ── Config I/O ────────────────────────────────────────────────────────
# Keep aligned with `INDICATOR_STYLES` / `DEFAULT_INDICATOR_STYLE` in
# ``ui-tui/src/app/interfaces.ts`` — both ends validate against the
# same shape so `config.get indicator` and the live TUI render agree.
_INDICATOR_STYLES: tuple[str, ...] = ("ascii", "emoji", "kaomoji", "unicode")
_INDICATOR_DEFAULT = "kaomoji"
def _load_cfg() -> dict:
global _cfg_cache, _cfg_mtime, _cfg_path
try:
@@ -683,6 +690,21 @@ def _coerce_statusbar(raw) -> str:
return "top"
def _display_mouse_tracking(display: dict) -> bool:
"""Return canonical display.mouse_tracking with legacy tui_mouse fallback."""
if not isinstance(display, dict):
return True
if "mouse_tracking" in display:
raw = display.get("mouse_tracking")
else:
raw = display.get("tui_mouse", True)
if raw is False or raw == 0:
return False
if isinstance(raw, str):
return raw.strip().lower() not in {"0", "false", "no", "off"}
return True
def _load_reasoning_config() -> dict | None:
from hermes_constants import parse_reasoning_effort
@@ -1023,17 +1045,6 @@ def _session_info(agent) -> dict:
info["mcp_servers"] = get_mcp_status()
except Exception:
info["mcp_servers"] = []
try:
from hermes_cli.learning_ledger import build_learning_ledger
ledger = build_learning_ledger(_get_db(), limit=1)
info["learning"] = {
"counts": ledger.get("counts", {}),
"inventory": ledger.get("inventory", {}),
"total": ledger.get("total", 0),
}
except Exception:
pass
try:
from hermes_cli.banner import get_update_result
from hermes_cli.config import recommended_update_command
@@ -1156,16 +1167,6 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result
pass
if _tool_progress_enabled(sid) or payload.get("inline_diff"):
_emit("tool.complete", sid, payload)
try:
from hermes_cli.learning_ledger import learning_event_from_tool
event = learning_event_from_tool(name, args, result)
if event:
if session is not None:
session.setdefault("learning_events", []).append(event)
_emit("learning.event", sid, event)
except Exception:
pass
def _on_tool_progress(
@@ -2442,7 +2443,6 @@ def _(rid, params: dict) -> dict:
if session.get("running"):
return _err(rid, 4009, "session busy")
session["running"] = True
session["learning_events"] = []
history = list(session["history"])
history_version = int(session.get("history_version", 0))
images = list(session.get("attached_images", []))
@@ -2606,9 +2606,6 @@ def _(rid, params: dict) -> dict:
payload["reasoning"] = last_reasoning
if status_note:
payload["warning"] = status_note
learning_events = list(session.get("learning_events") or [])
if learning_events:
payload["learning_events"] = learning_events
rendered = render_message(raw, cols)
if rendered:
payload["rendered"] = rendered
@@ -3190,12 +3187,9 @@ def _(rid, params: dict) -> dict:
if key == "mouse":
raw = str(value or "").strip().lower()
display = (
_load_cfg().get("display")
if isinstance(_load_cfg().get("display"), dict)
else {}
)
current = bool(display.get("tui_mouse", True))
cfg = _load_cfg()
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
current = _display_mouse_tracking(display)
if raw in ("", "toggle"):
nv = not current
@@ -3206,9 +3200,22 @@ def _(rid, params: dict) -> dict:
else:
return _err(rid, 4002, f"unknown mouse value: {value}")
_write_config_key("display.tui_mouse", nv)
_write_config_key("display.mouse_tracking", nv)
return _ok(rid, {"key": key, "value": "on" if nv else "off"})
if key == "indicator":
# Use an explicit None check rather than `value or ""` so falsy
# non-string inputs (0, False, []) still surface as themselves
# in the error message instead of looking like a blank value.
raw = ("" if value is None else str(value)).strip().lower()
if raw not in _INDICATOR_STYLES:
return _err(
rid, 4002,
f"unknown indicator: {raw!r}; pick one of {'|'.join(_INDICATOR_STYLES)}",
)
_write_config_key("display.tui_status_indicator", raw)
return _ok(rid, {"key": key, "value": raw})
if key in ("prompt", "personality", "skin"):
try:
cfg = _load_cfg()
@@ -3279,6 +3286,18 @@ def _(rid, params: dict) -> dict:
return _ok(
rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")}
)
if key == "indicator":
# Normalize so a hand-edited config.yaml with stray casing or
# an unknown value reads back the SAME value the TUI actually
# rendered (frontend's `normalizeIndicatorStyle` falls back to
# `_INDICATOR_DEFAULT` for the same inputs). Otherwise
# `/indicator` would print one thing while the UI shows another.
raw = (_load_cfg().get("display") or {}).get("tui_status_indicator", "")
norm = str(raw).strip().lower()
return _ok(
rid,
{"value": norm if norm in _INDICATOR_STYLES else _INDICATOR_DEFAULT},
)
if key == "personality":
return _ok(
rid,
@@ -3354,7 +3373,7 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"value": _coerce_statusbar(raw)})
if key == "mouse":
display = _load_cfg().get("display")
on = display.get("tui_mouse", True) if isinstance(display, dict) else True
on = _display_mouse_tracking(display)
return _ok(rid, {"value": "on" if on else "off"})
if key == "mtime":
cfg_path = _hermes_home / "config.yaml"
@@ -4699,12 +4718,51 @@ def _(rid, params: dict) -> dict:
# ── Methods: browser / plugins / cron / skills ───────────────────────
def _resolve_browser_cdp_url() -> str:
"""Return the configured browser CDP override without network I/O.
``/browser status`` must be fast — calling
``tools.browser_tool._get_cdp_override`` would invoke
``_resolve_cdp_override``, which performs an HTTP probe to
``.../json/version`` for discovery-style URLs. That probe has
a multi-second timeout and would block the TUI on a slow or
unreachable host even though status only needs to report whether
an override is set.
Mirrors the env/config precedence of ``_get_cdp_override`` (env
var first, then ``browser.cdp_url`` from config.yaml) without the
websocket-resolution step, so the answer reflects user intent
even when the configured host is not currently reachable. The
actual WS normalization happens in ``browser_navigate`` on the
next tool call.
"""
env_url = os.environ.get("BROWSER_CDP_URL", "").strip()
if env_url:
return env_url
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
browser_cfg = cfg.get("browser", {}) if isinstance(cfg, dict) else {}
if isinstance(browser_cfg, dict):
return str(browser_cfg.get("cdp_url", "") or "").strip()
except Exception:
pass
return ""
@method("browser.manage")
def _(rid, params: dict) -> dict:
action = params.get("action", "status")
if action == "status":
url = os.environ.get("BROWSER_CDP_URL", "")
return _ok(rid, {"connected": bool(url), "url": url})
resolved_url = _resolve_browser_cdp_url()
return _ok(
rid,
{
"connected": bool(resolved_url),
"url": resolved_url,
},
)
if action == "connect":
url = params.get("url", "http://localhost:9222")
try:
@@ -4715,36 +4773,97 @@ def _(rid, params: dict) -> dict:
parsed = urlparse(url if "://" in url else f"http://{url}")
if parsed.scheme not in {"http", "https", "ws", "wss"}:
return _err(rid, 4015, f"unsupported browser url: {url}")
probe_root = f"{'https' if parsed.scheme == 'wss' else 'http' if parsed.scheme == 'ws' else parsed.scheme}://{parsed.netloc}"
probe_urls = [
f"{probe_root.rstrip('/')}/json/version",
f"{probe_root.rstrip('/')}/json",
]
ok = False
for probe in probe_urls:
try:
with urllib.request.urlopen(probe, timeout=2.0) as resp:
if 200 <= getattr(resp, "status", 200) < 300:
ok = True
break
except Exception:
continue
if not ok:
return _err(rid, 5031, f"could not reach browser CDP at {url}")
os.environ["BROWSER_CDP_URL"] = url
# A concrete ``ws[s]://.../devtools/browser/<id>`` endpoint is
# already directly connectable — those are the URLs Browserbase
# / browserless / hosted CDP providers return, and they
# generally DON'T serve the discovery-style ``/json/version``
# path. Probing it would just reject valid endpoints. Skip
# the HTTP probe and do a TCP-level reachability check instead;
# the actual CDP handshake happens on the next ``browser_navigate``.
is_concrete_ws = (
parsed.scheme in {"ws", "wss"}
and parsed.path.startswith("/devtools/browser/")
)
if is_concrete_ws:
import socket
host = parsed.hostname
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
if not host:
return _err(rid, 4015, f"missing host in browser url: {url}")
try:
with socket.create_connection((host, port), timeout=2.0):
pass
except OSError as e:
return _err(rid, 5031, f"could not reach browser CDP at {url}: {e}")
else:
probe_root = f"{'https' if parsed.scheme == 'wss' else 'http' if parsed.scheme == 'ws' else parsed.scheme}://{parsed.netloc}"
probe_urls = [
f"{probe_root.rstrip('/')}/json/version",
f"{probe_root.rstrip('/')}/json",
]
ok = False
for probe in probe_urls:
try:
with urllib.request.urlopen(probe, timeout=2.0) as resp:
if 200 <= getattr(resp, "status", 200) < 300:
ok = True
break
except Exception:
continue
if not ok:
return _err(rid, 5031, f"could not reach browser CDP at {url}")
# Persist a normalized URL for downstream CDP resolution.
# Discovery-style inputs (`http://host:port` or
# `http://host:port/json[/version]`) collapse to bare
# ``scheme://host:port`` so ``_resolve_cdp_override`` can
# safely append ``/json/version`` without producing a
# double-discovery path like ``.../json/json/version``.
# Concrete websocket endpoints (``/devtools/browser/<id>``
# — what Browserbase and other cloud providers return)
# are preserved verbatim.
if parsed.path.startswith("/devtools/browser/"):
normalized = parsed.geturl()
else:
normalized = parsed._replace(
path="",
params="",
query="",
fragment="",
).geturl()
# Order matters: clear any cached browser sessions BEFORE
# publishing the new env var so an in-flight tool call
# observing the old supervisor is reaped first, and the
# next call freshly resolves the new URL. The previous
# ordering left a brief window where ``_ensure_cdp_supervisor``
# could re-attach to the *old* supervisor.
cleanup_all_browsers()
os.environ["BROWSER_CDP_URL"] = normalized
# Drain any further cached state that could outlive the
# cleanup pass (CDP supervisor for the default task,
# cached agent-browser timeouts, etc.) so the next
# ``browser_navigate`` definitively reaches ``normalized``.
cleanup_all_browsers()
except Exception as e:
return _err(rid, 5031, str(e))
return _ok(rid, {"connected": True, "url": url})
return _ok(rid, {"connected": True, "url": normalized})
if action == "disconnect":
os.environ.pop("BROWSER_CDP_URL", None)
try:
from tools.browser_tool import cleanup_all_browsers
cleanup_all_browsers()
except Exception:
pass
os.environ.pop("BROWSER_CDP_URL", None)
try:
from tools.browser_tool import cleanup_all_browsers as _again
_again()
except Exception:
pass
return _ok(rid, {"connected": False})
return _err(rid, 4015, f"unknown action: {action}")
@@ -5090,22 +5209,6 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5024, str(e))
@method("learning.ledger")
def _(rid, params: dict) -> dict:
try:
from hermes_cli.learning_ledger import build_learning_ledger
return _ok(
rid,
build_learning_ledger(
_get_db(),
limit=int(params.get("limit", 80) or 80),
),
)
except Exception as e:
return _err(rid, 5025, str(e))
# ── Methods: shell ───────────────────────────────────────────────────

View File

@@ -23,10 +23,45 @@ the stream lazily through a callback.
from __future__ import annotations
import contextvars
import errno
import json
import logging
import os
import threading
from typing import Any, Callable, Optional, Protocol, runtime_checkable
# Errno values that mean "the peer is gone" rather than "the host has a
# real I/O problem". Anything outside this set re-raises so it surfaces
# in the crash log instead of looking like a clean disconnect.
_PEER_GONE_ERRNOS = frozenset({
errno.EPIPE, # write to closed pipe (POSIX)
errno.ECONNRESET, # peer reset the connection
errno.EBADF, # fd closed under us
errno.ESHUTDOWN, # transport endpoint shut down
getattr(errno, "WSAECONNRESET", -1), # win32 mapping (no-op on POSIX)
getattr(errno, "WSAESHUTDOWN", -1),
} - {-1})
logger = logging.getLogger(__name__)
# Optional knob: when true, StdioTransport does not call ``stream.flush``
# after writing. Use this on environments where a half-closed pipe (TUI
# Node parent quit while the gateway is still emitting events) makes
# flush block long enough to starve the rest of the worker pool.
#
# IMPORTANT: Python text stdout is fully buffered when attached to a
# pipe (the TUI case), so this knob ONLY makes sense when the gateway
# is launched with ``-u`` or ``PYTHONUNBUFFERED=1``. Without one of
# those, JSON-RPC frames will accumulate in the buffer and the TUI
# will hang waiting for ``gateway.ready``. Default stays off so the
# existing flush-after-write behaviour is unchanged.
_DISABLE_FLUSH = (os.environ.get("HERMES_TUI_GATEWAY_NO_FLUSH", "") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
@runtime_checkable
class Transport(Protocol):
@@ -77,15 +112,72 @@ class StdioTransport:
self._lock = lock
def write(self, obj: dict) -> bool:
"""Return ``True`` on success, ``False`` ONLY when the peer is gone.
Returning ``False`` is the dispatcher's "broken stdout pipe" signal
— ``entry.py`` calls ``sys.exit(0)`` when ``write_json`` reports
``False``. So programming errors (non-JSON-safe payloads, encoding
misconfig, unexpected ValueErrors, host I/O bugs like ENOSPC) MUST
NOT return ``False``, otherwise a real bug looks like a clean
disconnect and is harder to diagnose. Those re-raise so the
existing crash-log infrastructure records the traceback.
Peer-gone branches:
* ``BrokenPipeError``
* ``ValueError("...closed file...")``
* ``OSError`` whose errno is in :data:`_PEER_GONE_ERRNOS`
(EPIPE / ECONNRESET / EBADF / ESHUTDOWN; plus WSA mappings
on Windows). Other OSError errnos (ENOSPC, EACCES, ...) are
real host problems and re-raise.
"""
# Serialization is OUTSIDE the lock so a large payload can't
# block other threads emitting their own frames. A non-JSON-safe
# payload is a programming error: re-raise so the crash log
# captures it instead of silently exiting via the False path.
line = json.dumps(obj, ensure_ascii=False) + "\n"
try:
with self._lock:
stream = self._stream_getter()
with self._lock:
stream = self._stream_getter()
try:
stream.write(line)
stream.flush()
return True
except BrokenPipeError:
return False
except BrokenPipeError:
return False
except ValueError as e:
# ValueError("I/O operation on closed file") is the
# ONLY ValueError that means "peer gone". Anything
# else — including UnicodeEncodeError, which is a
# ValueError subclass for misconfigured locales —
# is a real bug; re-raise so it surfaces in the crash log.
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
raise
return False
except OSError as e:
if e.errno not in _PEER_GONE_ERRNOS:
raise
logger.debug("StdioTransport write peer gone: %s", e)
return False
# A flush that *raises* with a peer-gone errno means the
# dispatcher should exit cleanly. A flush that *hangs* on
# a half-closed pipe holds the lock until it returns — see
# ``_DISABLE_FLUSH`` for the "skip flush entirely" escape
# hatch.
if not _DISABLE_FLUSH:
try:
stream.flush()
except BrokenPipeError:
return False
except ValueError as e:
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
raise
return False
except OSError as e:
if e.errno not in _PEER_GONE_ERRNOS:
raise
logger.debug("StdioTransport flush peer gone: %s", e)
return False
return True
def close(self) -> None:
return None

View File

@@ -30,7 +30,7 @@ export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
export { default as measureElement } from './src/ink/measure-element.ts'
export { createRoot, default as render, forceRedraw, renderSync } from './src/ink/root.ts'
export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts'
export type { Instance, RenderOptions, Root } from './src/ink/root.ts'
export { stringWidth } from './src/ink/stringWidth.ts'
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'

View File

@@ -23,7 +23,7 @@ export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
export { default as measureElement } from './ink/measure-element.js'
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
export { createRoot, default as render, forceRedraw, renderSync } from './ink/root.js'
export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js'
export { stringWidth } from './ink/stringWidth.js'
export { isXtermJs } from './ink/terminal.js'
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'

View File

@@ -1,4 +1,4 @@
import React, { PureComponent, type ReactNode } from 'react'
import { PureComponent, type ReactNode } from 'react'
import { updateLastInteractionTime } from '../../bootstrap/state.js'
import { logForDebugging } from '../../utils/debug.js'
@@ -316,8 +316,10 @@ export default class App extends PureComponent<Props, State> {
// Clear the timer reference
this.incompleteEscapeTimer = null
// Only proceed if we have incomplete sequences
if (!this.keyParseState.incomplete) {
// Only proceed if we have an incomplete escape sequence or an unterminated
// bracketed paste. Missing paste-end markers otherwise leave every later
// keystroke trapped in the paste buffer.
if (!this.keyParseState.incomplete && this.keyParseState.mode !== 'IN_PASTE') {
return
}
@@ -330,13 +332,16 @@ export default class App extends PureComponent<Props, State> {
// drain stdin next and clear this timer. Prevents both the spurious
// Escape key and the lost scroll event.
if (this.props.stdin.readableLength > 0) {
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT)
this.incompleteEscapeTimer = setTimeout(
this.flushIncomplete,
this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT
)
return
}
// Process incomplete as a flush operation (input=null)
// This reuses all existing parsing logic
// Process incomplete/paste state as a flush operation (input=null).
// This reuses all existing parsing logic.
this.processInput(null)
}
@@ -355,8 +360,10 @@ export default class App extends PureComponent<Props, State> {
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined)
}
// If we have incomplete escape sequences, set a timer to flush them
if (this.keyParseState.incomplete) {
// If we have incomplete escape sequences or an unterminated paste, set a
// timer to flush/reset them. Paste starts are complete CSI sequences, so
// checking only `incomplete` would never arm the watchdog.
if (this.keyParseState.incomplete || this.keyParseState.mode === 'IN_PASTE') {
// Cancel any existing timer first
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer)

View File

@@ -39,6 +39,15 @@ describe('enhanced keyboard modifier parsing', () => {
expect(event.key.super).toBe(true)
})
it('preserves forwarded VS Code/Cursor Cmd+C copy sequence as ctrl+super+c', () => {
const parsed = parseOne('\u001b[99;13u')
const event = new InputEvent(parsed)
expect(parsed.name).toBe('c')
expect(event.key.ctrl).toBe(true)
expect(event.key.super).toBe(true)
})
it('preserves Cmd on word-delete and word-navigation sequences', () => {
const backspace = new InputEvent(parseOne('\u001b[127;9u'))
const left = new InputEvent(parseOne('\u001b[1;9D'))

View File

@@ -35,6 +35,8 @@ export function useSelection(): {
* replaces the old SGR-7 inverse so syntax highlighting stays readable
* under selection). Call once on mount + whenever theme changes. */
setSelectionBgColor: (color: string) => void
/** Monotonic counter incremented on every selection mutation. */
version: () => number
} {
// Look up the Ink instance via stdout — same pattern as instances map.
// StdinContext is available (it's always provided), and the Ink instance
@@ -58,7 +60,8 @@ export function useSelection(): {
shiftSelection: () => {},
moveFocus: () => {},
captureScrolledRows: () => {},
setSelectionBgColor: () => {}
setSelectionBgColor: () => {},
version: () => 0
}
}
@@ -73,7 +76,8 @@ export function useSelection(): {
shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow),
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
captureScrolledRows: (firstRow, lastRow, side) => ink.captureScrolledRows(firstRow, lastRow, side),
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color)
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color),
version: () => ink.getSelectionVersion()
}
}, [ink])
}

View File

@@ -63,6 +63,7 @@ import {
hasSelection,
moveFocus,
selectionBounds,
selectionSignature,
type SelectionState,
selectLineAt,
selectWordAt,
@@ -213,7 +214,8 @@ export default class Ink {
// Fired alongside the terminal repaint whenever the selection mutates
// so UI (e.g. footer hints) can react to selection appearing/clearing.
private readonly selectionListeners = new Set<() => void>()
private selectionWasActive = false
private selectionVersion = 0
private lastSelectionSignature = ''
// DOM nodes currently under the pointer (mode-1003 motion). Held here
// so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
// against this set and mutates it in place.
@@ -1661,9 +1663,16 @@ export default class Ink {
return hasSelection(this.selection)
}
getSelectionVersion(): number {
return this.selectionVersion
}
/**
* Subscribe to selection state changes. Fires whenever the selection
* is started, updated, cleared, or copied. Returns an unsubscribe fn.
* mutates — anchor/focus moves, drag updates, programmatic clears.
* Does NOT fire on `copySelectionNoClear()` (no mutation, no notify),
* which is why version-based subscribers don't risk re-entrant copies.
* Returns an unsubscribe fn.
*/
subscribeToSelectionChange(cb: () => void): () => void {
this.selectionListeners.add(cb)
@@ -1673,14 +1682,18 @@ export default class Ink {
private notifySelectionChange(): void {
this.scheduleRender()
const active = hasSelection(this.selection)
// Only bump version when the selection range actually mutated.
// Listeners still fire unconditionally — useHasSelection() snapshots
// through React, which dedupes via Object.is on the boolean value.
const sig = selectionSignature(this.selection)
if (active !== this.selectionWasActive) {
this.selectionWasActive = active
if (sig !== this.lastSelectionSignature) {
this.lastSelectionSignature = sig
this.selectionVersion += 1
}
for (const cb of this.selectionListeners) {
cb()
}
for (const cb of this.selectionListeners) {
cb()
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { INITIAL_STATE, parseMultipleKeypresses } from './parse-keypress.js'
import { PASTE_END, PASTE_START } from './termio/csi.js'
describe('parseMultipleKeypresses bracketed paste recovery', () => {
it('emits empty bracketed pastes when the terminal sends both markers', () => {
const [keys, state] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START + PASTE_END)
expect(keys).toHaveLength(1)
expect(keys[0]).toMatchObject({ isPasted: true, raw: '' })
expect(state.mode).toBe('NORMAL')
})
it('flushes unterminated paste content back to normal input mode', () => {
const [pendingKeys, pendingState] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START + 'hello')
expect(pendingKeys).toEqual([])
expect(pendingState.mode).toBe('IN_PASTE')
const [keys, state] = parseMultipleKeypresses(pendingState, null)
expect(keys).toHaveLength(1)
expect(keys[0]).toMatchObject({ isPasted: true, raw: 'hello' })
expect(state.mode).toBe('NORMAL')
expect(state.pasteBuffer).toBe('')
})
it('resets an empty unterminated paste start instead of staying stuck', () => {
const [pendingKeys, pendingState] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START)
expect(pendingKeys).toEqual([])
expect(pendingState.mode).toBe('IN_PASTE')
const [keys, state] = parseMultipleKeypresses(pendingState, null)
expect(keys).toEqual([])
expect(state.mode).toBe('NORMAL')
expect(state.pasteBuffer).toBe('')
})
})

View File

@@ -288,9 +288,14 @@ export function parseMultipleKeypresses(
}
}
// If flushing and still in paste mode, emit what we have
if (isFlush && inPaste && pasteBuffer) {
keys.push(createPasteKey(pasteBuffer))
// If a terminal drops the paste-end marker, the App watchdog flushes the
// partial paste and returns to normal input instead of swallowing all future
// keystrokes as paste content.
if (isFlush && inPaste) {
if (pasteBuffer) {
keys.push(createPasteKey(pasteBuffer))
}
inPaste = false
pasteBuffer = ''
}

View File

@@ -75,11 +75,13 @@ export type Root = {
export const forceRedraw = (stdout: NodeJS.WriteStream = process.stdout): boolean => {
const instance = instances.get(stdout)
if (!instance) {
return false
}
instance.forceRedraw()
return true
}

View File

@@ -799,6 +799,20 @@ export function hasSelection(s: SelectionState): boolean {
return s.anchor !== null && s.focus !== null
}
/**
* Stable fingerprint of the user-visible selection state. Used by Ink
* to skip incrementing the mutation counter when notifySelectionChange()
* fires without an actual change to anchor/focus/isDragging — protects
* version-based subscribers (copy-on-select) from re-running for the
* same stable selection.
*/
export function selectionSignature(s: SelectionState): string {
const a = s.anchor ? `${s.anchor.row},${s.anchor.col}` : 'null'
const f = s.focus ? `${s.focus.row},${s.focus.col}` : 'null'
return `${a}|${f}|${s.isDragging ? 1 : 0}`
}
/**
* Normalized selection bounds: start is always before end in reading order.
* Returns null if no active selection.

View File

@@ -314,6 +314,48 @@ describe('createGatewayEventHandler', () => {
expect(messages.some(m => m.includes('FileNotFoundError'))).toBe(true)
})
it('prefers raw text over Rich-rendered ANSI on message.complete (#16391)', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const raw = 'Hermes here.\n\nLine two.'
// Rich-rendered ANSI (`final_response_markdown: render`) used to win,
// which left visible escape codes in Ink output. Raw text must win.
const rendered = '\u001b[33mHermes here.\u001b[0m\n\n\u001b[2mLine two.\u001b[0m'
onEvent({ payload: { rendered, text: raw }, type: 'message.complete' } as any)
const assistant = appended.find(msg => msg.role === 'assistant')
expect(assistant?.text).toBe(raw)
expect(assistant?.text).not.toContain('\u001b[')
})
it('falls back to payload.rendered when text is missing on message.complete', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const rendered = 'fallback when gateway omitted text'
onEvent({ payload: { rendered }, type: 'message.complete' } as any)
const assistant = appended.find(msg => msg.role === 'assistant')
expect(assistant?.text).toBe(rendered)
})
it('always accumulates raw text in message.delta and ignores `rendered` (#16391)', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Stream of partial text deltas; each delta carries an incremental
// Rich-ANSI fragment. Pre-fix code would replace the whole bufRef
// with the latest fragment, dropping prior text.
onEvent({ payload: { rendered: '\u001b[33mFi\u001b[0m', text: 'Fi' }, type: 'message.delta' } as any)
onEvent({ payload: { rendered: '\u001b[33mrst.\u001b[0m', text: 'rst.' }, type: 'message.delta' } as any)
onEvent({ payload: { text: ' second.' }, type: 'message.delta' } as any)
onEvent({ payload: {}, type: 'message.complete' } as any)
const assistant = appended.find(msg => msg.role === 'assistant')
expect(assistant?.text).toBe('First. second.')
})
it('anchors inline_diff as its own segment where the edit happened', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
@@ -672,9 +714,7 @@ describe('createGatewayEventHandler', () => {
} as any)
// Pre-interrupt todos should land in turn state.
expect(getTurnState().todos).toEqual([
{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }
])
expect(getTurnState().todos).toEqual([{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }])
turnController.interruptTurn({
appendMessage: (msg: Msg) => appended.push(msg),

View File

@@ -85,15 +85,6 @@ describe('createSlashHandler', () => {
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('opens the learning ledger locally', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/learned')).toBe(true)
expect(getOverlayState().learningLedger).toBe(true)
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('routes /skills install <name> to skills.manage without opening overlay', () => {
const ctx = buildCtx()
@@ -204,7 +195,8 @@ describe('createSlashHandler', () => {
['/reload-mcp', 'reload.mcp', { session_id: null }],
['/stop', 'process.stop', {}],
['/fast status', 'config.get', { key: 'fast', session_id: null }],
['/busy status', 'config.get', { key: 'busy' }]
['/busy status', 'config.get', { key: 'busy' }],
['/indicator', 'config.get', { key: 'indicator' }]
])('routes %s through native RPC (no slash worker)', (command, method, params) => {
const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
@@ -224,6 +216,24 @@ describe('createSlashHandler', () => {
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('hot-swaps the live indicator when /indicator <style> succeeds', async () => {
const rpc = vi.fn(() => Promise.resolve({ value: 'emoji' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/indicator emoji')).toBe(true)
expect(rpc).toHaveBeenCalledWith('config.set', { key: 'indicator', value: 'emoji' })
await vi.waitFor(() => expect(getUiState().indicatorStyle).toBe('emoji'))
})
it('rejects unknown indicator styles before hitting the gateway', () => {
const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/indicator sparkle')).toBe(true)
expect(rpc).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /indicator [ascii|emoji|kaomoji|unicode]')
})
it('drops stale slash.exec output after a newer slash', async () => {
let resolveLate: (v: { output?: string }) => void
let slashExecCalls = 0

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
const ENV_KEYS = ['COLORTERM', 'FORCE_COLOR', 'HERMES_TUI_TRUECOLOR', 'NO_COLOR'] as const
async function withCleanEnv(setup: () => void, body: () => Promise<void>) {
const saved: Record<string, string | undefined> = {}
for (const k of ENV_KEYS) {
saved[k] = process.env[k]
delete process.env[k]
}
try {
setup()
await body()
} finally {
for (const k of ENV_KEYS) {
if (saved[k] === undefined) {
delete process.env[k]
} else {
process.env[k] = saved[k]
}
}
}
}
describe('forceTruecolor', () => {
it('sets COLORTERM=truecolor and FORCE_COLOR=3 when unset', async () => {
await withCleanEnv(
() => {},
async () => {
await import('../lib/forceTruecolor.js?t=' + Date.now())
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
it('respects HERMES_TUI_TRUECOLOR=0 opt-out', async () => {
await withCleanEnv(
() => {
process.env.HERMES_TUI_TRUECOLOR = '0'
},
async () => {
await import('../lib/forceTruecolor.js?t=optout-' + Date.now())
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('respects NO_COLOR', async () => {
await withCleanEnv(
() => {
process.env.NO_COLOR = '1'
},
async () => {
await import('../lib/forceTruecolor.js?t=no-color-' + Date.now())
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
})

View File

@@ -51,6 +51,12 @@ describe('isCopyShortcut', () => {
expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', {})).toBe(false)
})
it('accepts the VS Code/Cursor forwarded Cmd+C copy sequence on macOS', async () => {
const { isCopyShortcut } = await importPlatform('darwin')
expect(isCopyShortcut({ ctrl: true, meta: false, super: true }, 'c', {})).toBe(true)
})
})
describe('isVoiceToggleKey', () => {

View File

@@ -28,6 +28,12 @@ describe('terminalParityHints', () => {
it('suppresses IDE setup hint when keybindings are already configured', async () => {
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus && terminalTextSelected',
args: { text: '\u001b[99;13u' }
},
{
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',

View File

@@ -79,11 +79,34 @@ describe('configureTerminalKeybindings', () => {
expect(writeFile).toHaveBeenCalledTimes(1)
expect(copyFile).not.toHaveBeenCalled() // no existing file to back up
const written = writeFile.mock.calls[0]?.[1] as string
expect(written).toContain('cmd+c')
expect(written).toContain('terminalTextSelected')
expect(written).toContain('\\u001b[99;13u')
expect(written).toContain('shift+enter')
expect(written).toContain('cmd+enter')
expect(written).toContain('cmd+z')
})
it('only adds the Cmd+C forwarding binding on macOS', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/home/me',
platform: 'linux'
})
expect(result.success).toBe(true)
const written = writeFile.mock.calls[0]?.[1] as string
expect(written).not.toContain('cmd+c')
expect(written).not.toContain('terminalTextSelected')
expect(written).not.toContain('\\u001b[99;13u')
expect(written).toContain('shift+enter')
})
it('reports conflicts without overwriting existing bindings', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
@@ -113,6 +136,126 @@ describe('configureTerminalKeybindings', () => {
expect(copyFile).not.toHaveBeenCalled() // no backup when not writing
})
it('flags a global (when-less) binding on the same key as a conflict', async () => {
// A user's keybindings.json `cmd+c` with no `when` clause is global —
// it overlaps any context, including our terminal scope. We must NOT
// silently add a terminal-scoped cmd+c that would shadow it.
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'myExtension.smartCopy'
}
])
)
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(false)
expect(result.message).toContain('cmd+c')
expect(writeFile).not.toHaveBeenCalled()
})
it('flags an overlapping terminal-context binding as a conflict', async () => {
// Existing `cmd+c` scoped to plain `terminalFocus` overlaps with our
// `terminalFocus && terminalTextSelected` — both fire when the
// terminal is focused with text selected, so the existing binding
// would shadow ours. Treat as a conflict even though the strings
// aren't identical.
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'workbench.action.terminal.copySelection',
when: 'terminalFocus'
}
])
)
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(false)
expect(result.message).toContain('cmd+c')
expect(writeFile).not.toHaveBeenCalled()
})
it('does not flag a negated terminalTextSelected binding as a conflict', async () => {
// A binding scoped to "terminal focused but no selected text" is
// logically disjoint from our copy-forwarding binding, which requires
// terminalTextSelected.
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus && !terminalTextSelected',
args: { text: '\u0003' }
}
])
)
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(true)
expect(writeFile).toHaveBeenCalledTimes(1)
})
it('does not flag a disjoint-when binding on the same key as a conflict', async () => {
// VS Code allows multiple bindings for the same key when their `when`
// clauses don't overlap. A user's pre-existing cmd+c binding scoped to
// editor focus should NOT block our terminal-scoped cmd+c binding.
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'editor.action.clipboardCopyAction',
when: 'editorFocus'
}
])
)
const writeFile = vi.fn().mockResolvedValue(undefined)
const copyFile = vi.fn().mockResolvedValue(undefined)
const result = await configureTerminalKeybindings('vscode', {
fileOps: { copyFile, mkdir, readFile, writeFile },
homeDir: '/Users/me',
platform: 'darwin'
})
expect(result.success).toBe(true)
expect(writeFile).toHaveBeenCalledTimes(1)
})
it('backs up existing keybindings.json only when writing changes', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined)
const readFile = vi.fn().mockResolvedValue(JSON.stringify([]))
@@ -186,6 +329,12 @@ describe('configureTerminalKeybindings', () => {
const readComplete = vi.fn().mockResolvedValue(
JSON.stringify([
{
key: 'cmd+c',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus && terminalTextSelected',
args: { text: '\u001b[99;13u' }
},
{
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',

View File

@@ -1,46 +1,92 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DARK_THEME, DEFAULT_THEME, detectLightMode, fromSkin, LIGHT_THEME } from '../theme.js'
// `theme.js` reads `process.env` at module-load to compute DEFAULT_THEME,
// and `fromSkin` closes over DEFAULT_THEME. A developer shell with
// HERMES_TUI_THEME=light (or HERMES_TUI_BACKGROUND set to something
// bright) would flip the base and turn these assertions into a local-
// only failure. We sterilize the relevant env vars + dynamically
// import the module fresh so EVERY symbol that closes over the env
// (DEFAULT_THEME, DARK_THEME, LIGHT_THEME, fromSkin) is loaded against
// a known-empty environment.
//
// `detectLightMode` takes env as an explicit arg, so it's safe to import
// statically — but we stay consistent and dynamic-import it too.
const RELEVANT_ENV = [
'HERMES_TUI_LIGHT',
'HERMES_TUI_THEME',
'HERMES_TUI_BACKGROUND',
'COLORFGBG',
'TERM_PROGRAM'
] as const
async function importThemeWithCleanEnv() {
for (const key of RELEVANT_ENV) {
vi.stubEnv(key, '')
}
vi.resetModules()
return import('../theme.js')
}
afterEach(() => {
vi.unstubAllEnvs()
vi.resetModules()
})
describe('DEFAULT_THEME', () => {
it('has brand defaults', () => {
it('has brand defaults', async () => {
const { DEFAULT_THEME } = await importThemeWithCleanEnv()
expect(DEFAULT_THEME.brand.name).toBe('Hermes Agent')
expect(DEFAULT_THEME.brand.prompt).toBe('')
expect(DEFAULT_THEME.brand.tool).toBe('┊')
})
it('has color palette', () => {
it('has color palette', async () => {
const { DEFAULT_THEME } = await importThemeWithCleanEnv()
expect(DEFAULT_THEME.color.primary).toBe('#FFD700')
expect(DEFAULT_THEME.color.error).toBe('#ef5350')
})
})
describe('LIGHT_THEME', () => {
it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', () => {
it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', async () => {
const { LIGHT_THEME } = await importThemeWithCleanEnv()
expect(LIGHT_THEME.color.primary).not.toBe('#FFD700')
expect(LIGHT_THEME.color.accent).not.toBe('#FFBF00')
expect(LIGHT_THEME.color.muted).not.toBe('#B8860B')
expect(LIGHT_THEME.color.statusWarn).not.toBe('#FFD700')
})
it('keeps the same shape as DARK_THEME', () => {
it('keeps the same shape as DARK_THEME', async () => {
const { DARK_THEME, LIGHT_THEME } = await importThemeWithCleanEnv()
expect(Object.keys(LIGHT_THEME.color).sort()).toEqual(Object.keys(DARK_THEME.color).sort())
expect(LIGHT_THEME.brand).toEqual(DARK_THEME.brand)
})
})
describe('DEFAULT_THEME aliasing', () => {
it('defaults to DARK_THEME when nothing signals light', () => {
expect(DEFAULT_THEME).toBe(DARK_THEME)
it('defaults to DARK_THEME when nothing signals light', async () => {
const { DEFAULT_THEME, DARK_THEME: DARK } = await importThemeWithCleanEnv()
expect(DEFAULT_THEME).toBe(DARK)
})
})
describe('detectLightMode', () => {
it('returns false on empty env', () => {
it('returns false on empty env', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({})).toBe(false)
})
it('honors HERMES_TUI_LIGHT on/off', () => {
it('honors HERMES_TUI_LIGHT on/off', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({ HERMES_TUI_LIGHT: '1' })).toBe(true)
expect(detectLightMode({ HERMES_TUI_LIGHT: 'true' })).toBe(true)
expect(detectLightMode({ HERMES_TUI_LIGHT: 'on' })).toBe(true)
@@ -48,7 +94,9 @@ describe('detectLightMode', () => {
expect(detectLightMode({ HERMES_TUI_LIGHT: 'off' })).toBe(false)
})
it('sniffs COLORFGBG bg slots 7 and 15 as light (#11300)', () => {
it('sniffs COLORFGBG bg slots 7 and 15 as light (#11300)', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({ COLORFGBG: '0;15' })).toBe(true)
expect(detectLightMode({ COLORFGBG: '0;default;15' })).toBe(true)
expect(detectLightMode({ COLORFGBG: '0;7' })).toBe(true)
@@ -56,43 +104,134 @@ describe('detectLightMode', () => {
expect(detectLightMode({ COLORFGBG: '7;default;0' })).toBe(false)
})
it('lets HERMES_TUI_LIGHT=0 override a light COLORFGBG', () => {
it('falls through on malformed COLORFGBG with empty/non-numeric trailing field', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
// `Number('')` is 0, so `'15;'` would have been read as bg=0
// (authoritative dark) and incorrectly blocked TERM_PROGRAM.
// The strict /^\d+$/ guard makes these fall through instead.
const allowList = new Set(['Apple_Terminal'])
expect(detectLightMode({ COLORFGBG: '15;', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
expect(detectLightMode({ COLORFGBG: 'default;default', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
// Without an allow-list match, fall-through still defaults to dark.
expect(detectLightMode({ COLORFGBG: '15;' })).toBe(false)
})
it('lets HERMES_TUI_LIGHT=0 override a light COLORFGBG', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({ COLORFGBG: '0;15', HERMES_TUI_LIGHT: '0' })).toBe(false)
})
it('honors HERMES_TUI_THEME=light/dark as a symmetric explicit override', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({ HERMES_TUI_THEME: 'light' })).toBe(true)
expect(detectLightMode({ HERMES_TUI_THEME: 'dark' })).toBe(false)
expect(detectLightMode({ COLORFGBG: '0;15', HERMES_TUI_THEME: 'dark' })).toBe(false)
expect(detectLightMode({ COLORFGBG: '15;0', HERMES_TUI_THEME: 'light' })).toBe(true)
})
it('uses HERMES_TUI_BACKGROUND luminance when COLORFGBG is missing', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#ffffff' })).toBe(true)
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#000000' })).toBe(false)
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#1e1e1e' })).toBe(false)
// Three-char hex normalises like CSS.
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fff' })).toBe(true)
// Garbage falls through to the default-dark path.
expect(detectLightMode({ HERMES_TUI_BACKGROUND: 'not-a-colour' })).toBe(false)
})
it('rejects partially-invalid hex instead of silently truncating', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
// `parseInt('fffgff'.slice(2,4), 16)` would return 15 — the strict
// regex must reject these inputs so they fall through to default-
// dark instead of producing a false-positive light reading.
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffgff' })).toBe(false)
expect(detectLightMode({ HERMES_TUI_BACKGROUND: 'ffggff' })).toBe(false)
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#xyz' })).toBe(false)
// Wrong length also rejected (no implicit padding/truncation).
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffff' })).toBe(false)
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffffff' })).toBe(false)
})
it('treats COLORFGBG as authoritative when present so it dominates the TERM_PROGRAM allow-list', async () => {
const { detectLightMode } = await importThemeWithCleanEnv()
// Inject a light-default allow-list so the precedence test is
// meaningful even though the production allow-list is empty.
const allowList = new Set(['Apple_Terminal'])
// Sanity: the allow-list alone WOULD turn this terminal light.
expect(detectLightMode({ TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
// Dark COLORFGBG must beat the allow-list.
expect(detectLightMode({ COLORFGBG: '15;0', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(false)
})
})
describe('fromSkin', () => {
it('overrides banner colors', () => {
// `fromSkin` closes over DEFAULT_THEME (which is env-derived), so we
// must dynamic-import it after sterilizing env — otherwise an ambient
// HERMES_TUI_THEME=light would flip the base palette and make these
// assertions order-dependent on the developer's shell.
it('overrides banner colors', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.primary).toBe('#FF0000')
})
it('preserves unset colors', () => {
it('preserves unset colors', async () => {
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.accent).toBe(DEFAULT_THEME.color.accent)
})
it('overrides branding', () => {
it('derives completion current background from resolved completion background', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
expect(theme.color.completionBg).toBe('#ffffff')
expect(theme.color.completionCurrentBg).toBe('#bfbfbf')
})
it('overrides branding', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { brand } = fromSkin({}, { agent_name: 'TestBot', prompt_symbol: '$' })
expect(brand.name).toBe('TestBot')
expect(brand.prompt).toBe('$')
})
it('normalizes skin prompt symbols to one trimmed line', () => {
it('normalizes skin prompt symbols to trimmed single-line text', async () => {
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
expect(fromSkin({}, { prompt_symbol: ' ⚔ \n' }).brand.prompt).toBe('⚔ ')
expect(fromSkin({}, { prompt_symbol: ' Ψ > \n' }).brand.prompt).toBe('Ψ >')
expect(fromSkin({}, { prompt_symbol: '\n\t' }).brand.prompt).toBe(DEFAULT_THEME.brand.prompt)
})
it('defaults for empty skin', () => {
it('defaults for empty skin', async () => {
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
expect(fromSkin({}, {}).color).toEqual(DEFAULT_THEME.color)
expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon)
})
it('passes banner logo/hero', () => {
it('passes banner logo/hero', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerLogo).toBe('LOGO')
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerHero).toBe('HERO')
})
it('maps ui_ color keys + cascades to status', () => {
it('maps ui_ color keys + cascades to status', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
const { color } = fromSkin({ ui_ok: '#008000' }, {})
expect(color.ok).toBe('#008000')
expect(color.statusGood).toBe('#008000')
})

View File

@@ -1,7 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $uiState, resetUiState } from '../app/uiStore.js'
import { applyDisplay, normalizeStatusBar } from '../app/useConfigSync.js'
import {
applyDisplay,
normalizeBusyInputMode,
normalizeIndicatorStyle,
normalizeMouseTracking,
normalizeStatusBar
} from '../app/useConfigSync.js'
describe('applyDisplay', () => {
beforeEach(() => {
@@ -65,6 +71,19 @@ describe('applyDisplay', () => {
expect(s.sections).toEqual({})
})
it('uses documented mouse_tracking with legacy tui_mouse fallback', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { mouse_tracking: false } } }, setBell)
expect($uiState.get().mouseTracking).toBe(false)
applyDisplay({ config: { display: { mouse_tracking: true, tui_mouse: false } } }, setBell)
expect($uiState.get().mouseTracking).toBe(true)
applyDisplay({ config: { display: { tui_mouse: false } } }, setBell)
expect($uiState.get().mouseTracking).toBe(false)
})
it('parses display.sections into per-section overrides', () => {
const setBell = vi.fn()
@@ -160,3 +179,116 @@ describe('normalizeStatusBar', () => {
expect(normalizeStatusBar('OFF')).toBe('off')
})
})
describe('normalizeMouseTracking', () => {
it('defaults on and prefers canonical mouse_tracking over legacy tui_mouse', () => {
expect(normalizeMouseTracking({})).toBe(true)
expect(normalizeMouseTracking({ mouse_tracking: false })).toBe(false)
expect(normalizeMouseTracking({ mouse_tracking: 0 })).toBe(false)
expect(normalizeMouseTracking({ mouse_tracking: 'off' })).toBe(false)
expect(normalizeMouseTracking({ mouse_tracking: 'false' })).toBe(false)
expect(normalizeMouseTracking({ mouse_tracking: null, tui_mouse: false })).toBe(true)
expect(normalizeMouseTracking({ mouse_tracking: true, tui_mouse: false })).toBe(true)
expect(normalizeMouseTracking({ tui_mouse: false })).toBe(false)
})
})
describe('normalizeBusyInputMode', () => {
it('passes through the canonical CLI parity values', () => {
expect(normalizeBusyInputMode('queue')).toBe('queue')
expect(normalizeBusyInputMode('steer')).toBe('steer')
expect(normalizeBusyInputMode('interrupt')).toBe('interrupt')
})
it('trims and lowercases input', () => {
expect(normalizeBusyInputMode(' Queue ')).toBe('queue')
expect(normalizeBusyInputMode('STEER')).toBe('steer')
})
it('defaults to queue for missing/unknown values (TUI-only override)', () => {
// CLI / messaging adapters keep `interrupt` as the framework default
// (see hermes_cli/config.py + tui_gateway/server.py::_load_busy_input_mode);
// the TUI ships `queue` because typing a follow-up while the agent
// streams is the common authoring pattern and an unintended interrupt
// loses work.
expect(normalizeBusyInputMode(undefined)).toBe('queue')
expect(normalizeBusyInputMode(null)).toBe('queue')
expect(normalizeBusyInputMode('')).toBe('queue')
expect(normalizeBusyInputMode('drop')).toBe('queue')
expect(normalizeBusyInputMode(42)).toBe('queue')
})
})
describe('normalizeIndicatorStyle', () => {
it('passes through the canonical enum', () => {
expect(normalizeIndicatorStyle('kaomoji')).toBe('kaomoji')
expect(normalizeIndicatorStyle('emoji')).toBe('emoji')
expect(normalizeIndicatorStyle('unicode')).toBe('unicode')
expect(normalizeIndicatorStyle('ascii')).toBe('ascii')
})
it('trims and lowercases input', () => {
expect(normalizeIndicatorStyle(' Emoji ')).toBe('emoji')
expect(normalizeIndicatorStyle('UNICODE')).toBe('unicode')
})
it('defaults to kaomoji for missing/unknown values', () => {
expect(normalizeIndicatorStyle(undefined)).toBe('kaomoji')
expect(normalizeIndicatorStyle(null)).toBe('kaomoji')
expect(normalizeIndicatorStyle('')).toBe('kaomoji')
expect(normalizeIndicatorStyle('sparkle')).toBe('kaomoji')
expect(normalizeIndicatorStyle(42)).toBe('kaomoji')
})
})
describe('applyDisplay → busy_input_mode', () => {
beforeEach(() => {
resetUiState()
})
it('threads display.busy_input_mode into $uiState', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { busy_input_mode: 'queue' } } }, setBell)
expect($uiState.get().busyInputMode).toBe('queue')
applyDisplay({ config: { display: { busy_input_mode: 'steer' } } }, setBell)
expect($uiState.get().busyInputMode).toBe('steer')
})
it('falls back to queue when value is missing or invalid (TUI-only default)', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: {} } }, setBell)
expect($uiState.get().busyInputMode).toBe('queue')
applyDisplay({ config: { display: { busy_input_mode: 'drop' } } }, setBell)
expect($uiState.get().busyInputMode).toBe('queue')
})
})
describe('applyDisplay → tui_status_indicator', () => {
beforeEach(() => {
resetUiState()
})
it('threads display.tui_status_indicator into $uiState', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { tui_status_indicator: 'emoji' } } }, setBell)
expect($uiState.get().indicatorStyle).toBe('emoji')
applyDisplay({ config: { display: { tui_status_indicator: 'unicode' } } }, setBell)
expect($uiState.get().indicatorStyle).toBe('unicode')
})
it('falls back to kaomoji default when missing or invalid', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: {} } }, setBell)
expect($uiState.get().indicatorStyle).toBe('kaomoji')
applyDisplay({ config: { display: { tui_status_indicator: 'rainbow' } } }, setBell)
expect($uiState.get().indicatorStyle).toBe('kaomoji')
})
})

View File

@@ -28,4 +28,31 @@ describe('stickyPromptFromViewport', () => {
expect(stickyPromptFromViewport(messages, offsets, 16, 20, false)).toBe('current prompt')
})
it('shows the last prompt once the viewport starts after the history tail', () => {
const messages = [
{ role: 'user' as const, text: 'current prompt' },
{ role: 'assistant' as const, text: 'completed answer' }
]
expect(stickyPromptFromViewport(messages, [0, 2, 5], 8, 14, false)).toBe('current prompt')
})
it('shows a prompt as soon as its full row is above the viewport', () => {
const messages = [
{ role: 'user' as const, text: 'current prompt' },
{ role: 'assistant' as const, text: 'current answer' }
]
expect(stickyPromptFromViewport(messages, [0, 2, 10], 2, 8, false)).toBe('current prompt')
})
it('hides the sticky prompt at the bottom', () => {
const messages = [
{ role: 'user' as const, text: 'current prompt' },
{ role: 'assistant' as const, text: 'current answer' }
]
expect(stickyPromptFromViewport(messages, [0, 2, 10], 8, 10, true)).toBe('')
})
})

View File

@@ -35,4 +35,20 @@ describe('viewportStore', () => {
})
expect(viewportSnapshotKey(snap)).toBe('0:16:5:40:3')
})
it('uses fresh scroll height to clear stale non-bottom state', () => {
const handle = {
getFreshScrollHeight: () => 20,
getPendingDelta: () => 0,
getScrollHeight: () => 40,
getScrollTop: () => 15,
getViewportHeight: () => 5,
isSticky: () => false
}
const snap = getViewportSnapshot(handle as any)
expect(snap.atBottom).toBe(true)
expect(snap.scrollHeight).toBe(20)
})
})

View File

@@ -64,7 +64,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
let pendingThinkingStatus = ''
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
let pendingLearning: string[] = []
// Inject the disk-save callback into turnController so recordMessageComplete
// can fire-and-forget a persist without having to plumb a gateway ref around.
@@ -270,19 +269,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
case 'learning.event': {
const title = String(ev.payload?.title ?? '').trim()
const verb = String(ev.payload?.verb ?? ev.payload?.type ?? 'learned').trim()
if (title) {
pendingLearning = pushUnique(4)(pendingLearning, `${verb}: ${title}`)
}
return
}
case 'message.start':
pendingLearning = []
turnController.startMessage()
return
@@ -386,6 +373,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// 120-char clip used for `gateway.stderr` activity entries.
const STDERR_LINE_CAP = 120
const STDERR_LINES_MAX = 8
const tailLines = (stderrTail ?? '')
.split('\n')
.map(l => l.trim())
@@ -603,22 +591,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
case 'message.complete': {
const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {})
const completedLearning = (ev.payload?.learning_events ?? [])
.map(e => {
const title = String(e?.title ?? '').trim()
const verb = String(e?.verb ?? e?.type ?? 'learned').trim()
return title ? `${verb}: ${title}` : ''
})
.filter(Boolean)
if (!wasInterrupted) {
const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]
const learningLines = [...completedLearning, ...pendingLearning].filter((text, i, xs) => xs.indexOf(text) === i)
msgs.forEach(appendMessage)
learningLines.forEach(text => appendMessage({ kind: 'learning', role: 'system', text }))
pendingLearning = []
if (bellOnComplete && stdout?.isTTY) {
stdout.write('\x07')

View File

@@ -27,11 +27,23 @@ export interface StateSetter<T> {
export type StatusBarMode = 'bottom' | 'off' | 'top'
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
// Single source of truth for indicator style names. Union type is
// derived from this tuple so adding/removing a style only touches one
// line — `useConfigSync` (validation) and `session.ts` (slash arg
// validation + usage hint) both import it.
export const INDICATOR_STYLES = ['ascii', 'emoji', 'kaomoji', 'unicode'] as const
export type IndicatorStyle = (typeof INDICATOR_STYLES)[number]
export const DEFAULT_INDICATOR_STYLE: IndicatorStyle = 'kaomoji'
export interface SelectionApi {
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
clearSelection: () => void
copySelection: () => Promise<string>
copySelectionNoClear: () => Promise<string>
getState: () => unknown
version: () => number
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
}
@@ -62,7 +74,6 @@ export interface OverlayState {
approval: ApprovalReq | null
clarify: ClarifyReq | null
confirm: ConfirmReq | null
learningLedger: boolean
modelPicker: boolean
pager: null | PagerState
picker: boolean
@@ -86,6 +97,7 @@ export interface TranscriptRow {
export interface UiState {
bgTasks: Set<string>
busy: boolean
busyInputMode: BusyInputMode
compact: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
@@ -95,6 +107,7 @@ export interface UiState {
sections: SectionVisibility
showCost: boolean
showReasoning: boolean
indicatorStyle: IndicatorStyle
sid: null | string
status: string
statusBar: StatusBarMode

View File

@@ -8,7 +8,6 @@ const buildOverlayState = (): OverlayState => ({
approval: null,
clarify: null,
confirm: null,
learningLedger: false,
modelPicker: false,
pager: null,
picker: false,
@@ -21,20 +20,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed(
$overlayState,
({ agents, approval, clarify, confirm, learningLedger, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(
agents ||
approval ||
clarify ||
confirm ||
learningLedger ||
modelPicker ||
pager ||
picker ||
secret ||
skillsHub ||
sudo
)
({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
)
export const getOverlayState = () => $overlayState.get()
@@ -58,7 +45,6 @@ export const resetFlowOverlays = () =>
...buildOverlayState(),
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
learningLedger: $overlayState.get().learningLedger,
modelPicker: $overlayState.get().modelPicker,
picker: $overlayState.get().picker,
skillsHub: $overlayState.get().skillsHub

View File

@@ -503,7 +503,7 @@ export const coreCommands: SlashCommand[] = [
ctx.guarded<SessionSteerResponse>(r => {
if (r?.status === 'queued') {
ctx.transcript.sys(
`steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
`steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
)
} else {
ctx.transcript.sys('steer rejected')

View File

@@ -98,13 +98,16 @@ export const opsCommands: SlashCommand[] = [
const action = (rawAction || 'status').toLowerCase()
if (!['connect', 'disconnect', 'status'].includes(action)) {
return ctx.transcript.sys('usage: /browser [connect|disconnect|status] [url]')
return ctx.transcript.sys(
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
)
}
const payload: Record<string, unknown> = { action }
const requested = rest.join(' ').trim()
if (action === 'connect') {
payload.url = rest.join(' ').trim() || 'http://localhost:9222'
payload.url = requested || 'http://localhost:9222'
}
ctx.gateway
@@ -113,14 +116,21 @@ export const opsCommands: SlashCommand[] = [
ctx.guarded<BrowserManageResponse>(r => {
if (action === 'status') {
return ctx.transcript.sys(
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser not connected'
r.connected
? `browser connected: ${r.url || '(url unavailable)'}`
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
)
}
if (action === 'connect') {
return ctx.transcript.sys(
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser connect failed'
)
if (r.connected) {
ctx.transcript.sys(`browser connected: ${r.url || '(url unavailable)'}`)
ctx.transcript.sys('next browser tool call will use this CDP endpoint')
return
}
return ctx.transcript.sys('browser connect failed')
}
ctx.transcript.sys('browser disconnected')
@@ -379,13 +389,6 @@ export const opsCommands: SlashCommand[] = [
}
},
{
aliases: ['growth', 'learned'],
help: 'show memories, skills, recalls, and integrations Hermes has accumulated',
name: 'learning',
run: () => patchOverlayState({ learningLedger: true })
},
{
help: 'browse, inspect, install skills',
name: 'skills',

View File

@@ -12,6 +12,7 @@ import type {
} from '../../../gatewayTypes.js'
import { fmtK } from '../../../lib/text.js'
import type { PanelSection } from '../../../types.js'
import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
@@ -268,6 +269,43 @@ export const sessionCommands: SlashCommand[] = [
}
},
{
help: 'pick the busy indicator: kaomoji (default), emoji, unicode (braille), or ascii',
name: 'indicator',
usage: `/indicator [${INDICATOR_STYLES.join('|')}]`,
run: (arg, ctx) => {
const value = arg.trim().toLowerCase()
if (!value) {
return ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'indicator' })
.then(
ctx.guarded<ConfigGetValueResponse>(r =>
ctx.transcript.sys(`indicator: ${r.value || DEFAULT_INDICATOR_STYLE}`)
)
)
}
if (!(INDICATOR_STYLES as readonly string[]).includes(value)) {
return ctx.transcript.sys(`usage: /indicator [${INDICATOR_STYLES.join('|')}]`)
}
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'indicator', value }).then(
ctx.guarded<ConfigSetResponse>(r => {
if (!r.value) {
return
}
// Hot-swap the running TUI immediately so the next render
// uses the new style without waiting for the 5s mtime poll
// to re-apply config.full.
patchUiState({ indicatorStyle: value as IndicatorStyle })
ctx.transcript.sys(`indicator → ${r.value}`)
})
)
}
},
{
help: 'toggle yolo mode (per-session approvals)',
name: 'yolo',

View File

@@ -431,7 +431,13 @@ class TurnController {
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
this.closeReasoningSegment()
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
// Ink renders markdown via <Md>; the gateway's Rich-rendered ANSI
// (`payload.rendered`) is for terminals that can't. Prioritising
// `rendered` here garbles output whenever a user opts into
// `display.final_response_markdown: render` because raw ANSI escapes
// pass through into the React tree. Prefer raw text and fall back
// only when the gateway elected not to send any (#16391).
const rawText = (payload.text ?? payload.rendered ?? this.bufRef).trimStart()
const split = splitReasoning(rawText)
const finalText = finalTail(split.text, this.segmentMessages)
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
@@ -516,7 +522,7 @@ class TurnController {
return { finalMessages, finalText, wasInterrupted }
}
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
recordMessageDelta({ text }: { rendered?: string; text?: string }) {
if (this.interrupted || !text) {
return
}
@@ -524,7 +530,12 @@ class TurnController {
this.pruneTransient()
this.endReasoningPhase()
this.bufRef = rendered ?? this.bufRef + text
// Always accumulate the raw text delta. The pre-#16391 path replaced
// the entire buffer with `rendered` (an *incremental* Rich ANSI
// fragment), which on every tick discarded everything streamed so far
// — visible as overlapping coloured text and lost prose under
// `display.final_response_markdown: render`.
this.bufRef += text
if (getUiState().streaming) {
this.scheduleStreaming()

View File

@@ -4,14 +4,16 @@ import { MOUSE_TRACKING } from '../config/env.js'
import { ZERO } from '../domain/usage.js'
import { DEFAULT_THEME } from '../theme.js'
import type { UiState } from './interfaces.js'
import { DEFAULT_INDICATOR_STYLE, type UiState } from './interfaces.js'
const buildUiState = (): UiState => ({
bgTasks: new Set(),
busy: false,
busyInputMode: 'queue',
compact: false,
detailsMode: 'collapsed',
detailsModeCommandOverride: false,
indicatorStyle: DEFAULT_INDICATOR_STYLE,
info: null,
inlineDiffs: true,
mouseTracking: MOUSE_TRACKING,

View File

@@ -10,7 +10,13 @@ import type {
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import type { StatusBarMode } from './interfaces.js'
import {
type BusyInputMode,
DEFAULT_INDICATOR_STYLE,
INDICATOR_STYLES,
type IndicatorStyle,
type StatusBarMode
} from './interfaces.js'
import { turnController } from './turnController.js'
import { patchUiState } from './uiStore.js'
@@ -24,6 +30,52 @@ const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top'
const BUSY_MODES = new Set<BusyInputMode>(['interrupt', 'queue', 'steer'])
// TUI defaults to `queue` even though the framework default
// (`hermes_cli/config.py`) is `interrupt`. Rationale: in a full-screen
// TUI you're typically authoring the next prompt while the agent is
// still streaming, and an unintended interrupt loses work. Set
// `display.busy_input_mode: interrupt` (or `steer`) explicitly to
// opt out per-config; CLI / messaging adapters keep their `interrupt`
// default unchanged.
const TUI_BUSY_DEFAULT: BusyInputMode = 'queue'
export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => {
if (typeof raw !== 'string') {
return TUI_BUSY_DEFAULT
}
const v = raw.trim().toLowerCase() as BusyInputMode
return BUSY_MODES.has(v) ? v : TUI_BUSY_DEFAULT
}
const INDICATOR_STYLE_SET: ReadonlySet<IndicatorStyle> = new Set(INDICATOR_STYLES)
export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => {
if (typeof raw !== 'string') {
return DEFAULT_INDICATOR_STYLE
}
const v = raw.trim().toLowerCase() as IndicatorStyle
return INDICATOR_STYLE_SET.has(v) ? v : DEFAULT_INDICATOR_STYLE
}
const FALSEY_MOUSE = new Set(['0', 'false', 'no', 'off'])
const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key)
export const normalizeMouseTracking = (display: { mouse_tracking?: unknown; tui_mouse?: unknown }): boolean => {
const raw = hasOwn(display, 'mouse_tracking') ? display.mouse_tracking : display.tui_mouse
if (raw === false || raw === 0) {
return false
}
return typeof raw === 'string' ? !FALSEY_MOUSE.has(raw.trim().toLowerCase()) : true
}
const MTIME_POLL_MS = 5000
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
@@ -43,11 +95,13 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
setBell(!!d.bell_on_complete)
patchUiState({
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
compact: !!d.tui_compact,
detailsMode: resolveDetailsMode(d),
detailsModeCommandOverride: false,
indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator),
inlineDiffs: d.inline_diffs !== false,
mouseTracking: d.tui_mouse !== false,
mouseTracking: normalizeMouseTracking(d),
sections: resolveSections(d.sections),
showCost: !!d.show_cost,
showReasoning: !!d.show_reasoning,

View File

@@ -92,10 +92,6 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return patchOverlayState({ skillsHub: false })
}
if (overlay.learningLedger) {
return patchOverlayState({ learningLedger: false })
}
if (overlay.picker) {
return patchOverlayState({ picker: false })
}
@@ -370,6 +366,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) {
cActions.removeQueue(cState.queueEditIdx)
return cActions.clearIn()
}
@@ -397,6 +394,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (isAction(key, ch, 'l')) {
clearSelection()
forceRedraw(terminal.stdout ?? process.stdout)
return
}

View File

@@ -17,6 +17,7 @@ import type {
import { useGitBranch } from '../hooks/useGitBranch.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { appendTranscriptMessage } from '../lib/messages.js'
import { isMac } from '../lib/platform.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js'
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
@@ -143,11 +144,47 @@ export function useMainApp(gw: GatewayClient) {
const hasSelection = useHasSelection()
const selection = useSelection()
const lastCopiedVersionRef = useRef(-1)
useEffect(() => {
selection.setSelectionBgColor(ui.theme.color.selectionBg)
}, [selection, ui.theme.color.selectionBg])
// macOS Terminal.app does not forward Cmd+C to fullscreen TUIs that enable
// mouse tracking, so the only reliable native-feeling path is iTerm-style
// copy-on-select: once a drag creates a stable TUI selection, write it to
// the system clipboard while keeping the highlight visible.
//
// Subscribe directly via the ink selection bus (not useSyncExternalStore)
// so React doesn't re-render MainApp on every drag-move tick. The version
// ref de-dupes against re-entrant notifications.
useEffect(() => {
if (!isMac) {
return
}
return selection.subscribe(() => {
if (!selection.hasSelection()) {
return
}
const state = selection.getState() as { isDragging?: boolean } | null
if (state?.isDragging) {
return
}
const version = selection.version()
if (version === lastCopiedVersionRef.current) {
return
}
lastCopiedVersionRef.current = version
void selection.copySelectionNoClear()
})
}, [selection])
const clearSelection = useCallback(() => {
selection.clearSelection()
getInputSelection()?.collapseToEnd()

View File

@@ -4,7 +4,12 @@ import { TYPING_IDLE_MS } from '../config/timing.js'
import { attachedImageNotice } from '../domain/messages.js'
import { looksLikeSlashCommand } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js'
import type {
InputDetectDropResponse,
PromptSubmitResponse,
SessionSteerResponse,
ShellExecResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js'
import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
@@ -207,6 +212,72 @@ export function useSubmission(opts: UseSubmissionOptions) {
[interpolate, send, shellExec]
)
// Honors `display.busy_input_mode` from config.yaml (CLI parity):
// - 'queue' (legacy): append to queueRef; drains on busy → false
// - 'steer' : inject into the current turn via session.steer; falls
// back to queue when steer is rejected (no agent / no
// tool window).
// - 'interrupt' (default): cancel the in-flight turn, then send the
// new text as a fresh prompt so it actually moves.
//
// `opts.fallbackToFront` controls whether a steer fallback re-inserts
// at the front of the queue (used by the queue-edit path to preserve
// a picked item's position); the mainline submit path always appends.
const handleBusyInput = useCallback(
(full: string, opts: { fallbackToFront?: boolean } = {}) => {
const live = getUiState()
const mode = live.busyInputMode
const fallback = (note: string) => {
if (opts.fallbackToFront) {
composerRefs.queueRef.current.unshift(full)
composerActions.syncQueue()
} else {
composerActions.enqueue(full)
}
sys(note)
}
if (mode === 'queue') {
return composerActions.enqueue(full)
}
if (mode === 'steer' && live.sid) {
gw.request<SessionSteerResponse>('session.steer', { session_id: live.sid, text: full })
.then(raw => {
const r = asRpcResult<SessionSteerResponse>(raw)
if (r?.status !== 'queued') {
fallback('steer rejected — message queued for next turn')
}
})
.catch(() => fallback('steer failed — message queued for next turn'))
return
}
// 'interrupt' (default): tear down the current turn, then send.
// `interruptTurn` fires `session.interrupt` without awaiting; if
// the gateway is still mid-response when `prompt.submit` lands,
// `send()`'s catch path re-queues with a "queued: ..." sys note
// (`isSessionBusyError`) — so a lost race degrades to queue
// semantics, not a dropped message.
if (live.sid) {
turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
}
if (hasInterpolation(full)) {
patchUiState({ busy: true })
return interpolate(full, send)
}
send(full)
},
[appendMessage, composerActions, composerRefs, gw, interpolate, send, sys]
)
const dispatchSubmission = useCallback(
(full: string) => {
if (!full.trim()) {
@@ -252,9 +323,16 @@ export function useSubmission(opts: UseSubmissionOptions) {
}
if (getUiState().busy) {
composerRefs.queueRef.current.unshift(picked)
// 'interrupt' / 'steer' should reach the live turn instead of
// silently going back to the queue. handleBusyInput resolves
// mode-specific behavior (interrupt-and-send, steer, or queue).
if (getUiState().busyInputMode === 'queue') {
composerRefs.queueRef.current.unshift(picked)
return composerActions.syncQueue()
return composerActions.syncQueue()
}
return handleBusyInput(picked, { fallbackToFront: true })
}
return sendQueued(picked)
@@ -263,7 +341,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
composerActions.pushHistory(full)
if (getUiState().busy) {
return composerActions.enqueue(full)
return handleBusyInput(full)
}
if (hasInterpolation(full)) {
@@ -274,7 +352,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
send(full)
},
[appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef]
[appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef]
)
const submit = useCallback(

View File

@@ -671,9 +671,7 @@ function DiffView({
<Text color={t.color.text}>
{diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)}
</Text>
<Text color={t.color.text}>
{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}
</Text>
<Text color={t.color.text}>{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}</Text>
<Text color={t.color.text}>{diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)}</Text>
</Box>
</Box>

View File

@@ -1,9 +1,12 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type RefObject, useEffect, useMemo, useState } from 'react'
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
import unicodeSpinners from 'unicode-animations'
import { $delegationState } from '../app/delegationStore.js'
import type { IndicatorStyle } from '../app/interfaces.js'
import { useTurnSelector } from '../app/turnStore.js'
import { $uiState } from '../app/uiStore.js'
import { FACES } from '../content/faces.js'
import { VERBS } from '../content/verbs.js'
import { fmtDuration } from '../domain/messages.js'
@@ -15,23 +18,98 @@ import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
const FACE_TICK_MS = 2500
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
// Compact alternates for the `emoji` and `ascii` indicator styles.
// Each entry is a fixed-width (display-width) glyph.
const EMOJI_FRAMES = ['⚕ ', '🌀', '🤔', '✨', '🍵', '🔮']
const ASCII_FRAMES = ['|', '/', '-', '\\']
// Faster tick for spinner-style indicators — they read as motion only
// at frame rates closer to their authored interval.
const SPINNER_TICK_MS = 100
interface IndicatorRender {
frame: string
intervalMs: number
// When false, FaceTicker hides the rotating verb and just shows the
// glyph + duration. Lets `unicode` stay minimal while the other
// styles keep the verb-rotation flavour users associate with the
// running… status.
showVerb: boolean
}
const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender => {
if (style === 'kaomoji') {
return { frame: FACES[tick % FACES.length] ?? '', intervalMs: FACE_TICK_MS, showVerb: true }
}
if (style === 'emoji') {
return {
frame: EMOJI_FRAMES[tick % EMOJI_FRAMES.length] ?? '⚕ ',
intervalMs: SPINNER_TICK_MS * 6,
showVerb: true
}
}
if (style === 'ascii') {
return {
frame: ASCII_FRAMES[tick % ASCII_FRAMES.length] ?? '|',
intervalMs: SPINNER_TICK_MS,
showVerb: true
}
}
// 'unicode' — braille spinner (fixed 1-col). Authored interval is
// ~80ms; honour it but bound below at a safe minimum so React
// re-renders stay reasonable. This style is for users who want
// the cleanest possible status, so no verb rotation either.
const spinner = unicodeSpinners.braille
const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋'
return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false }
}
function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) {
const ui = useStore($uiState)
const style = ui.indicatorStyle
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
const [verbTick, setVerbTick] = useState(() => Math.floor(Math.random() * VERBS.length))
const [now, setNow] = useState(() => Date.now())
// Pre-compute cadence + verb-visibility for the active style so an
// `/indicator` switch re-arms the interval (and skips the verb timer
// for verb-less styles like `unicode`) without leaving the previous
// timer dangling.
const { intervalMs, showVerb } = renderIndicator(style, 0)
useEffect(() => {
const face = setInterval(() => setTick(n => n + 1), FACE_TICK_MS)
const glyph = setInterval(() => setTick(n => n + 1), intervalMs)
const clock = setInterval(() => setNow(Date.now()), 1000)
// Verb timer is gated on `showVerb` — `unicode` style hides the verb
// entirely, so cycling `verbTick` would be an avoidable re-render.
const verb = showVerb ? setInterval(() => setVerbTick(n => n + 1), FACE_TICK_MS) : null
return () => {
clearInterval(face)
clearInterval(glyph)
clearInterval(clock)
if (verb !== null) {
clearInterval(verb)
}
}
}, [])
}, [intervalMs, showVerb])
const { frame } = renderIndicator(style, tick)
const verb = VERBS[verbTick % VERBS.length] ?? ''
const verbSegment = showVerb ? ` ${verb}` : ''
const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''
return (
<Text color={color}>
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''}
{frame}
{verbSegment}
{durationSegment}
</Text>
)
}
@@ -260,6 +338,22 @@ export function StatusRule({
)
}
export function FloatBox({ children, color }: { children: ReactNode; color: string }) {
return (
<Box
alignSelf="flex-start"
borderColor={color}
borderStyle="double"
flexDirection="column"
marginTop={1}
opaque
paddingX={1}
>
{children}
</Box>
)
}
export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) {
const { atBottom, bottom, top } = useViewportSnapshot(scrollRef)
const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom)

View File

@@ -127,9 +127,6 @@ const ComposerPane = memo(function ComposerPane({
const promptText = sh ? '$' : ui.theme.brand.prompt
const promptLabel = `${promptText} `
const promptWidth = Math.max(1, stringWidth(promptLabel))
// ``pw`` retained as the local alias used by the mouse-drag handlers
// below — semantically the same value, kept short for readability there.
const pw = promptWidth
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
const inputHeight = inputVisualHeight(composer.input, inputColumns)
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
@@ -151,7 +148,7 @@ const ComposerPane = memo(function ComposerPane({
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - pw)
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - promptWidth)
}
// Spacer rows live on a different vertical origin; only the column is
@@ -163,7 +160,7 @@ const ComposerPane = memo(function ComposerPane({
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - pw)
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth)
}
const endInputDrag = () => inputMouseRef.current?.end()
@@ -227,7 +224,12 @@ const ComposerPane = memo(function ComposerPane({
</Box>
))}
<Box onMouseDown={captureInputDrag} onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative">
<Box
onMouseDown={captureInputDrag}
onMouseDrag={dragFromPromptRow}
onMouseUp={endInputDrag}
position="relative"
>
<Box width={promptWidth}>
{sh ? (
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>

View File

@@ -1,4 +1,4 @@
import { Box, Text, useStdout } from '@hermes/ink'
import { Box, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useGateway } from '../app/gatewayContext.js'
@@ -6,18 +6,15 @@ import type { AppOverlaysProps } from '../app/interfaces.js'
import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import { LearningLedger } from './learningLedger.js'
import { FloatBox } from './appChrome.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { OverlayHint } from './overlayControls.js'
import { OverlayGrid } from './overlayGrid.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
import { SkillsHub } from './skillsHub.js'
const COMPLETION_WINDOW = 16
const OVERLAY_GUTTER = 4
const OVERLAY_MIN_WIDTH = 44
export function PromptZone({
cols,
@@ -105,15 +102,8 @@ export function FloatingOverlays({
const { gw } = useGateway()
const overlay = useStore($overlayState)
const ui = useStore($uiState)
const { stdout } = useStdout()
const hasAny =
overlay.learningLedger ||
overlay.modelPicker ||
overlay.pager ||
overlay.picker ||
overlay.skillsHub ||
completions.length
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length
if (!hasAny) {
return null
@@ -125,169 +115,87 @@ export function FloatingOverlays({
const viewportSize = Math.min(COMPLETION_WINDOW, completions.length)
const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize))
const overlayWidth = Math.max(OVERLAY_MIN_WIDTH, cols - OVERLAY_GUTTER)
const overlayMaxHeight = Math.max(6, Math.min(18, (stdout?.rows ?? 24) - 8))
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.picker && (
<OverlayGrid
borderColor={ui.theme.color.border}
panels={[
{
content: (
<SessionPicker
gw={gw}
onCancel={() => patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
t={ui.theme}
/>
),
id: 'sessions'
}
]}
maxHeight={overlayMaxHeight}
t={ui.theme}
width={overlayWidth}
/>
<FloatBox color={ui.theme.color.border}>
<SessionPicker
gw={gw}
onCancel={() => patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
t={ui.theme}
/>
</FloatBox>
)}
{overlay.modelPicker && (
<OverlayGrid
borderColor={ui.theme.color.border}
panels={[
{
content: (
<ModelPicker
gw={gw}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={ui.sid}
t={ui.theme}
/>
),
id: 'models'
}
]}
maxHeight={overlayMaxHeight}
t={ui.theme}
width={overlayWidth}
/>
<FloatBox color={ui.theme.color.border}>
<ModelPicker
gw={gw}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={ui.sid}
t={ui.theme}
/>
</FloatBox>
)}
{overlay.skillsHub && (
<OverlayGrid
borderColor={ui.theme.color.border}
panels={[
{
content: <SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />,
id: 'skills'
}
]}
maxHeight={overlayMaxHeight}
t={ui.theme}
width={overlayWidth}
/>
)}
{overlay.learningLedger && (
<LearningLedger
borderColor={ui.theme.color.border}
gw={gw}
onClose={() => patchOverlayState({ learningLedger: false })}
t={ui.theme}
width={overlayWidth}
maxHeight={overlayMaxHeight}
/>
<FloatBox color={ui.theme.color.border}>
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />
</FloatBox>
)}
{overlay.pager && (
<OverlayGrid
borderColor={ui.theme.color.border}
panels={[
{
content: (
<Box flexDirection="column">
{overlay.pager.lines
.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize)
.map((line, i) => (
<Text key={i}>{line}</Text>
))}
<FloatBox color={ui.theme.color.border}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={ui.theme.color.primary}>
{overlay.pager.title}
</Text>
</Box>
)}
</Box>
),
footer: (
<OverlayHint t={ui.theme}>
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
</OverlayHint>
),
id: 'pager',
title: overlay.pager.title
}
]}
maxHeight={overlayMaxHeight}
t={ui.theme}
width={overlayWidth}
/>
{overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Box marginTop={1}>
<OverlayHint t={ui.theme}>
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
</OverlayHint>
</Box>
</Box>
</FloatBox>
)}
{!!completions.length && (
<OverlayGrid
borderColor={ui.theme.color.primary}
panels={[
{
content: (
<Box flexDirection="column">
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
<FloatBox color={ui.theme.color.primary}>
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
return (
<Box
backgroundColor={active ? ui.theme.color.completionCurrentBg : undefined}
key={`${start + i}:${item.text}`}
width="100%"
>
<Text bold color={ui.theme.color.label} wrap="truncate-end">
{item.display}
</Text>
</Box>
)
})}
return (
<Box
backgroundColor={active ? ui.theme.color.completionCurrentBg : undefined}
flexDirection="row"
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
>
<Text bold color={ui.theme.color.label}>
{' '}
{item.display}
</Text>
{item.meta ? <Text color={ui.theme.color.muted}> {item.meta}</Text> : null}
</Box>
),
grow: 4,
id: 'completion-list'
},
{
content: (
<Box flexDirection="column">
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
return (
<Box
backgroundColor={active ? ui.theme.color.completionCurrentBg : undefined}
key={`${start + i}:${item.text}:meta`}
width="100%"
>
<Text color={ui.theme.color.muted} wrap="truncate-end">
{item.meta ?? ' '}
</Text>
</Box>
)
})}
</Box>
),
grow: 6,
id: 'completion-meta'
}
]}
maxHeight={overlayMaxHeight}
t={ui.theme}
width={overlayWidth}
/>
)
})}
</Box>
</FloatBox>
)}
</Box>
)

View File

@@ -89,16 +89,6 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
</Box>
)
}
const learningLine = (() => {
const counts = info.learning?.counts ?? {}
const parts = [
counts.user || counts.memory ? `${(counts.user ?? 0) + (counts.memory ?? 0)} memories` : '',
counts.recall ? `${counts.recall} recalls` : '',
counts['skill-use'] ? `${counts['skill-use']} applied skills` : ''
].filter(Boolean)
return parts.length ? `learned: ${parts.join(' · ')}` : ''
})()
return (
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
@@ -170,12 +160,6 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
<Text color={t.color.muted}>/help for commands</Text>
</Text>
{learningLine && (
<Text color={t.color.text} dimColor italic>
{learningLine} · /learned
</Text>
)}
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
<Text bold color={t.color.warn}>
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind

View File

@@ -1,316 +0,0 @@
import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayGrid } from './overlayGrid.js'
import { OverlayHint, windowItems, windowOffset } from './overlayControls.js'
const EDGE_GUTTER = 10
const MAX_WIDTH = 132
const MIN_WIDTH = 64
const VISIBLE_ROWS = 12
const LISTS = [
{ id: 'memories', title: 'Memories', types: ['user', 'memory'] },
{ id: 'skills', title: 'Skills', types: ['skill-use'] },
{ id: 'recalls', title: 'Recalls', types: ['recall'] },
{ id: 'connected', title: 'Connected', types: ['integration'] }
] as const
const typeIcon: Record<string, string> = {
integration: '◇',
memory: '◆',
recall: '↺',
'skill-use': '✦',
user: '●'
}
const fmtTime = (ts?: null | number) => {
if (!ts) {
return ''
}
const days = Math.floor((Date.now() - ts * 1000) / 86_400_000)
return days <= 0 ? 'today' : `${days}d ago`
}
export function LearningLedger({ borderColor, gw, maxHeight, onClose, t, width: fixedWidth }: LearningLedgerProps) {
const [ledger, setLedger] = useState<LearningLedgerResponse | null>(null)
const [activeList, setActiveList] = useState(0)
const [indices, setIndices] = useState<Record<string, number>>({})
const [expanded, setExpanded] = useState(false)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = fixedWidth ?? Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - EDGE_GUTTER))
useEffect(() => {
gw.request<LearningLedgerResponse>('learning.ledger', { limit: 120 })
.then(r => {
setLedger(r)
setErr('')
})
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
.finally(() => setLoading(false))
}, [gw])
const items = ledger?.items ?? []
const lists = LISTS.map(list => ({
...list,
items: items.filter(item => list.types.includes(item.type as never))
}))
const active = lists[activeList] ?? lists[0]!
const activeIdx = Math.min(indices[active.id] ?? 0, Math.max(0, active.items.length - 1))
const selected = active.items[activeIdx]
const detailOpen = expanded && !!selected
useInput((ch, key) => {
if (key.escape || ch.toLowerCase() === 'q') {
onClose()
return
}
if (key.leftArrow && activeList > 0) {
setActiveList(v => v - 1)
return
}
if (key.rightArrow && activeList < lists.length - 1) {
setActiveList(v => v + 1)
return
}
if (key.upArrow && activeIdx > 0) {
setIndices(v => ({ ...v, [active.id]: activeIdx - 1 }))
return
}
if (key.downArrow && activeIdx < active.items.length - 1) {
setIndices(v => ({ ...v, [active.id]: activeIdx + 1 }))
return
}
if (key.return || ch === ' ') {
setExpanded(v => !v)
return
}
const n = ch === '0' ? 10 : parseInt(ch, 10)
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, active.items.length)) {
const next = windowOffset(active.items.length, activeIdx, VISIBLE_ROWS) + n - 1
if (active.items[next]) {
setIndices(v => ({ ...v, [active.id]: next }))
}
}
})
if (loading) {
return <Text color={t.color.muted}>indexing learning ledger</Text>
}
if (err) {
return (
<Box flexDirection="column" width={width}>
<Text color={t.color.label}>learning ledger error: {err}</Text>
<OverlayHint t={t}>Esc/q close</OverlayHint>
</Box>
)
}
if (!items.length) {
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Recent Learning
</Text>
<Text color={t.color.muted}>no memories, recalls, used skills, or integrations found yet</Text>
{ledger?.inventory?.skills ? (
<Text color={t.color.muted}>available knowledge: {ledger.inventory.skills} installed skills</Text>
) : null}
<OverlayHint t={t}>Esc/q close</OverlayHint>
</Box>
)
}
const listPanels = lists.map((list, listIdx) => {
const selectedIndex = Math.min(indices[list.id] ?? 0, Math.max(0, list.items.length - 1))
const { items: visible, offset } = windowItems(list.items, selectedIndex, Math.max(3, Math.floor(VISIBLE_ROWS / 2)))
return {
content: (
<LearningList
active={activeList === listIdx}
items={visible}
offset={offset}
selectedIndex={selectedIndex}
t={t}
total={list.items.length}
/>
),
grow: 1,
id: `learning-${list.id}`,
title: list.title
}
})
return (
<OverlayGrid
borderColor={borderColor}
footer={<OverlayHint t={t}>/ panel · / select · Enter/Space details · 1-9,0 quick · Esc/q close</OverlayHint>}
panels={[
...listPanels,
...(detailOpen && selected
? [
{
content: <LedgerDetails item={selected} t={t} />,
grow: 2,
id: 'learning-details',
title: 'Details'
}
]
: [])
]}
maxHeight={maxHeight}
t={t}
width={width}
/>
)
}
function LearningList({ active, items, offset, selectedIndex, t, total }: LearningListProps) {
return (
<Box flexDirection="column">
<Text color={active ? t.color.accent : t.color.muted}>{total} item{total === 1 ? '' : 's'}</Text>
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
<Box flexDirection="column">
{items.map((item, i) => {
const absolute = offset + i
return (
<LedgerRow
active={active && absolute === selectedIndex}
index={i + 1}
item={item}
key={`${item.type}:${item.name}:${i}`}
t={t}
/>
)
})}
</Box>
{offset + items.length < total && (
<Text color={t.color.muted}> {total - offset - items.length} more</Text>
)}
</Box>
)
}
function LedgerRow({ active, index, item, t }: LedgerRowProps) {
const when = fmtTime(item.last_used_at ?? item.learned_at)
const count = item.count ? ` ×${item.count}` : ''
const icon = typeIcon[item.type] ?? '•'
const title = compactTitle(item)
return (
<Box flexShrink={0} width="100%">
<Text bold={active} color={active ? t.color.accent : t.color.muted} inverse={active} wrap="truncate-end">
{active ? '▸ ' : ' '}
{index}. {icon} {title}
<Text color={active ? t.color.accent : t.color.muted}>
{' '}
{count}
{when ? ` · ${when}` : ''}
</Text>
</Text>
</Box>
)
}
function compactTitle(item: LearningLedgerItem) {
const raw = item.type === 'memory' || item.type === 'user' ? item.summary : item.name
return raw
.replace(/^User\s+/i, '')
.replace(/^Durable memory updates$/i, 'memory updated')
.replace(/^session_search$/i, 'past sessions')
}
function LedgerDetails({ item, t }: LedgerDetailsProps) {
const memoryLike = item.type === 'memory' || item.type === 'user'
return (
<Box flexDirection="column">
<Text color={t.color.primary} wrap="truncate-end">
{memoryLike ? item.name : item.summary}
</Text>
{memoryLike ? <Text color={t.color.text}>{item.summary}</Text> : null}
{item.count ? <Text color={t.color.muted}>used: {item.count}×</Text> : null}
{item.learned_from ? <Text color={t.color.muted}>from: {item.learned_from}</Text> : null}
{item.via ? <Text color={t.color.muted}>via: {item.via}</Text> : null}
{item.last_used_at ? <Text color={t.color.muted}>last used: {fmtTime(item.last_used_at)}</Text> : null}
<Text color={t.color.muted}>source: {item.source}</Text>
</Box>
)
}
interface LearningLedgerItem {
count?: number
learned_from?: null | string
last_used_at?: null | number
learned_at?: null | number
name: string
source: string
summary: string
type: string
via?: null | string
}
interface LearningLedgerResponse {
counts?: Record<string, number>
generated_at?: number
home?: string
inventory?: { skills?: number }
items?: LearningLedgerItem[]
total?: number
}
interface LearningListProps {
active: boolean
items: LearningLedgerItem[]
offset: number
selectedIndex: number
t: Theme
total: number
}
interface LedgerRowProps {
active: boolean
index: number
item: LearningLedgerItem
t: Theme
}
interface LedgerDetailsProps {
item: LearningLedgerItem
t: Theme
}
interface LearningLedgerProps {
borderColor: string
gw: GatewayClient
maxHeight?: number
onClose: () => void
t: Theme
width?: number
}

View File

@@ -94,16 +94,6 @@ export const MessageLine = memo(function MessageLine({
)
}
if (msg.kind === 'learning') {
return (
<Box marginLeft={3} marginTop={1}>
<Text color={t.color.muted} italic>
{msg.text}
</Text>
</Box>
)
}
const { body, glyph, prefix } = ROLE[msg.role](t)
const showDetails =

View File

@@ -25,8 +25,10 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const [stage, setStage] = useState<'model' | 'provider'>('provider')
const { stdout } = useStdout()
// Pin the picker to a stable width so long provider / model names scroll
// into view without changing the overlay grid's measured layout.
// Pin the picker to a stable width so the FloatBox parent (which shrinks-
// to-fit with alignSelf="flex-start") doesn't resize as long provider /
// model names scroll into view, and so `wrap="truncate-end"` on each row
// has an actual constraint to truncate against.
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
useEffect(() => {

View File

@@ -1,79 +0,0 @@
import { Box, Text } from '@hermes/ink'
import type { ReactNode } from 'react'
import type { Theme } from '../theme.js'
const GAP = 2
export function OverlayGrid({ borderColor, footer, maxHeight, panels, t, width }: OverlayGridProps) {
const visible = panels.filter(p => p.content)
const innerWidth = Math.max(20, width - 4)
const innerHeight = maxHeight ? Math.max(1, maxHeight - 2) : undefined
const panelHeight = innerHeight ? Math.max(1, innerHeight - (footer ? 1 : 0)) : undefined
const gapTotal = Math.max(0, visible.length - 1) * GAP
const usable = Math.max(1, innerWidth - gapTotal)
const growTotal = visible.reduce((sum, p) => sum + (p.grow ?? 1), 0) || 1
let used = 0
return (
<Box
alignSelf="flex-start"
borderColor={borderColor}
borderStyle="double"
flexDirection="column"
marginTop={1}
opaque
paddingX={1}
width={width}
>
<Box flexDirection="row">
{visible.map((panel, i) => {
const last = i === visible.length - 1
const panelWidth = last
? Math.max(1, usable - used)
: Math.max(1, Math.floor((usable * (panel.grow ?? 1)) / growTotal))
used += panelWidth
return (
<Box flexDirection="row" key={panel.id}>
<Box flexDirection="column" flexShrink={0} width={panelWidth}>
{panel.title ? (
<Text bold color={t.color.accent}>
{panel.title}
</Text>
) : null}
<Box
flexDirection="column"
height={panelHeight ? Math.max(1, panelHeight - (panel.title ? 1 : 0) - (panel.footer ? 1 : 0)) : undefined}
overflow="hidden"
>
{panel.content}
</Box>
{panel.footer ? <Box flexDirection="column">{panel.footer}</Box> : null}
</Box>
{!last ? <Box flexShrink={0} width={GAP} /> : null}
</Box>
)
})}
</Box>
{footer ? <Box flexDirection="column">{footer}</Box> : null}
</Box>
)
}
export interface OverlayGridPanel {
content: ReactNode
footer?: ReactNode
grow?: number
id: string
title?: string
}
interface OverlayGridProps {
borderColor: string
footer?: ReactNode
maxHeight?: number
panels: OverlayGridPanel[]
t: Theme
width: number
}

View File

@@ -133,7 +133,12 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
</Text>
</Box>
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected} wrap="truncate-end">
<Text
bold={selected}
color={selected ? t.color.accent : t.color.muted}
inverse={selected}
wrap="truncate-end"
>
{s.title || s.preview || '(untitled)'}
</Text>
</Box>

View File

@@ -360,6 +360,10 @@ export function TextInput({
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
// Placeholder text is just a hint, not a selection — render it dim
// without inverse styling. In a TTY the hardware cursor parks at column
// 0 and visually marks the input start. Non-TTY surfaces still need the
// synthetic inverse first-char to draw a cursor at all.
const rendered = useMemo(() => {
if (!focus) {
return display || dim(placeholder)
@@ -711,6 +715,14 @@ export function TextInput({
if (range && range.start === range.end) {
selRef.current = null
setSel(null)
return
}
const normalized = selRange()
if (isMac && normalized) {
void writeClipboardText(vRef.current.slice(normalized.start, normalized.end))
}
}

View File

@@ -873,7 +873,7 @@ export const ToolTrail = memo(function ToolTrail({
const hasTools = groups.length > 0
const hasSubagents = subagents.length > 0
const hasMeta = meta.length > 0
const hasThinking = !!cot || reasoningActive || busy
const hasThinking = !!cot || reasoningActive || reasoningStreaming
const thinkingLive = reasoningActive || reasoningStreaming
const tokenCount =

View File

@@ -26,21 +26,25 @@ export const stickyPromptFromViewport = (
return ''
}
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1))
const last = Math.max(first, Math.min(messages.length - 1, upperBound(offsets, bottom) - 1))
const first = Math.max(0, upperBound(offsets, top) - 1)
const last = Math.max(first, upperBound(offsets, bottom) - 1)
const visibleStart = Math.min(messages.length, first)
const visibleEnd = Math.min(messages.length - 1, last)
for (let i = first; i <= last; i++) {
for (let i = visibleStart; i <= visibleEnd; i++) {
if (messages[i]?.role === 'user') {
return ''
}
}
for (let i = first - 1; i >= 0; i--) {
for (let i = Math.min(messages.length - 1, visibleStart - 1); i >= 0; i--) {
if (messages[i]?.role !== 'user') {
continue
}
return (offsets[i] ?? 0) + 1 < top ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : ''
return (offsets[i + 1] ?? (offsets[i] ?? 0) + 1) <= top
? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
: ''
}
return ''

View File

@@ -1,4 +1,10 @@
#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc
// Must be first import — mutates process.env.FORCE_COLOR / COLORTERM before
// any chalk / supports-color import so the banner gradient renders in
// truecolor instead of being downsampled to 256-color (which collapses
// gold #FFD700 and amber #FFBF00 to the same slot).
import './lib/forceTruecolor.js'
import type { FrameEvent } from '@hermes/ink'
import { GatewayClient } from './gatewayClient.js'

View File

@@ -53,8 +53,10 @@ export type CommandDispatchResponse =
export interface ConfigDisplayConfig {
bell_on_complete?: boolean
busy_input_mode?: string
details_mode?: string
inline_diffs?: boolean
mouse_tracking?: boolean | null | number | string
sections?: Record<string, string>
show_cost?: boolean
show_reasoning?: boolean
@@ -62,7 +64,14 @@ export interface ConfigDisplayConfig {
thinking_mode?: string
tui_auto_resume_recent?: boolean
tui_compact?: boolean
tui_mouse?: boolean
/** Legacy alias for display.mouse_tracking. */
tui_mouse?: boolean | null | number | string
// Forward-compat: backend may send styles this client doesn't know yet —
// `normalizeIndicatorStyle` falls back to 'kaomoji' for those — but the
// wire type is documented as `string` so consumers don't get a false
// narrowing-and-autocomplete contract on a value that requires runtime
// validation anyway.
tui_status_indicator?: string
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
}
@@ -418,17 +427,16 @@ export type GatewayEvent =
| { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' }
| { payload: SessionInfo; session_id?: string; type: 'session.info' }
| { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' }
| {
payload?: { source?: string; summary?: string; title?: string; type?: string; verb?: string; via?: string }
session_id?: string
type: 'learning.event'
}
| { payload?: undefined; session_id?: string; type: 'message.start' }
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
| { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' }
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
| { payload?: { cwd?: string; python?: string; stderr_tail?: string }; session_id?: string; type: 'gateway.start_timeout' }
| {
payload?: { cwd?: string; python?: string; stderr_tail?: string }
session_id?: string
type: 'gateway.start_timeout'
}
| { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' }
| { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }
@@ -468,13 +476,7 @@ export type GatewayEvent =
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' }
| { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' }
| {
payload?: {
learning_events?: { source?: string; summary?: string; title?: string; type?: string; verb?: string; via?: string }[]
reasoning?: string
rendered?: string
text?: string
usage?: Usage
}
payload?: { reasoning?: string; rendered?: string; text?: string; usage?: Usage }
session_id?: string
type: 'message.complete'
}

View File

@@ -0,0 +1,32 @@
/**
* Force 24-bit truecolor output before any chalk / supports-color import.
*
* Why this exists:
* The base CLI (Python/Rich) emits banner colors as truecolor ANSI
* (`\033[38;2;R;G;Bm`). The TUI renders through Ink → chalk, whose
* supports-color auto-detection defaults to 256-color on macOS Terminal.app
* and any terminal that does NOT set `COLORTERM=truecolor`. In 256-color
* mode, chalk downsamples `#FFD700` (gold) and `#FFBF00` (amber) to the
* *same* xterm-256 palette slot (220) — collapsing the banner gradient
* into a single flat yellow band. The bronze and dim rows also lose
* contrast against each other.
*
* Terminal.app (macOS 12+), iTerm2, kitty, Alacritty, VS Code, Cursor,
* and WezTerm all render truecolor correctly. The few that don't
* (ancient xterm, some CI environments) can set `HERMES_TUI_TRUECOLOR=0`
* to opt out.
*
* This MUST run before any `chalk` or `supports-color` import. supports-color
* caches its level on first load, so nudging env vars after that point has
* no effect.
*/
if (process.env.HERMES_TUI_TRUECOLOR !== '0' && !process.env.NO_COLOR && !process.env.FORCE_COLOR) {
if (!process.env.COLORTERM) {
process.env.COLORTERM = 'truecolor'
}
process.env.FORCE_COLOR = '3'
}
export {}

View File

@@ -42,7 +42,13 @@ export const isCopyShortcut = (
ch: string,
env: NodeJS.ProcessEnv = process.env
): boolean =>
isAction(key, ch, 'c') || (isRemoteShell(env) && (key.meta || key.super === true) && ch.toLowerCase() === 'c')
ch.toLowerCase() === 'c' &&
(isAction(key, ch, 'c') ||
(isRemoteShell(env) && (key.meta || key.super === true)) ||
// VS Code/Cursor/Windsurf terminal setup forwards Cmd+C as a CSI-u
// sequence with the super bit plus a benign ctrl bit. Accept that shape
// even though raw Ctrl+C should remain interrupt on local macOS.
(isMac && key.ctrl && (key.meta || key.super === true)))
/**
* Voice recording toggle key (Ctrl+B).

View File

@@ -25,6 +25,7 @@ export type TerminalSetupResult = {
}
const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile }
const COPY_SEQUENCE = '\u001b[99;13u'
const MULTILINE_SEQUENCE = '\\\r\n'
const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string }> = {
@@ -33,7 +34,14 @@ const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string
windsurf: { appName: 'Windsurf', label: 'Windsurf' }
}
const TARGET_BINDINGS: Keybinding[] = [
const MAC_COPY_BINDING: Keybinding = {
key: 'cmd+c',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus && terminalTextSelected',
args: { text: COPY_SEQUENCE }
}
const BASE_BINDINGS: Keybinding[] = [
{
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
@@ -66,6 +74,9 @@ const TARGET_BINDINGS: Keybinding[] = [
}
]
const targetBindings = (platform: NodeJS.Platform): Keybinding[] =>
platform === 'darwin' ? [MAC_COPY_BINDING, ...BASE_BINDINGS] : BASE_BINDINGS
export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): null | SupportedTerminal {
const askpass = env['VSCODE_GIT_ASKPASS_MAIN']?.toLowerCase() ?? ''
@@ -172,6 +183,90 @@ function sameBinding(a: Keybinding, b: Keybinding): boolean {
return a.key === b.key && a.command === b.command && a.when === b.when && a.args?.text === b.args?.text
}
type WhenRequirements = {
forbidden: Set<string>
required: Set<string>
}
const WHEN_TOKEN_RE = /!?[A-Za-z_][\w.]*/g
function parseWhenRequirements(when: string): WhenRequirements {
const required = new Set<string>()
const forbidden = new Set<string>()
for (const [token] of when.matchAll(WHEN_TOKEN_RE)) {
if (token.startsWith('!')) {
forbidden.add(token.slice(1))
} else {
required.add(token)
}
}
return { forbidden, required }
}
function requirementsContradict(a: WhenRequirements, b: WhenRequirements): boolean {
for (const token of a.required) {
if (b.forbidden.has(token)) {
return true
}
}
for (const token of b.required) {
if (a.forbidden.has(token)) {
return true
}
}
return false
}
function whensOverlap(a: string, b: string): boolean {
if (a === b) {
return true
}
// Empty when = global, overlaps every context.
if (!a || !b) {
return true
}
const left = parseWhenRequirements(a)
const right = parseWhenRequirements(b)
if (requirementsContradict(left, right)) {
return false
}
// This intentionally avoids a full VS Code when-clause parser. If two
// same-key bindings share a positive context token and don't explicitly
// contradict each other, they can fire together in that context.
for (const token of left.required) {
if (right.required.has(token)) {
return true
}
}
return false
}
// VS Code allows multiple bindings on the same key as long as their `when`
// clauses don't overlap. We flag a conflict when the contexts overlap but
// the bindings differ — e.g. existing `terminalFocus` cmd+c overlaps with
// our `terminalFocus && terminalTextSelected`, so the existing binding
// would shadow ours when text isn't selected.
function bindingsConflict(existing: Keybinding, target: Keybinding): boolean {
if (existing.key !== target.key) {
return false
}
if (!whensOverlap(existing.when ?? '', target.when ?? '')) {
return false
}
return !sameBinding(existing, target)
}
async function backupFile(filePath: string, ops: FileOps): Promise<void> {
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
await ops.copyFile(filePath, `${filePath}.backup.${stamp}`)
@@ -240,10 +335,10 @@ export async function configureTerminalKeybindings(
}
}
const conflicts = TARGET_BINDINGS.filter(target =>
keybindings.some(
existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target)
)
const targets = targetBindings(platform)
const conflicts = targets.filter(target =>
keybindings.some(existing => isKeybinding(existing) && bindingsConflict(existing, target))
)
if (conflicts.length) {
@@ -256,7 +351,7 @@ export async function configureTerminalKeybindings(
let added = 0
for (const target of TARGET_BINDINGS.slice().reverse()) {
for (const target of targets.slice().reverse()) {
const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target))
if (!exists) {
@@ -340,7 +435,7 @@ export async function shouldPromptForTerminalSetup(options?: {
return true
}
return TARGET_BINDINGS.some(
return targetBindings(platform).some(
target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target))
)
} catch {

View File

@@ -28,11 +28,18 @@ export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapsho
const pending = s.getPendingDelta()
const top = Math.max(0, s.getScrollTop() + pending)
const viewportHeight = Math.max(0, s.getViewportHeight())
const scrollHeight = Math.max(viewportHeight, s.getScrollHeight())
const cachedScrollHeight = Math.max(viewportHeight, s.getScrollHeight())
let scrollHeight = cachedScrollHeight
const bottom = top + viewportHeight
let atBottom = s.isSticky() || bottom >= scrollHeight - 2
if (!atBottom) {
scrollHeight = Math.max(viewportHeight, s.getFreshScrollHeight?.() ?? cachedScrollHeight)
atBottom = s.isSticky() || bottom >= scrollHeight - 2
}
return {
atBottom: s.isSticky() || bottom >= scrollHeight - 2,
atBottom,
bottom,
pending,
scrollHeight,

View File

@@ -187,23 +187,130 @@ export const LIGHT_THEME: Theme = {
bannerHero: ''
}
// Pick light vs dark. Explicit `HERMES_TUI_LIGHT` wins; otherwise sniff
// `COLORFGBG` (set by XFCE Terminal, rxvt, Terminal.app, etc.) — last field is the
// background ANSI index; 7/15 are the "white" slots most light themes emit (#11300).
export function detectLightMode(env: NodeJS.ProcessEnv = process.env): boolean {
const explicit = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
const TRUE_RE = /^(?:1|true|yes|on)$/
const FALSE_RE = /^(?:0|false|no|off)$/
if (/^(?:1|true|yes|on)$/.test(explicit)) {
// Reserved for future TERM_PROGRAM-based heuristics. Empty by default:
// most modern terminals (Ghostty, Warp, iTerm2, Apple_Terminal) ship a
// dark profile out of the box, so guessing wrong here is more annoying
// than missing a light user — light users can always set
// `HERMES_TUI_LIGHT=1` or `HERMES_TUI_THEME=light`.
const LIGHT_DEFAULT_TERM_PROGRAMS = new Set<string>()
// Best-effort RGB → luminance check. Currently only accepts a 3- or
// 6-digit hex value (with or without a leading `#`); the env var name
// `HERMES_TUI_BACKGROUND` is intentionally generic so a future OSC11
// query helper can cache its answer there too, but additional formats
// (rgb()/hsl()/named colours) would need explicit parsing here first.
const LUMA_LIGHT_THRESHOLD = 0.6
// Strict allow-list: parseInt(..., 16) silently truncates at the first
// non-hex character (e.g. `fffgff` would parse as `fff` and yield a
// false-positive "white" reading), so reject anything that doesn't match
// the canonical 3- or 6-digit shape up front.
const HEX_3_RE = /^[0-9a-f]{3}$/
const HEX_6_RE = /^[0-9a-f]{6}$/
function backgroundLuminance(raw: string): null | number {
const v = raw.trim().toLowerCase()
if (!v) {
return null
}
const hex = v.startsWith('#') ? v.slice(1) : v
const rgb = HEX_6_RE.test(hex)
? [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
: HEX_3_RE.test(hex)
? [parseInt(hex[0]! + hex[0]!, 16), parseInt(hex[1]! + hex[1]!, 16), parseInt(hex[2]! + hex[2]!, 16)]
: null
if (!rgb) {
return null
}
// Rec. 709 luma — close enough for "is this background bright".
return (0.2126 * rgb[0]! + 0.7152 * rgb[1]! + 0.0722 * rgb[2]!) / 255
}
// Pick light vs dark with ordered, explainable signals (#11300):
//
// 1. `HERMES_TUI_LIGHT` boolean — `1`/`true`/`yes`/`on` → light;
// `0`/`false`/`no`/`off` → dark. Either explicit value wins
// regardless of any later signal.
// 2. `HERMES_TUI_THEME` named override — `light` / `dark` win over
// every signal below.
// 3. `HERMES_TUI_BACKGROUND` hex hint (3- or 6-digit) — luminance
// ≥ LUMA_LIGHT_THRESHOLD → light.
// 4. `COLORFGBG` last field — XFCE / rxvt / Terminal.app emit
// slot 7 or 15 on light profiles; 015 ranges are otherwise
// treated as authoritatively dark so the TERM_PROGRAM
// allow-list below cannot override an explicit dark profile.
// 5. `TERM_PROGRAM` light-default allow-list (currently empty).
//
// Anything we can't decide stays dark — the default Hermes palette
// is the dark one.
export function detectLightMode(
env: NodeJS.ProcessEnv = process.env,
// Injectable so tests can prove the COLORFGBG-over-TERM_PROGRAM
// precedence rule even though the production allow-list is empty.
lightDefaultTermPrograms: ReadonlySet<string> = LIGHT_DEFAULT_TERM_PROGRAMS
): boolean {
const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
if (TRUE_RE.test(lightFlag)) {
return true
}
if (/^(?:0|false|no|off)$/.test(explicit)) {
if (FALSE_RE.test(lightFlag)) {
return false
}
const bg = Number((env.COLORFGBG ?? '').trim().split(';').at(-1))
const themeFlag = (env.HERMES_TUI_THEME ?? '').trim().toLowerCase()
return bg === 7 || bg === 15
if (themeFlag === 'light') {
return true
}
if (themeFlag === 'dark') {
return false
}
const bgHint = backgroundLuminance(env.HERMES_TUI_BACKGROUND ?? '')
if (bgHint !== null) {
return bgHint >= LUMA_LIGHT_THRESHOLD
}
const colorfgbg = (env.COLORFGBG ?? '').trim()
if (colorfgbg) {
// Validate as a decimal integer before coercing — `Number('')` is 0,
// so a malformed `COLORFGBG='15;'` would otherwise look like an
// authoritative dark slot and incorrectly block the TERM_PROGRAM
// allow-list. Anything that isn't pure digits falls through.
const lastField = colorfgbg.split(';').at(-1) ?? ''
if (/^\d+$/.test(lastField)) {
const bg = Number(lastField)
if (bg === 7 || bg === 15) {
return true
}
// Slots 06 and 814 are the dark half of the 015 ANSI range.
// When COLORFGBG is set we trust it as authoritative — a non-light
// value here shouldn't get overridden by the TERM_PROGRAM allow-list.
if (bg >= 0 && bg < 16) {
return false
}
}
}
const termProgram = (env.TERM_PROGRAM ?? '').trim()
return lightDefaultTermPrograms.has(termProgram)
}
export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME
@@ -224,6 +331,7 @@ export function fromSkin(
const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent
const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent
const muted = c('banner_dim') ?? d.color.muted
const completionBg = c('completion_menu_bg') ?? d.color.completionBg
return {
color: {
@@ -232,8 +340,8 @@ export function fromSkin(
border: c('ui_border') ?? c('banner_border') ?? d.color.border,
text: c('ui_text') ?? c('banner_text') ?? d.color.text,
muted,
completionBg: c('completion_menu_bg') ?? d.color.completionBg,
completionCurrentBg: c('completion_menu_current_bg') ?? mix(d.color.completionBg, bannerAccent, 0.25),
completionBg,
completionCurrentBg: c('completion_menu_current_bg') ?? mix(completionBg, bannerAccent, 0.25),
label: c('ui_label') ?? d.color.label,
ok: c('ui_ok') ?? d.color.ok,

View File

@@ -108,7 +108,7 @@ export interface ClarifyReq {
export interface Msg {
info?: SessionInfo
kind?: 'diff' | 'intro' | 'learning' | 'panel' | 'slash' | 'trail'
kind?: 'diff' | 'intro' | 'panel' | 'slash' | 'trail'
panelData?: PanelData
role: Role
text: string
@@ -148,7 +148,6 @@ export interface SessionInfo {
reasoning_effort?: string
service_tier?: string
release_date?: string
learning?: LearningSummary
skills: Record<string, string[]>
tools: Record<string, string[]>
update_behind?: number | null
@@ -157,12 +156,6 @@ export interface SessionInfo {
version?: string
}
export interface LearningSummary {
counts?: Record<string, number>
inventory?: { skills?: number }
total?: number
}
export interface Usage {
calls: number
context_max?: number

View File

@@ -83,6 +83,7 @@ declare module '@hermes/ink' {
readonly getScrollTop: () => number
readonly getPendingDelta: () => number
readonly getScrollHeight: () => number
readonly getFreshScrollHeight: () => number
readonly getViewportHeight: () => number
readonly getViewportTop: () => number
readonly getLastManualScrollAt: () => number
@@ -145,6 +146,7 @@ declare module '@hermes/ink' {
readonly clearSelection: () => void
readonly hasSelection: () => boolean
readonly getState: () => unknown
readonly version: () => number
readonly subscribe: (cb: () => void) => () => void
readonly shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
readonly shiftSelection: (dRow: number, minRow: number, maxRow: number) => void

View File

@@ -182,6 +182,161 @@ async def handle(event_type: str, context: dict):
}, timeout=5)
```
### Tutorial: BOOT.md — Run a Startup Checklist on Every Gateway Boot
A popular pattern from the community: drop a Markdown checklist at `~/.hermes/BOOT.md`, and have the agent run it once every time the gateway starts. Useful for "on every boot, check overnight cron failures and ping me on Discord if anything failed," or "summarize the last 24h of deploy.log and post it to Slack #ops."
This tutorial shows how to build it yourself as a user-defined hook. Hermes does not ship a built-in BOOT.md hook — you wire up exactly the behavior you want.
#### What we're building
1. A file at `~/.hermes/BOOT.md` with natural-language startup instructions.
2. A gateway hook that fires on `gateway:startup`, spawns a one-shot agent with your gateway's resolved model/credentials, and runs the BOOT.md instructions.
3. A `[SILENT]` convention so the agent can opt out of sending a message when there's nothing to report.
#### Step 1: Write your checklist
Create `~/.hermes/BOOT.md`. Write it as if you were giving instructions to a human assistant:
```markdown
# Startup Checklist
1. Run `hermes cron list` and check if any scheduled jobs failed overnight.
2. If any failed, send a summary to Discord #ops using the `send_message` tool.
3. Check if `/opt/app/deploy.log` has any ERROR lines from the last 24 hours. If yes, summarize them and include in the same Discord message.
4. If nothing went wrong, reply with only `[SILENT]` so no message is sent.
```
The agent sees this as part of its prompt, so anything you can describe in plain language works — tool calls, shell commands, sending messages, summarizing files.
#### Step 2: Create the hook
```text
~/.hermes/hooks/boot-md/
├── HOOK.yaml
└── handler.py
```
**`~/.hermes/hooks/boot-md/HOOK.yaml`**
```yaml
name: boot-md
description: Run ~/.hermes/BOOT.md on gateway startup
events:
- gateway:startup
```
**`~/.hermes/hooks/boot-md/handler.py`**
```python
"""Run ~/.hermes/BOOT.md on every gateway startup."""
import logging
import threading
from pathlib import Path
logger = logging.getLogger("hooks.boot-md")
BOOT_FILE = Path.home() / ".hermes" / "BOOT.md"
def _build_prompt(content: str) -> str:
return (
"You are running a startup boot checklist. Follow the instructions "
"below exactly.\n\n"
"---\n"
f"{content}\n"
"---\n\n"
"Execute each instruction. Use the send_message tool to deliver any "
"messages to platforms like Discord or Slack.\n"
"If nothing needs attention and there is nothing to report, reply "
"with ONLY: [SILENT]"
)
def _run_boot_agent(content: str) -> None:
"""Spawn a one-shot agent and execute the checklist.
Uses the gateway's resolved model and runtime credentials so this works
against custom endpoints, aggregators, and OAuth-based providers alike.
"""
try:
from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
from run_agent import AIAgent
agent = AIAgent(
model=_resolve_gateway_model(),
**_resolve_runtime_agent_kwargs(),
platform="gateway",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
max_iterations=20,
)
result = agent.run_conversation(_build_prompt(content))
response = result.get("final_response", "")
if response and "[SILENT]" not in response:
logger.info("boot-md completed: %s", response[:200])
else:
logger.info("boot-md completed (nothing to report)")
except Exception as e:
logger.error("boot-md agent failed: %s", e)
async def handle(event_type: str, context: dict) -> None:
if not BOOT_FILE.exists():
return
content = BOOT_FILE.read_text(encoding="utf-8").strip()
if not content:
return
logger.info("Running BOOT.md (%d chars)", len(content))
# Background thread so gateway startup isn't blocked on a full agent turn.
thread = threading.Thread(
target=_run_boot_agent,
args=(content,),
name="boot-md",
daemon=True,
)
thread.start()
```
The two key lines:
- `_resolve_gateway_model()` reads the gateway's currently-configured model.
- `_resolve_runtime_agent_kwargs()` resolves provider credentials the same way a normal gateway turn does — including API keys, base URLs, OAuth tokens, and credential pools.
Without these, a bare `AIAgent()` falls back to built-in defaults and will 401 against any non-default endpoint.
#### Step 3: Test it
Restart the gateway:
```bash
hermes gateway restart
```
Watch the logs:
```bash
hermes logs --follow --level INFO | grep boot-md
```
You should see `Running BOOT.md (N chars)` followed by either `boot-md completed: ...` (summary of what the agent did) or `boot-md completed (nothing to report)` when the agent replied `[SILENT]`.
Delete `~/.hermes/BOOT.md` to disable the checklist — the hook stays loaded but silently skips when the file isn't there.
#### Extending the pattern
- **Schedule-aware checklists:** key off `datetime.now().weekday()` inside BOOT.md's instructions ("if it's Monday, also check the weekly deploy log"). The instructions are free-form text, so anything the agent can reason about is fair game.
- **Multiple checklists:** point the hook at a different file (`STARTUP.md`, `MORNING.md`, etc.) and register separate hook directories for each.
- **Non-agent variant:** if you don't need a full agent loop, skip `AIAgent` entirely and have the handler post a fixed notification directly via `httpx`. Cheaper, faster, and has no provider dependency.
#### Why this isn't a built-in
An earlier version of Hermes shipped this as a built-in hook and silently spawned an agent with bare defaults on every gateway boot. That surprised users with custom endpoints and made the feature invisible to users who didn't know it was running. Keeping it as a documented pattern — built by you, in your hooks directory — means you see exactly what it does and opt in by writing the files.
### How It Works
1. On gateway startup, `HookRegistry.discover_and_load()` scans `~/.hermes/hooks/`

View File

@@ -41,6 +41,7 @@ display:
| `poseidon` | Ocean-god theme — deep blue and seafoam | `Poseidon Agent` | Deep blue to seafoam gradient. Ocean-themed spinners ("charting currents", "sounding the depth"). Trident ASCII art banner. |
| `sisyphus` | Sisyphean theme — austere grayscale with persistence | `Sisyphus Agent` | Light grays with stark contrast. Boulder-themed spinners ("pushing uphill", "resetting the boulder", "enduring the loop"). Boulder-and-hill ASCII art banner. |
| `charizard` | Volcanic theme — burnt orange and ember | `Charizard Agent` | Warm burnt orange to ember gradient. Fire-themed spinners ("banking into the draft", "measuring burn"). Dragon-silhouette ASCII art banner. |
| `bunnny` | Barbie-pink coquette theme — sparkles, bows, and bubblegum | `Hermes Agent` | Hot pink (`#E91E63`) borders with Barbie-pink (`#FF69B4`) accents and lavender-blush text. Coquette spinner verbs ("sparkling", "twirling", "tying a little bow"). Heart (♡) prompt symbol, sparkle-kaomoji greeting, twin-bunny hero art. |
## Complete list of configurable keys
@@ -95,7 +96,7 @@ Text strings used throughout the CLI interface.
| `welcome` | Welcome message shown at CLI startup | `Welcome to Hermes Agent! Type your message or /help for commands.` |
| `goodbye` | Message shown on exit | `Goodbye! ⚕` |
| `response_label` | Label on the response box header | ` ⚕ Hermes ` |
| `prompt_symbol` | Symbol before the user input prompt | ` ` |
| `prompt_symbol` | Symbol before the user input prompt (bare token, renderers add a trailing space) | `` |
| `help_header` | Header text for the `/help` command output | `(^_^)? Available Commands` |
### Other top-level keys
@@ -167,7 +168,7 @@ branding:
welcome: "Welcome to My Agent! Type your message or /help for commands."
goodbye: "See you later! ⚡"
response_label: " ⚡ My Agent "
prompt_symbol: "⚡ "
prompt_symbol: "⚡"
help_header: "(⚡) Available Commands"
tool_prefix: "┊"