/ AGENTS.md
AGENTS.md
  1  # Hermes Agent - Development Guide
  2  
  3  Instructions for AI coding assistants and developers working on the hermes-agent codebase.
  4  
  5  ## Development Environment
  6  
  7  ```bash
  8  # Prefer .venv; fall back to venv if that's what your checkout has.
  9  source .venv/bin/activate   # or: source venv/bin/activate
 10  ```
 11  
 12  `scripts/run_tests.sh` probes `.venv` first, then `venv`, then
 13  `$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the
 14  main checkout).
 15  
 16  ## Project Structure
 17  
 18  File counts shift constantly — don't treat the tree below as exhaustive.
 19  The canonical source is the filesystem. The notes call out the load-bearing
 20  entry points you'll actually edit.
 21  
 22  ```
 23  hermes-agent/
 24  ├── run_agent.py          # AIAgent class — core conversation loop (~12k LOC)
 25  ├── model_tools.py        # Tool orchestration, discover_builtin_tools(), handle_function_call()
 26  ├── toolsets.py           # Toolset definitions, _HERMES_CORE_TOOLS list
 27  ├── cli.py                # HermesCLI class — interactive CLI orchestrator (~11k LOC)
 28  ├── hermes_state.py       # SessionDB — SQLite session store (FTS5 search)
 29  ├── hermes_constants.py   # get_hermes_home(), display_hermes_home() — profile-aware paths
 30  ├── hermes_logging.py     # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
 31  ├── batch_runner.py       # Parallel batch processing
 32  ├── agent/                # Agent internals (provider adapters, memory, caching, compression, etc.)
 33  ├── hermes_cli/           # CLI subcommands, setup wizard, plugins loader, skin engine
 34  ├── tools/                # Tool implementations — auto-discovered via tools/registry.py
 35  │   └── environments/     # Terminal backends (local, docker, ssh, modal, daytona, singularity)
 36  ├── gateway/              # Messaging gateway — run.py + session.py + platforms/
 37  │   ├── platforms/        # Adapter per platform (telegram, discord, slack, whatsapp,
 38  │   │                     #   homeassistant, signal, matrix, mattermost, email, sms,
 39  │   │                     #   dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
 40  │   │                     #   webhook, api_server, ...). See ADDING_A_PLATFORM.md.
 41  │   └── builtin_hooks/    # Extension point for always-registered gateway hooks (none shipped)
 42  ├── plugins/              # Plugin system (see "Plugins" section below)
 43  │   ├── memory/           # Memory-provider plugins (honcho, mem0, supermemory, ...)
 44  │   ├── context_engine/   # Context-engine plugins
 45  │   └── <others>/         # Dashboard, image-gen, disk-cleanup, examples, ...
 46  ├── optional-skills/      # Heavier/niche skills shipped but NOT active by default
 47  ├── skills/               # Built-in skills bundled with the repo
 48  ├── ui-tui/               # Ink (React) terminal UI — `hermes --tui`
 49  │   └── src/              # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib
 50  ├── tui_gateway/          # Python JSON-RPC backend for the TUI
 51  ├── acp_adapter/          # ACP server (VS Code / Zed / JetBrains integration)
 52  ├── cron/                 # Scheduler — jobs.py, scheduler.py
 53  ├── environments/         # RL training environments (Atropos)
 54  ├── scripts/              # run_tests.sh, release.py, auxiliary scripts
 55  ├── website/              # Docusaurus docs site
 56  └── tests/                # Pytest suite (~15k tests across ~700 files as of Apr 2026)
 57  ```
 58  
 59  **User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
 60  **Logs:** `~/.hermes/logs/` — `agent.log` (INFO+), `errors.log` (WARNING+),
 61  `gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
 62  Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
 63  
 64  ## File Dependency Chain
 65  
 66  ```
 67  tools/registry.py  (no deps — imported by all tool files)
 68 69  tools/*.py  (each calls registry.register() at import time)
 70 71  model_tools.py  (imports tools/registry + triggers tool discovery)
 72 73  run_agent.py, cli.py, batch_runner.py, environments/
 74  ```
 75  
 76  ---
 77  
 78  ## AIAgent Class (run_agent.py)
 79  
 80  The real `AIAgent.__init__` takes ~60 parameters (credentials, routing, callbacks,
 81  session context, budget, credential pool, etc.). The signature below is the
 82  minimum subset you'll usually touch — read `run_agent.py` for the full list.
 83  
 84  ```python
 85  class AIAgent:
 86      def __init__(self,
 87          base_url: str = None,
 88          api_key: str = None,
 89          provider: str = None,
 90          api_mode: str = None,              # "chat_completions" | "codex_responses" | ...
 91          model: str = "",                   # empty → resolved from config/provider later
 92          max_iterations: int = 90,          # tool-calling iterations (shared with subagents)
 93          enabled_toolsets: list = None,
 94          disabled_toolsets: list = None,
 95          quiet_mode: bool = False,
 96          save_trajectories: bool = False,
 97          platform: str = None,              # "cli", "telegram", etc.
 98          session_id: str = None,
 99          skip_context_files: bool = False,
100          skip_memory: bool = False,
101          credential_pool=None,
102          # ... plus callbacks, thread/user/chat IDs, iteration_budget, fallback_model,
103          # checkpoints config, prefill_messages, service_tier, reasoning_config, etc.
104      ): ...
105  
106      def chat(self, message: str) -> str:
107          """Simple interface — returns final response string."""
108  
109      def run_conversation(self, user_message: str, system_message: str = None,
110                           conversation_history: list = None, task_id: str = None) -> dict:
111          """Full interface — returns dict with final_response + messages."""
112  ```
113  
114  ### Agent Loop
115  
116  The core loop is inside `run_conversation()` — entirely synchronous, with
117  interrupt checks, budget tracking, and a one-turn grace call:
118  
119  ```python
120  while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
121          or self._budget_grace_call:
122      if self._interrupt_requested: break
123      response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
124      if response.tool_calls:
125          for tool_call in response.tool_calls:
126              result = handle_function_call(tool_call.name, tool_call.args, task_id)
127              messages.append(tool_result_message(result))
128          api_call_count += 1
129      else:
130          return response.content
131  ```
132  
133  Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`.
134  Reasoning content is stored in `assistant_msg["reasoning"]`.
135  
136  ---
137  
138  ## CLI Architecture (cli.py)
139  
140  - **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete
141  - **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
142  - `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
143  - **Skin engine** (`hermes_cli/skin_engine.py`) — data-driven CLI theming; initialized from `display.skin` config key at startup; skins customize banner colors, spinner faces/verbs/wings, tool prefix, response box, branding text
144  - `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry
145  - Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching
146  
147  ### Slash Command Registry (`hermes_cli/commands.py`)
148  
149  All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically:
150  
151  - **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name
152  - **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch
153  - **Gateway help** — `gateway_help_lines()` generates `/help` output
154  - **Telegram** — `telegram_bot_commands()` generates the BotCommand menu
155  - **Slack** — `slack_subcommand_map()` generates `/hermes` subcommand routing
156  - **Autocomplete** — `COMMANDS` flat dict feeds `SlashCommandCompleter`
157  - **CLI help** — `COMMANDS_BY_CATEGORY` dict feeds `show_help()`
158  
159  ### Adding a Slash Command
160  
161  1. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
162  ```python
163  CommandDef("mycommand", "Description of what it does", "Session",
164             aliases=("mc",), args_hint="[arg]"),
165  ```
166  2. Add handler in `HermesCLI.process_command()` in `cli.py`:
167  ```python
168  elif canonical == "mycommand":
169      self._handle_mycommand(cmd_original)
170  ```
171  3. If the command is available in the gateway, add a handler in `gateway/run.py`:
172  ```python
173  if canonical == "mycommand":
174      return await self._handle_mycommand(event)
175  ```
176  4. For persistent settings, use `save_config_value()` in `cli.py`
177  
178  **CommandDef fields:**
179  - `name` — canonical name without slash (e.g. `"background"`)
180  - `description` — human-readable description
181  - `category` — one of `"Session"`, `"Configuration"`, `"Tools & Skills"`, `"Info"`, `"Exit"`
182  - `aliases` — tuple of alternative names (e.g. `("bg",)`)
183  - `args_hint` — argument placeholder shown in help (e.g. `"<prompt>"`, `"[name]"`)
184  - `cli_only` — only available in the interactive CLI
185  - `gateway_only` — only available in messaging platforms
186  - `gateway_config_gate` — config dotpath (e.g. `"display.tool_progress_command"`); when set on a `cli_only` command, the command becomes available in the gateway if the config value is truthy. `GATEWAY_KNOWN_COMMANDS` always includes config-gated commands so the gateway can dispatch them; help/menus only show them when the gate is open.
187  
188  **Adding an alias** requires only adding it to the `aliases` tuple on the existing `CommandDef`. No other file changes needed — dispatch, help text, Telegram menu, Slack mapping, and autocomplete all update automatically.
189  
190  ---
191  
192  ## TUI Architecture (ui-tui + tui_gateway)
193  
194  The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`.
195  
196  ### Process Model
197  
198  ```
199  hermes --tui
200    └─ Node (Ink)  ──stdio JSON-RPC──  Python (tui_gateway)
201         │                                  └─ AIAgent + tools + sessions
202         └─ renders transcript, composer, prompts, activity
203  ```
204  
205  TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic.
206  
207  ### Transport
208  
209  Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog.
210  
211  ### Key Surfaces
212  
213  | Surface | Ink component | Gateway method |
214  |---------|---------------|----------------|
215  | Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` |
216  | Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
217  | Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` |
218  | Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
219  | Session picker | `sessionPicker.tsx` | `session.list/resume` |
220  | Slash commands | Local handler + fallthrough | `slash.exec` → `_SlashWorker`, `command.dispatch` |
221  | Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
222  | Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
223  
224  ### Slash Command Flow
225  
226  1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx`
227  2. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback
228  
229  ### Dev Commands
230  
231  ```bash
232  cd ui-tui
233  npm install       # first time
234  npm run dev       # watch mode (rebuilds hermes-ink + tsx --watch)
235  npm start         # production
236  npm run build     # full build (hermes-ink + tsc)
237  npm run type-check # typecheck only (tsc --noEmit)
238  npm run lint      # eslint
239  npm run fmt       # prettier
240  npm test          # vitest
241  ```
242  
243  ### TUI in the Dashboard (`hermes dashboard` → `/chat`)
244  
245  The dashboard embeds the real `hermes --tui` — **not** a rewrite.  See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`.
246  
247  - Browser loads `web/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths.
248  - `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST, via query param (browsers can't set `Authorization` on WS upgrade).
249  - The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not).
250  - Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:<cols>;<rows>]` intercepted on the server and applied with `TIOCSWINSZ`.
251  
252  **Do not re-implement the primary chat experience in React.** The main transcript, composer/input flow (including slash-command behavior), and PTY-backed terminal belong to the embedded `hermes --tui` — anything new you add to Ink shows up in the dashboard automatically. If you find yourself rebuilding the transcript or composer for the dashboard, stop and extend Ink instead.
253  
254  **Structured React UI around the TUI is allowed when it is not a second chat surface.** Sidebar widgets, inspectors, summaries, status panels, and similar supporting views (e.g. `ChatSidebar`, `ModelPickerDialog`, `ToolCall`) are fine when they complement the embedded TUI rather than replacing the transcript / composer / terminal. Keep their state independent of the PTY child's session and surface their failures non-destructively so the terminal pane keeps working unimpaired.
255  
256  ---
257  
258  ## Adding New Tools
259  
260  For most custom or local-only tools, do **not** edit Hermes core. Use the plugin
261  route instead: create `~/.hermes/plugins/<name>/plugin.yaml` and
262  `~/.hermes/plugins/<name>/__init__.py`, then register tools with
263  `ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
264  enabled or disabled without touching `tools/` or `toolsets.py`.
265  
266  Use the built-in route below only when the user is explicitly contributing a new
267  core Hermes tool that should ship in the base system.
268  
269  Built-in/core tools require changes in **2 files**:
270  
271  **1. Create `tools/your_tool.py`:**
272  ```python
273  import json, os
274  from tools.registry import registry
275  
276  def check_requirements() -> bool:
277      return bool(os.getenv("EXAMPLE_API_KEY"))
278  
279  def example_tool(param: str, task_id: str = None) -> str:
280      return json.dumps({"success": True, "data": "..."})
281  
282  registry.register(
283      name="example_tool",
284      toolset="example",
285      schema={"name": "example_tool", "description": "...", "parameters": {...}},
286      handler=lambda args, **kw: example_tool(param=args.get("param", ""), task_id=kw.get("task_id")),
287      check_fn=check_requirements,
288      requires_env=["EXAMPLE_API_KEY"],
289  )
290  ```
291  
292  **2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset.
293  
294  Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain.
295  
296  The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
297  
298  **Path references in tool schemas**: If the schema description mentions file paths (e.g. default output directories), use `display_hermes_home()` to make them profile-aware. The schema is generated at import time, which is after `_apply_profile_override()` sets `HERMES_HOME`.
299  
300  **State files**: If a tool stores persistent state (caches, logs, checkpoints), use `get_hermes_home()` for the base directory — never `Path.home() / ".hermes"`. This ensures each profile gets its own state.
301  
302  **Agent-level tools** (todo, memory): intercepted by `run_agent.py` before `handle_function_call()`. See `tools/todo_tool.py` for the pattern.
303  
304  ---
305  
306  ## Adding Configuration
307  
308  ### config.yaml options:
309  1. Add to `DEFAULT_CONFIG` in `hermes_cli/config.py`
310  2. Bump `_config_version` (check the current value at the top of `DEFAULT_CONFIG`)
311     ONLY if you need to actively migrate/transform existing user config
312     (renaming keys, changing structure). Adding a new key to an existing
313     section is handled automatically by the deep-merge and does NOT require
314     a version bump.
315  
316  ### .env variables (SECRETS ONLY — API keys, tokens, passwords):
317  1. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
318  ```python
319  "NEW_API_KEY": {
320      "description": "What it's for",
321      "prompt": "Display name",
322      "url": "https://...",
323      "password": True,
324      "category": "tool",  # provider, tool, messaging, setting
325  },
326  ```
327  
328  Non-secret settings (timeouts, thresholds, feature flags, paths, display
329  preferences) belong in `config.yaml`, not `.env`. If internal code needs an
330  env var mirror for backward compatibility, bridge it from `config.yaml` to
331  the env var in code (see `gateway_timeout`, `terminal.cwd` → `TERMINAL_CWD`).
332  
333  ### Config loaders (three paths — know which one you're in):
334  
335  | Loader | Used by | Location |
336  |--------|---------|----------|
337  | `load_cli_config()` | CLI mode | `cli.py` — merges CLI-specific defaults + user YAML |
338  | `load_config()` | `hermes tools`, `hermes setup`, most CLI subcommands | `hermes_cli/config.py` — merges `DEFAULT_CONFIG` + user YAML |
339  | Direct YAML load | Gateway runtime | `gateway/run.py` + `gateway/config.py` — reads user YAML raw |
340  
341  If you add a new key and the CLI sees it but the gateway doesn't (or vice
342  versa), you're on the wrong loader. Check `DEFAULT_CONFIG` coverage.
343  
344  ### Working directory:
345  - **CLI** — uses the process's current directory (`os.getcwd()`).
346  - **Messaging** — uses `terminal.cwd` from `config.yaml`. The gateway bridges this
347    to the `TERMINAL_CWD` env var for child tools. **`MESSAGING_CWD` has been
348    removed** — the config loader prints a deprecation warning if it's set in
349    `.env`. Same for `TERMINAL_CWD` in `.env`; the canonical setting is
350    `terminal.cwd` in `config.yaml`.
351  
352  ---
353  
354  ## Skin/Theme System
355  
356  The skin engine (`hermes_cli/skin_engine.py`) provides data-driven CLI visual customization. Skins are **pure data** — no code changes needed to add a new skin.
357  
358  ### Architecture
359  
360  ```
361  hermes_cli/skin_engine.py    # SkinConfig dataclass, built-in skins, YAML loader
362  ~/.hermes/skins/*.yaml       # User-installed custom skins (drop-in)
363  ```
364  
365  - `init_skin_from_config()` — called at CLI startup, reads `display.skin` from config
366  - `get_active_skin()` — returns cached `SkinConfig` for the current skin
367  - `set_active_skin(name)` — switches skin at runtime (used by `/skin` command)
368  - `load_skin(name)` — loads from user skins first, then built-ins, then falls back to default
369  - Missing skin values inherit from the `default` skin automatically
370  
371  ### What skins customize
372  
373  | Element | Skin Key | Used By |
374  |---------|----------|---------|
375  | Banner panel border | `colors.banner_border` | `banner.py` |
376  | Banner panel title | `colors.banner_title` | `banner.py` |
377  | Banner section headers | `colors.banner_accent` | `banner.py` |
378  | Banner dim text | `colors.banner_dim` | `banner.py` |
379  | Banner body text | `colors.banner_text` | `banner.py` |
380  | Response box border | `colors.response_border` | `cli.py` |
381  | Spinner faces (waiting) | `spinner.waiting_faces` | `display.py` |
382  | Spinner faces (thinking) | `spinner.thinking_faces` | `display.py` |
383  | Spinner verbs | `spinner.thinking_verbs` | `display.py` |
384  | Spinner wings (optional) | `spinner.wings` | `display.py` |
385  | Tool output prefix | `tool_prefix` | `display.py` |
386  | Per-tool emojis | `tool_emojis` | `display.py` → `get_tool_emoji()` |
387  | Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
388  | Welcome message | `branding.welcome` | `cli.py` |
389  | Response box label | `branding.response_label` | `cli.py` |
390  | Prompt symbol | `branding.prompt_symbol` | `cli.py` |
391  
392  ### Built-in skins
393  
394  - `default` — Classic Hermes gold/kawaii (the current look)
395  - `ares` — Crimson/bronze war-god theme with custom spinner wings
396  - `mono` — Clean grayscale monochrome
397  - `slate` — Cool blue developer-focused theme
398  
399  ### Adding a built-in skin
400  
401  Add to `_BUILTIN_SKINS` dict in `hermes_cli/skin_engine.py`:
402  
403  ```python
404  "mytheme": {
405      "name": "mytheme",
406      "description": "Short description",
407      "colors": { ... },
408      "spinner": { ... },
409      "branding": { ... },
410      "tool_prefix": "┊",
411  },
412  ```
413  
414  ### User skins (YAML)
415  
416  Users create `~/.hermes/skins/<name>.yaml`:
417  
418  ```yaml
419  name: cyberpunk
420  description: Neon-soaked terminal theme
421  
422  colors:
423    banner_border: "#FF00FF"
424    banner_title: "#00FFFF"
425    banner_accent: "#FF1493"
426  
427  spinner:
428    thinking_verbs: ["jacking in", "decrypting", "uploading"]
429    wings:
430      - ["⟨⚡", "⚡⟩"]
431  
432  branding:
433    agent_name: "Cyber Agent"
434    response_label: " ⚡ Cyber "
435  
436  tool_prefix: "▏"
437  ```
438  
439  Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
440  
441  ---
442  
443  ## Plugins
444  
445  Hermes has two plugin surfaces. Both live under `plugins/` in the repo so
446  repo-shipped plugins can be discovered alongside user-installed ones in
447  `~/.hermes/plugins/` and pip-installed entry points.
448  
449  ### General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
450  
451  `PluginManager` discovers plugins from `~/.hermes/plugins/`, `./.hermes/plugins/`,
452  and pip entry points. Each plugin exposes a `register(ctx)` function that
453  can:
454  
455  - Register Python-callback lifecycle hooks:
456    `pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`,
457    `on_session_start`, `on_session_end`
458  - Register new tools via `ctx.register_tool(...)`
459  - Register CLI subcommands via `ctx.register_cli_command(...)` — the
460    plugin's argparse tree is wired into `hermes` at startup so
461    `hermes <pluginname> <subcmd>` works with no change to `main.py`
462  
463  Hooks are invoked from `model_tools.py` (pre/post tool) and `run_agent.py`
464  (lifecycle). **Discovery timing pitfall:** `discover_plugins()` only runs
465  as a side effect of importing `model_tools.py`. Code paths that read plugin
466  state without importing `model_tools.py` first must call `discover_plugins()`
467  explicitly (it's idempotent).
468  
469  ### Memory-provider plugins (`plugins/memory/<name>/`)
470  
471  Separate discovery system for pluggable memory backends. Current built-in
472  providers include **honcho, mem0, supermemory, byterover, hindsight,
473  holographic, openviking, retaindb**.
474  
475  Each provider implements the `MemoryProvider` ABC (see `agent/memory_provider.py`)
476  and is orchestrated by `agent/memory_manager.py`. Lifecycle hooks include
477  `sync_turn(turn_messages)`, `prefetch(query)`, `shutdown()`, and optional
478  `post_setup(hermes_home, config)` for setup-wizard integration.
479  
480  **CLI commands via `plugins/memory/<name>/cli.py`:** if a memory plugin
481  defines `register_cli(subparser)`, `discover_plugin_cli_commands()` finds
482  it at argparse setup time and wires it into `hermes <plugin>`. The
483  framework only exposes CLI commands for the **currently active** memory
484  provider (read from `memory.provider` in config.yaml), so disabled
485  providers don't clutter `hermes --help`.
486  
487  **Rule (Teknium, May 2026):** plugins MUST NOT modify core files
488  (`run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.).
489  If a plugin needs a capability the framework doesn't expose, expand the
490  generic plugin surface (new hook, new ctx method) — never hardcode
491  plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
492  honcho argparse from `main.py` for exactly this reason.
493  
494  ### Dashboard / context-engine / image-gen plugin directories
495  
496  `plugins/context_engine/`, `plugins/image_gen/`, `plugins/example-dashboard/`,
497  etc. follow the same pattern (ABC + orchestrator + per-plugin directory).
498  Context engines plug into `agent/context_engine.py`; image-gen providers
499  into `agent/image_gen_provider.py`.
500  
501  ---
502  
503  ## Skills
504  
505  Two parallel surfaces:
506  
507  - **`skills/`** — built-in skills shipped and loadable by default.
508    Organized by category directories (e.g. `skills/github/`, `skills/mlops/`).
509  - **`optional-skills/`** — heavier or niche skills shipped with the repo but
510    NOT active by default. Installed explicitly via
511    `hermes skills install official/<category>/<skill>`. Adapter lives in
512    `tools/skills_hub.py` (`OptionalSkillSource`). Categories include
513    `autonomous-ai-agents`, `blockchain`, `communication`, `creative`,
514    `devops`, `email`, `health`, `mcp`, `migration`, `mlops`, `productivity`,
515    `research`, `security`, `web-development`.
516  
517  When reviewing skill PRs, check which directory they target — heavy-dep or
518  niche skills belong in `optional-skills/`.
519  
520  ### SKILL.md frontmatter
521  
522  Standard fields: `name`, `description`, `version`, `platforms`
523  (OS-gating list: `[macos]`, `[linux, macos]`, ...),
524  `metadata.hermes.tags`, `metadata.hermes.category`,
525  `metadata.hermes.config` (config.yaml settings the skill needs — stored
526  under `skills.config.<key>`, prompted during setup, injected at load time).
527  
528  ---
529  
530  ## Important Policies
531  
532  ### Prompt Caching Must Not Break
533  
534  Hermes-Agent ensures caching remains valid throughout a conversation. **Do NOT implement changes that would:**
535  - Alter past context mid-conversation
536  - Change toolsets mid-conversation
537  - Reload memories or rebuild system prompts mid-conversation
538  
539  Cache-breaking forces dramatically higher costs. The ONLY time we alter context is during context compression.
540  
541  Slash commands that mutate system-prompt state (skills, tools, memory, etc.)
542  must be **cache-aware**: default to deferred invalidation (change takes
543  effect next session), with an opt-in `--now` flag for immediate
544  invalidation. See `/skills install --now` for the canonical pattern.
545  
546  ### Background Process Notifications (Gateway)
547  
548  When `terminal(background=true, notify_on_complete=true)` is used, the gateway runs a watcher that
549  detects process completion and triggers a new agent turn. Control verbosity of background process
550  messages with `display.background_process_notifications`
551  in config.yaml (or `HERMES_BACKGROUND_NOTIFICATIONS` env var):
552  
553  - `all` — running-output updates + final message (default)
554  - `result` — only the final completion message
555  - `error` — only the final message when exit code != 0
556  - `off` — no watcher messages at all
557  
558  ---
559  
560  ## Profiles: Multi-Instance Support
561  
562  Hermes supports **profiles** — multiple fully isolated instances, each with its own
563  `HERMES_HOME` directory (config, API keys, memory, sessions, skills, gateway, etc.).
564  
565  The core mechanism: `_apply_profile_override()` in `hermes_cli/main.py` sets
566  `HERMES_HOME` before any module imports. All `get_hermes_home()` references
567  automatically scope to the active profile.
568  
569  ### Rules for profile-safe code
570  
571  1. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`.
572     NEVER hardcode `~/.hermes` or `Path.home() / ".hermes"` in code that reads/writes state.
573     ```python
574     # GOOD
575     from hermes_constants import get_hermes_home
576     config_path = get_hermes_home() / "config.yaml"
577  
578     # BAD — breaks profiles
579     config_path = Path.home() / ".hermes" / "config.yaml"
580     ```
581  
582  2. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`.
583     This returns `~/.hermes` for default or `~/.hermes/profiles/<name>` for profiles.
584     ```python
585     # GOOD
586     from hermes_constants import display_hermes_home
587     print(f"Config saved to {display_hermes_home()}/config.yaml")
588  
589     # BAD — shows wrong path for profiles
590     print("Config saved to ~/.hermes/config.yaml")
591     ```
592  
593  3. **Module-level constants are fine** — they cache `get_hermes_home()` at import time,
594     which is AFTER `_apply_profile_override()` sets the env var. Just use `get_hermes_home()`,
595     not `Path.home() / ".hermes"`.
596  
597  4. **Tests that mock `Path.home()` must also set `HERMES_HOME`** — since code now uses
598     `get_hermes_home()` (reads env var), not `Path.home() / ".hermes"`:
599     ```python
600     with patch.object(Path, "home", return_value=tmp_path), \
601          patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
602         ...
603     ```
604  
605  5. **Gateway platform adapters should use token locks** — if the adapter connects with
606     a unique credential (bot token, API key), call `acquire_scoped_lock()` from
607     `gateway.status` in the `connect()`/`start()` method and `release_scoped_lock()` in
608     `disconnect()`/`stop()`. This prevents two profiles from using the same credential.
609     See `gateway/platforms/telegram.py` for the canonical pattern.
610  
611  6. **Profile operations are HOME-anchored, not HERMES_HOME-anchored** — `_get_profiles_root()`
612     returns `Path.home() / ".hermes" / "profiles"`, NOT `get_hermes_home() / "profiles"`.
613     This is intentional — it lets `hermes -p coder profile list` see all profiles regardless
614     of which one is active.
615  
616  ## Known Pitfalls
617  
618  ### DO NOT hardcode `~/.hermes` paths
619  Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_hermes_home()`
620  for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile
621  has its own `HERMES_HOME` directory. This was the source of 5 bugs fixed in PR #3575.
622  
623  ### DO NOT introduce new `simple_term_menu` usage
624  Existing call sites in `hermes_cli/main.py` remain for legacy fallback only;
625  the preferred UI is curses (stdlib) because `simple_term_menu` has
626  ghost-duplication rendering bugs in tmux/iTerm2 with arrow keys. New
627  interactive menus must use `hermes_cli/curses_ui.py` — see
628  `hermes_cli/tools_config.py` for the canonical pattern.
629  
630  ### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code
631  Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`.
632  
633  ### `_last_resolved_tool_names` is a process-global in `model_tools.py`
634  `_run_single_child()` in `delegate_tool.py` saves and restores this global around subagent execution. If you add new code that reads this global, be aware it may be temporarily stale during child agent runs.
635  
636  ### DO NOT hardcode cross-tool references in schema descriptions
637  Tool schema descriptions must not mention tools from other toolsets by name (e.g., `browser_navigate` saying "prefer web_search"). Those tools may be unavailable (missing API keys, disabled toolset), causing the model to hallucinate calls to non-existent tools. If a cross-reference is needed, add it dynamically in `get_tool_definitions()` in `model_tools.py` — see the `browser_navigate` / `execute_code` post-processing blocks for the pattern.
638  
639  ### The gateway has TWO message guards — both must bypass approval/control commands
640  When an agent is running, messages pass through two sequential guards:
641  (1) **base adapter** (`gateway/platforms/base.py`) queues messages in
642  `_pending_messages` when `session_key in self._active_sessions`, and
643  (2) **gateway runner** (`gateway/run.py`) intercepts `/stop`, `/new`,
644  `/queue`, `/status`, `/approve`, `/deny` before they reach
645  `running_agent.interrupt()`. Any new command that must reach the runner
646  while the agent is blocked (e.g. approval prompts) MUST bypass BOTH
647  guards and be dispatched inline, not via `_process_message_background()`
648  (which races session lifecycle).
649  
650  ### Squash merges from stale branches silently revert recent fixes
651  Before squash-merging a PR, ensure the branch is up to date with `main`
652  (`git fetch origin main && git reset --hard origin/main` in the worktree,
653  then re-apply the PR's commits). A stale branch's version of an unrelated
654  file will silently overwrite recent fixes on main when squashed. Verify
655  with `git diff HEAD~1..HEAD` after merging — unexpected deletions are a
656  red flag.
657  
658  ### Don't wire in dead code without E2E validation
659  Unused code that was never shipped was dead for a reason. Before wiring an
660  unused module into a live code path, E2E test the real resolution chain
661  with actual imports (not mocks) against a temp `HERMES_HOME`.
662  
663  ### Tests must not write to `~/.hermes/`
664  The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests.
665  
666  **Profile tests**: When testing profile features, also mock `Path.home()` so that
667  `_get_profiles_root()` and `_get_default_hermes_home()` resolve within the temp dir.
668  Use the pattern from `tests/hermes_cli/test_profiles.py`:
669  ```python
670  @pytest.fixture
671  def profile_env(tmp_path, monkeypatch):
672      home = tmp_path / ".hermes"
673      home.mkdir()
674      monkeypatch.setattr(Path, "home", lambda: tmp_path)
675      monkeypatch.setenv("HERMES_HOME", str(home))
676      return home
677  ```
678  
679  ---
680  
681  ## Testing
682  
683  **ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
684  hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
685  4 xdist workers matching GHA ubuntu-latest). Direct `pytest` on a 16+ core
686  developer machine with API keys set diverges from CI in ways that have caused
687  multiple "works locally, fails in CI" incidents (and the reverse).
688  
689  ```bash
690  scripts/run_tests.sh                                  # full suite, CI-parity
691  scripts/run_tests.sh tests/gateway/                   # one directory
692  scripts/run_tests.sh tests/agent/test_foo.py::test_x  # one test
693  scripts/run_tests.sh -v --tb=long                     # pass-through pytest flags
694  ```
695  
696  ### Why the wrapper (and why the old "just call pytest" doesn't work)
697  
698  Five real sources of local-vs-CI drift the script closes:
699  
700  | | Without wrapper | With wrapper |
701  |---|---|---|
702  | Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. unset |
703  | HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
704  | Timezone | Local TZ (PDT etc.) | UTC |
705  | Locale | Whatever is set | C.UTF-8 |
706  | xdist workers | `-n auto` = all cores (20+ on a workstation) | `-n 4` matching CI |
707  
708  `tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
709  invocation (including IDE integrations) gets hermetic behavior — but the wrapper
710  is belt-and-suspenders.
711  
712  ### Running without the wrapper (only if you must)
713  
714  If you can't use the wrapper (e.g. on Windows or inside an IDE that shells
715  pytest directly), at minimum activate the venv and pass `-n 4`:
716  
717  ```bash
718  source .venv/bin/activate   # or: source venv/bin/activate
719  python -m pytest tests/ -q -n 4
720  ```
721  
722  Worker count above 4 will surface test-ordering flakes that CI never sees.
723  
724  Always run the full suite before pushing changes.
725  
726  ### Don't write change-detector tests
727  
728  A test is a **change-detector** if it fails whenever data that is **expected
729  to change** gets updated — model catalogs, config version numbers,
730  enumeration counts, hardcoded lists of provider models. These tests add no
731  behavioral coverage; they just guarantee that routine source updates break
732  CI and cost engineering time to "fix."
733  
734  **Do not write:**
735  
736  ```python
737  # catalog snapshot — breaks every model release
738  assert "gemini-2.5-pro" in _PROVIDER_MODELS["gemini"]
739  assert "MiniMax-M2.7" in models
740  
741  # config version literal — breaks every schema bump
742  assert DEFAULT_CONFIG["_config_version"] == 21
743  
744  # enumeration count — breaks every time a skill/provider is added
745  assert len(_PROVIDER_MODELS["huggingface"]) == 8
746  ```
747  
748  **Do write:**
749  
750  ```python
751  # behavior: does the catalog plumbing work at all?
752  assert "gemini" in _PROVIDER_MODELS
753  assert len(_PROVIDER_MODELS["gemini"]) >= 1
754  
755  # behavior: does migration bump the user's version to current latest?
756  assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
757  
758  # invariant: no plan-only model leaks into the legacy list
759  assert not (set(moonshot_models) & coding_plan_only_models)
760  
761  # invariant: every model in the catalog has a context-length entry
762  for m in _PROVIDER_MODELS["huggingface"]:
763      assert m.lower() in DEFAULT_CONTEXT_LENGTHS_LOWER
764  ```
765  
766  The rule: if the test reads like a snapshot of current data, delete it. If
767  it reads like a contract about how two pieces of data must relate, keep it.
768  When a PR adds a new provider/model and you want a test, make the test
769  assert the relationship (e.g. "catalog entries all have context lengths"),
770  not the specific names.
771  
772  Reviewers should reject new change-detector tests; authors should convert
773  them into invariants before re-requesting review.