Two files, one repository
NousResearch/hermes-agent ships 1 format across 2 indexed files. The question worth asking is whether the second one says anything the first does not.
| Dimension | Shared | Only in A | Only in B | Overlap |
|---|---|---|---|---|
| Sections | 0 | 60 | 13 | 0% |
| Commands | 0 | 17 | 0 | 0% |
| Section tags | 3 | 12 | 0 | 20% |
What each file covers
Sections
0 shared · 60 only in A · 13 only in B- − Hermes Agent - Development Guide
- − What Hermes Is
- − Contribution Rubric — What We Want / What We Don't
- − What we want
- − What we don't want (rejected even when well-built)
- − Before you call it a bug — verify the premise (and when NOT to close)
- − The Footprint Ladder (new capability decision)
- − Surface capability is a property of the SESSION, never of the process env
- − Development Environment
- − Prefer .venv; fall back to venv if that's what your checkout has.
- − Project Structure
- − TypeScript Style
- − File Dependency Chain
- − AIAgent Class (run_agent.py)
- − Agent Loop
- − CLI Architecture (cli.py)
- − Slash Command Registry (`hermes_cli/commands.py`)
- − Adding a Slash Command
- − TUI Architecture (ui-tui + tui_gateway)
- − Process Model
- − Transport
- − Key Surfaces
- − Slash Command Flow
- − Dev Commands
- − TUI in the Dashboard (`hermes dashboard` → `/chat`)
- − Electron Desktop Chat App (`apps/desktop/`)
- − Adding New Tools
- − Dependency Pinning Policy
- − Adding Configuration
- − config.yaml options:
- − Top-level `config.yaml` sections (non-exhaustive):
- − .env variables (SECRETS ONLY — API keys, tokens, passwords):
- − Config loaders (three paths — know which one you're in):
- − Working directory:
- − Skin/Theme System
- − Architecture
- − What skins customize
- − Built-in skins
- − Adding a built-in skin
- − User skins (YAML)
- − Plugins
- − General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
- − Memory-provider plugins (`plugins/memory/<name>/`)
- − Model-provider plugins (`plugins/model-providers/<name>/`)
- − Dashboard / context-engine / image-gen plugin directories
- − Skills
- − SKILL.md frontmatter
- − Skill authoring standards (HARDLINE)
- − Toolsets
- − Delegation (`delegate_task`)
- − Curator (skill lifecycle)
- − Cron (scheduled jobs)
- − Kanban (multi-agent work queue)
- − Important Policies
- − Prompt Caching Must Not Break
- − Background Process Notifications (Gateway)
- − Profiles: Multi-Instance Support
- − Rules for profile-safe code
- − Known Pitfalls
- − DO NOT hardcode `~/.hermes` paths
- + Desktop Engineering Guide
- + What this app is
- + Decide state by authority
- + Identity is not incidental
- + Server truth is cached, not owned
- + Switching context is a re-home, not a reboot
- + Cross everything as an observable ladder
- + Compatibility without carrying the past forever
- + Keep the waist narrow, grow at the edges
- + Respect the person using it
- + Make it feel instant
- + Testing as a habit of proof
- + The taste test before you hand off
Commands
0 shared · 17 only in A · 0 only in B- − npm install
- − npm run dev
- − npm start
- − npm run build
- − npm run typecheck
- − npm run lint
- − npm run fmt
- − npm test
- − git log -p -S
- − git log -p -S "<symbol>"
- − npx vitest run src/lib/desktop-slash-commands.test.ts
- − git+https://...@<40-char-sha>
- − uv.lock
- − git fetch origin main && git reset --hard origin/main
- − git diff HEAD~1..HEAD
- − pytest
- − pytest.skip()
Section tags
3 shared · 12 only in A · 0 only in B- − setup
- − build
- − lint-format
- − code-style
- − architecture
- − types
- − testing-strategy
- − api
- − ui
- − performance
- − monorepo
- − do-not
- test
- security
- agent-behaviour
Line diff
NousResearch/hermes-agent · AGENTS.md
@@ −1 @@
1# Hermes Agent - Development Guide
2
3Instructions for AI coding assistants and developers working on the hermes-agent codebase.
4
5**Never give up on the right solution.**
6
7## What Hermes Is
8
9Hermes is a personal AI agent that runs the same agent core across a CLI, a
10messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI,
11and an Electron desktop app. It learns across sessions (memory + skills),
12delegates to subagents, runs scheduled jobs, and drives a real terminal and
13browser. It is extended primarily through **plugins and skills**, not by
14growing the core.
15
16Two properties shape almost every design decision and are the lens for
17reviewing any change:
18
19- **Per-conversation prompt caching is sacred.** A long-lived conversation
20 reuses a cached prefix every turn. Anything that mutates past context,
21 swaps toolsets, or rebuilds the system prompt mid-conversation invalidates
22 that cache and multiplies the user's cost. We do not do it (the one
23 exception is context compression).
24- **The core is a narrow waist; capability lives at the edges.** Every model
25 tool we add is sent on every API call, so the bar for a new *core* tool is
26 high. Most new capability should arrive as a CLI command + skill, a
27 service-gated tool, or a plugin — not as core surface.
28
29## Contribution Rubric — What We Want / What We Don't
30
31This is the project's intent layer. Use it two ways:
32
331. **For humans and for your own work** — what gets merged and what gets
34 rejected, so a contribution aims at the target.
352. **For automated review (the triage sweeper)** — guidance on when a PR is
36 safe to close on the three allowed reasons (`implemented_on_main`,
37 `cannot_reproduce`, `incoherent`) and, just as important, **when NOT to
38 close** one. Taste-based "we don't want this / out of scope" closes are NOT
39 an automated decision — those stay with a human maintainer. The sweeper's
40 job here is to recognize design intent and *avoid wrongly closing a
41 legitimate contribution*, not to make the won't-implement call itself.
42
43Read the balance right: Hermes ships a **lot** — most merges are bug fixes to
44real reported behavior, and the product surface (platforms, channels,
45providers, models, desktop/TUI features) expands aggressively and on purpose.
46The restraint below is aimed squarely at the **core agent + the model tool
47schema**, the one place where every addition is paid for on every API call.
48"Smallest footprint" governs *how a capability is wired into the core*, NOT
49whether the product is allowed to grow. We are expansive at the edges and
50conservative at the waist.
51
52### What we want
53
54- **Fix real bugs, well.** The bulk of what lands is `fix(...)` against an
55 actual reported symptom. A good fix reproduces the symptom on current
56 `main`, points to the exact line where it manifests, and fixes the whole bug
57 class — sibling call paths included — not just the one site the reporter hit.
58- **Expand reach at the edges.** New platform adapters, channels, providers,
59 models, and desktop/TUI/dashboard features are welcome and land routinely,
60 including large ones (a new messaging channel, a session-cap feature, a
61 Windows PTY bridge). Breadth in the product is a goal, not a footprint
62 concern — as long as it integrates with the existing setup/config UX
63 (`hermes tools`, `hermes setup`, auto-install) rather than bolting on a raw
64 env var.
65- **Refactor god-files into clean modules.** Extracting a multi-thousand-line
66 cluster out of `cli.py` / `run_agent.py` / `gateway/run.py` into a focused
67 mixin or module is wanted work, even when the diff is huge and mechanical
68 (large `+N/-N` refactors merge regularly). The "every line traces to the
69 request" test applies to *feature* PRs; a declared refactor's request IS the
70 extraction.
71- **Keep the core narrow.** New *model tools* are the expensive exception —
72 every tool ships on every API call. Prefer, in order: extend existing code →
73 CLI command + skill → service-gated tool (`check_fn`) → plugin → MCP server
74 in the catalog → new core tool (last resort). See "The Footprint Ladder."
75- **Extend, don't duplicate.** Before adding a module/manager/hook, check
76 whether existing infrastructure already covers the use case. When several PRs
77 integrate the same *category*, design one shared interface instead of merging
78 them one at a time (see the ABC + orchestrator note under the Footprint
79 Ladder).
80- **Behavior contracts over snapshots.** Tests should assert how two pieces of
81 data must relate (invariants), not freeze a current value (model lists,
82 config version literals, enumeration counts). See "Don't write
83 change-detector tests."
84- **E2E validation, not just green unit mocks.** For anything touching
85 resolution chains, config propagation, security boundaries, remote
86 backends, or file/network I/O, exercise the real path with real imports
87 against a temp `HERMES_HOME`. Mocks hide integration bugs.
88- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict
89 message role alternation (never two same-role messages in a row; never a
90 synthetic user message injected mid-loop), and a system prompt that is
91 byte-stable for the life of a conversation.
92- **Contributor credit preserved.** Salvage external work by cherry-picking
93 (rebase-merge) so authorship survives in git history; don't reimplement from
94 scratch when you can build on top.
95
96### What we don't want (rejected even when well-built)
97
98- **Speculative infrastructure.** Hooks, callbacks, or extension points with no
99 concrete consumer. Adding a hook is easy; removing one after plugins depend
100 on it is hard. A hook is NOT speculative if a contributor has a real, stated
101 use case — even if the consumer ships separately.
102- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets
103 only (API keys, tokens, passwords). All behavioral settings — timeouts,
104 thresholds, feature flags, display prefs — go in `config.yaml`. Bridge to an
105 internal env var if the mechanism needs one, but user-facing docs point to
106 `config.yaml`. Reject PRs that tell users to "set X in your .env" unless X
107 is a credential.
108- **A new core tool when terminal + file already do the job, or when a skill
109 would.** If the only barrier is file visibility on a remote backend, fix the
110 mount, not the toolset.
111- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit`
112 pagination on tools that load content the agent must read fully (skills,
113 prompts, playbooks). Models will read page 1 and skip the rest.
114- **"Fixes" that destroy the feature they secure.** A mitigation that kills the
115 feature's purpose is the wrong mitigation. Read the original commit's intent
116 (`git log -p -S`) before restricting behavior; find a fix that preserves the
117 feature.
118- **Outbound telemetry / usage attribution without opt-in gating.** No new
119 analytics, third-party identifier tagging, or attribution tags until a
120 generic user-facing opt-in (config gate + setup prompt + `hermes tools`
121 toggle) exists. Park behind a label, do not merge.
122- **Change-detector tests, cache-breaking mid-conversation, dead code wired in
123 without E2E proof, and plugins that touch core files.** Plugins live in their
124 own directory and work within the ABCs/hooks we provide; if a plugin needs
125 more, widen the generic plugin surface, don't special-case it in core.
126- **Third-party products / other people's projects integrated into the core
127 tree.** Observability backends, vendor SaaS integrations, analytics dashboards,
128 and similar "someone else's product" plugins do NOT land under `plugins/` in
129 this repo. They place an ongoing maintenance burden on us to keep them working
130 against a fast-moving core, for a backend we don't own. Ship them as a
131 **standalone plugin repo** users install into `~/.hermes/plugins/` (or via a
132 pip entry point), and promote them in the Nous Research Discord
133 (`#plugins-skills-and-skins`). This is a coupling-and-maintenance decision, not
134 a quality bar — the plugin can be excellent and still be a close. PRs that add
135 such a directory to the tree are closed with a pointer to publish it as its own
136 repo.
137
138### Before you call it a bug — verify the premise (and when NOT to close)
139
140The most common reason a well-written PR gets closed is not code quality — it
141is that the change is built on a **wrong premise**, or it treats an
142**intentional design as a gap**. These patterns cut both ways: they tell a
143human reviewer what to scrutinize, and they tell the automated sweeper when a
144PR is NOT safe to close as `implemented_on_main` / `cannot_reproduce` (when in
145doubt, leave it open for a human). They are distilled from real closes.
146
147- **"Intentional design, not a gap."** A limitation that looks like an
148 oversight is often deliberate. Before "fixing" a missing link or a
149 restriction, ask whether the isolation IS the design. Example: profiles are
150 independent islands on purpose — a PR adding live config inheritance from the
151 default profile was closed because coupling profiles together is exactly what
152 the design prevents (the copy-at-creation `--clone` path already covers the
153 legitimate "start from my default" case). Read the original commit's intent
154 (`git log -p -S "<symbol>"`) before assuming something is unfinished.
155- **"The premise doesn't hold against how X actually works."** A PR's
156 justification frequently rests on a wrong mental model of an existing
157 mechanism. Trace the real code/runtime before accepting the rationale. Two
158 real closes: a rate-limit "re-probe during cooldown" PR (the breaker only
159 trips on a *confirmed-empty* account bucket, so re-probing just hammers a
160 bucket we've already proven empty); a usage-accumulation fix whose new branch
161 **never executes at runtime** because an earlier guard already popped the
162 state it depended on. If you can't point to the exact line where the bug
163 manifests AND show the fix changes that line's behavior, you haven't verified
164 the premise.
165- **"This fix was wrong — the absence/omission was deliberate."** Adding the
166 obvious-looking missing piece can break things the omission was protecting.
167 Example: restoring "missing" `__init__.py` files made a test tree importable
168 as a dotted package that shadowed the real plugin, deleting its `register()`
169 at import time. The absence was load-bearing.
170- **"Overreached / resurrected an approach we'd moved past."** Scope creep that
171 supersedes an agreed-on base, or revives a direction the maintainers
172 deliberately closed, gets rejected even when the code works. Keep the change
173 to the narrow piece that was actually agreed; offer the rest as a focused
174 follow-up.
175
176The throughline: **verify the claim AND the intent against the codebase before
177writing or merging a fix.** A confirmed reproduction on current `main` plus a
178line-level account of where the fix acts beats a plausible-sounding rationale
179every time. When in doubt about intent, it is cheaper to ask than to ship a
180fix that fights the design.
181
182### The Footprint Ladder (new capability decision)
183
184Each rung adds more permanent surface than the one above. Choose the highest
185(least-footprint) rung that correctly solves the problem:
186
1871. **Extend existing code** — the capability is a variation of something that
188 already exists. Zero new surface.
1892. **CLI command + skill** — manages config/state/infra expressible as shell
190 commands. The agent runs `hermes <subcommand>` guided by a skill. Zero
191 model-tool footprint. Default choice for subscriptions, scheduled tasks,
192 service setup. Examples: `hermes webhook`, `hermes cron`, `hermes tools`.
1933. **Service-gated tool (`check_fn`)** — needs structured params/returns AND
194 only appears when a prerequisite is configured. Zero footprint otherwise.
195 Examples: Home Assistant tools (gated on token), memory-provider tools.
1964. **Plugin** — third-party/niche/user-specific capability that doesn't ship in
197 core. Lives in `~/.hermes/plugins/` or a pip package, discovered at runtime.
1985. **MCP server (in the catalog)** — if the capability genuinely needs to be a
199 tool (structured I/O the agent invokes) but isn't core-fundamental, prefer
200 building it as an MCP server and adding it to the MCP catalog over growing
201 the core toolset. The agent connects to it through the built-in MCP client;
202 zero permanent core-schema footprint, and it's reusable by any MCP host.
2036. **New core tool** — only when the capability is fundamental, broadly useful
204 to nearly every user, and unreachable via terminal + file (or an MCP server).
205 Examples of correct core tools: terminal, read_file, web_search,
206 browser_navigate.
207
208When 3+ open PRs try to integrate the same *category* of thing (memory
209backends, providers, notifiers), don't merge them one at a time — design an
210ABC + orchestrator, wrap the existing built-in as the first provider, and turn
211the competing PRs into plugins against that interface.
212
213### Surface capability is a property of the SESSION, never of the process env
214
215A tool that only works because of *who is on the other end of the connection* —
216the desktop app's panes, the in-app browser, message reactions, Projects — must
217resolve its availability from the **session's own source**, not from an env var
218on the backend process.
219
220The client and the backend are separate machines on separate clocks. The
221desktop app can be driving a backend Electron spawned locally, one over SSH,
222one behind a plain URL + token, or Hermes Cloud. Only the first two are spawned
223by us and carry `HERMES_DESKTOP=1`. Every env-keyed GUI gate is therefore a
224silent no-op on the other half of the topologies, and the failure is invisible:
225the tool is stripped from the schema before the model ever sees it, on the same
226backend whose platform hint is telling the model it's *"chatting inside the
227Hermes desktop app."*
228
229The pattern that works:
230
231- **The toolset is the surface gate.** Keep the tools off `_HERMES_CORE_TOOLS`
232 (nobody else should pay their schema) and put them in a named toolset —
233 `desktop_ui`, `project`. The GUI gateway's `_load_enabled_toolsets(platform)`
234 folds that toolset in when the session's platform says GUI. One resolver,
235 every topology.
236- **`check_fn` answers reachability or user opt-in, not surface.** "Is the
237 renderer bridge wired?", "did the user enable reactions?" — fine. "Was I
238 spawned by Electron?" — not fine. `check_fn` results are also TTL-cached
239 process-wide (`tools/registry.py`), so a per-session answer does not belong
240 there at all: one process serves many sessions.
241- **Ask which identity you actually mean.** `HERMES_DESKTOP=1` legitimately
242 marks *"this backend process was spawned by the app"* — it gates the cron
243 ticker and web-dist handling correctly. It does NOT mean "a GUI is watching",
244 and the embedded terminal pane (`hermes --tui` against that same backend) is
245 the standing counterexample.
246
247Same test both ways: if the capability would still make sense with the client
248on another machine, it is session-scoped. Cover it with a test that asserts the
249GUI session gets the tool **with the env var absent** — that's the assertion
250the original gate could never have passed.
251
252## Development Environment
253
254```bash
255# Prefer .venv; fall back to venv if that's what your checkout has.
256source .venv/bin/activate # or: source venv/bin/activate
257```
258
259`scripts/run_tests.sh` probes `.venv` first, then `venv`, then
260`$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the
261main checkout).
262
263## Project Structure
264
265File counts shift constantly — don't treat the tree below as exhaustive.
266The canonical source is the filesystem. The notes call out the load-bearing
267entry points you'll actually edit.
268
269```
270hermes-agent/
271├── run_agent.py # AIAgent class — core conversation loop (~12k LOC)
272├── model_tools.py # Tool orchestration, discover_builtin_tools(), handle_function_call()
273├── toolsets.py # Toolset definitions, _HERMES_CORE_TOOLS list
274├── cli.py # HermesCLI class — interactive CLI orchestrator (~11k LOC)
275├── hermes_state.py # SessionDB — SQLite session store (FTS5 search)
276├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths
277├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
278├── batch_runner.py # Parallel batch processing
279├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
280├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
281├── tools/ # Tool implementations — auto-discovered via tools/registry.py
282│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
283├── gateway/ # Messaging gateway — run.py + session.py + platforms/
284│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
285│ │ # homeassistant, signal, matrix, mattermost, email, sms,
286│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
287│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
288│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
289├── plugins/ # Plugin system (see "Plugins" section below)
290│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
291│ ├── context_engine/ # Context-engine plugins
292│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
293│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
294│ ├── hermes-achievements/ # Gamified achievement tracking
295│ ├── observability/ # Metrics / traces / logs plugin
296│ ├── image_gen/ # Image-generation providers
297│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
298│ # strike-freedom-cockpit, ...
299├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
300├── skills/ # Built-in skills bundled with the repo
301├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
302│ └── src/ # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib
303├── tui_gateway/ # Python JSON-RPC backend for the TUI
304├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
305├── cron/ # Scheduler — jobs.py, scheduler.py
306├── scripts/ # run_tests.sh, release.py, auxiliary scripts
307├── website/ # Docusaurus docs site
308└── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026)
309```
310
311**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
312**Logs:** `~/.hermes/logs/` — `agent.log` (INFO+), `errors.log` (WARNING+),
313`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
314Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
315
316## TypeScript Style
317
318Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
319
320- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
321- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
322- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
323- Do not pass state through three components when the leaf can subscribe to the atom.
324- Keep persistence beside the atom that owns it.
325- Keep route roots thin. They compose routes and shell; they should not become controllers.
326- No monolithic hooks. A hook should own one narrow job.
327- Prefer colocated action modules over hidden god hooks.
328- If a callback is pure side effect, use the terse void form:
329 `onState={st => void setGatewayState(st)}`.
330- Async UI handlers should make intent explicit:
331 `onClick={() => void save()}`.
332- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
333- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
334- Table-driven beats condition ladders when mapping ids, routes, or views.
335- `src/app` owns routes, pages, and page-specific components.
336- `src/store` owns shared atoms.
337- `src/lib` owns shared pure helpers.
338
339## File Dependency Chain
340
341```
342tools/registry.py (no deps — imported by all tool files)
343 ↑
344tools/*.py (each calls registry.register() at import time)
345 ↑
346model_tools.py (imports tools/registry + triggers tool discovery)
347 ↑
348run_agent.py, cli.py, batch_runner.py, environments/
349```
350
351---
352
353## AIAgent Class (run_agent.py)
354
355The real `AIAgent.__init__` takes ~60 parameters (credentials, routing, callbacks,
356session context, budget, credential pool, etc.). The signature below is the
357minimum subset you'll usually touch — read `run_agent.py` for the full list.
358
359```python
360class AIAgent:
361 def __init__(self,
362 base_url: str = None,
363 api_key: str = None,
364 provider: str = None,
365 api_mode: str = None, # "chat_completions" | "codex_responses" | ...
366 model: str = "", # empty → resolved from config/provider later
367 max_iterations: int = 500, # tool-calling iterations (shared with subagents)
368 enabled_toolsets: list = None,
369 disabled_toolsets: list = None,
370 quiet_mode: bool = False,
371 save_trajectories: bool = False,
372 platform: str = None, # "cli", "telegram", etc.
373 session_id: str = None,
374 skip_context_files: bool = False,
375 skip_memory: bool = False,
376 credential_pool=None,
377 # ... plus callbacks, thread/user/chat IDs, iteration_budget, fallback_model,
378 # checkpoints config, prefill_messages, service_tier, reasoning_config, etc.
379 ): ...
380
381 def chat(self, message: str) -> str:
382 """Simple interface — returns final response string."""
383
384 def run_conversation(self, user_message: str, system_message: str = None,
385 conversation_history: list = None, task_id: str = None) -> dict:
386 """Full interface — returns dict with final_response + messages."""
387```
388
389### Agent Loop
390
391The core loop is inside `run_conversation()` — entirely synchronous, with
392interrupt checks, budget tracking, and a one-turn grace call:
393
394```python
395while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
396 or self._budget_grace_call:
397 if self._interrupt_requested: break
398 response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
399 if response.tool_calls:
400 for tool_call in response.tool_calls:
401 result = handle_function_call(tool_call.name, tool_call.args, task_id)
402 messages.append(tool_result_message(result))
403 api_call_count += 1
404 else:
405 return response.content
406```
407
408Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`.
409Reasoning content is stored in `assistant_msg["reasoning"]`.
410
411---
412
413## CLI Architecture (cli.py)
414
415- **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete
416- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
417- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
418- **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
419- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry
420- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching
421
422### Slash Command Registry (`hermes_cli/commands.py`)
423
424All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically:
425
426- **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name
427- **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch
428- **Gateway help** — `gateway_help_lines()` generates `/help` output
429- **Telegram** — `telegram_bot_commands()` generates the BotCommand menu
430- **Slack** — `slack_subcommand_map()` generates `/hermes` subcommand routing
431- **Autocomplete** — `COMMANDS` flat dict feeds `SlashCommandCompleter`
432- **CLI help** — `COMMANDS_BY_CATEGORY` dict feeds `show_help()`
433
434### Adding a Slash Command
435
4361. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
437```python
438CommandDef("mycommand", "Description of what it does", "Session",
439 aliases=("mc",), args_hint="[arg]"),
440```
4412. Add handler in `HermesCLI.process_command()` in `cli.py`:
442```python
443elif canonical == "mycommand":
444 self._handle_mycommand(cmd_original)
445```
4463. If the command is available in the gateway, add a handler in `gateway/run.py`:
447```python
448if canonical == "mycommand":
449 return await self._handle_mycommand(event)
450```
4514. For persistent settings, use `save_config_value()` in `cli.py`
452
453**CommandDef fields:**
454- `name` — canonical name without slash (e.g. `"background"`)
455- `description` — human-readable description
456- `category` — one of `"Session"`, `"Configuration"`, `"Tools & Skills"`, `"Info"`, `"Exit"`
457- `aliases` — tuple of alternative names (e.g. `("bg",)`)
458- `args_hint` — argument placeholder shown in help (e.g. `"<prompt>"`, `"[name]"`)
459- `cli_only` — only available in the interactive CLI
460- `gateway_only` — only available in messaging platforms
461- `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.
462
463**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.
464
465---
466
467## TUI Architecture (ui-tui + tui_gateway)
468
469The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`.
470
471### Process Model
472
473```
474hermes --tui
475 └─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway)
476 │ └─ AIAgent + tools + sessions
477 └─ renders transcript, composer, prompts, activity
478```
479
480TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic.
481
482### Transport
483
484Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog.
485
486### Key Surfaces
487
488| Surface | Ink component | Gateway method |
489|---------|---------------|----------------|
490| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` |
491| Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
492| Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` |
493| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
494| Session picker | `sessionPicker.tsx` | `session.list/resume` |
495| Slash commands | Local handler + fallthrough | `slash.exec` → `_SlashWorker`, `command.dispatch` |
496| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
497| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
498
499### Slash Command Flow
500
5011. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx`
5022. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback
503
504### Dev Commands
505
506```bash
507cd ui-tui
508npm install # first time
509npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
510npm start # production
511npm run build # full build (hermes-ink + tsc)
512npm run typecheck # typecheck only (tsc --noEmit)
513npm run lint # eslint
514npm run fmt # prettier
515npm test # vitest
516```
517
518### TUI in the Dashboard (`hermes dashboard` → `/chat`)
519
520The 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`.
521
522- 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.
523- `/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).
524- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not).
525- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:<cols>;<rows>]` intercepted on the server and applied with `TIOCSWINSZ`.
526
527**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.
528
529**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.
530
531### Electron Desktop Chat App (`apps/desktop/`)
532
533A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.ts` + `backendSupportsServe()` in `electron/main.ts`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. For scoped Desktop architecture, state, resolver, transport, and testing rules, read `apps/desktop/AGENTS.md`.
534
535**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
536
537- **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills.
538- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMAND_SPECS` (the built-ins and their Desktop surfaces) plus `NO_DESKTOP_SURFACE` block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
539 - `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
540 - `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`.
541 - `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.)
542- **Dispatch** lives in `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
543
544**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: from `apps/desktop`, run `npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace dependencies are installed at the repo root).
545
546---
547
548## Adding New Tools
549
550Before adding any tool, settle the footprint question first (see "The
551Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
552be core tools. For custom or local-only tools, do **not** edit Hermes core.
553Use the plugin route instead: create `~/.hermes/plugins/<name>/plugin.yaml`
554and `~/.hermes/plugins/<name>/__init__.py`, then register tools with
555`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
556enabled or disabled without touching `tools/` or `toolsets.py`.
557
558Use the built-in route below only when the user is explicitly contributing a new
559core Hermes tool that should ship in the base system.
560
561Built-in/core tools require changes in **2 files**:
562
563**1. Create `tools/your_tool.py`:**
564```python
565import json, os
566from tools.registry import registry
567
568def check_requirements() -> bool:
569 return bool(os.getenv("EXAMPLE_API_KEY"))
570
571def example_tool(param: str, task_id: str = None) -> str:
572 return json.dumps({"success": True, "data": "..."})
573
574registry.register(
575 name="example_tool",
576 toolset="example",
577 schema={"name": "example_tool", "description": "...", "parameters": {...}},
578 handler=lambda args, **kw: example_tool(param=args.get("param", ""), task_id=kw.get("task_id")),
579 check_fn=check_requirements,
580 requires_env=["EXAMPLE_API_KEY"],
581)
582```
583
584**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only *exposed to an agent* if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from.
585
586Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step.
587
588The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
589
590**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`.
591
592**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.
593
594**Agent-level tools** (todo, memory): intercepted by `run_agent.py` before `handle_function_call()`. See `tools/todo_tool.py` for the pattern.
595
596---
597
598## Dependency Pinning Policy
599
600All dependencies must have upper bounds to limit supply-chain attack surface.
601This policy was established after the litellm compromise (PR #2796, #2810) and
602reinforced after the Mini Shai-Hulud worm campaign (May 2026).
603
604| Source type | Treatment | Example |
605|---|---|---|
606| PyPI package | `>=floor,<next_major` | `"httpx>=0.28.1,<1"` |
607| Git URL | Commit SHA | `git+https://...@<40-char-sha>` |
608| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@<sha> # v4` |
609| CI-only pip | `==exact` | `pyyaml==6.0.2` |
610
611**When adding a new dependency to `pyproject.toml`:**
6121. Pin to `>=current_version,<next_major` for post-1.0 (e.g. `>=1.5.0,<2`).
6132. For pre-1.0 packages, use `<0.(current_minor + 2)` (e.g. `>=0.29,<0.32`).
6143. Never commit a bare `>=X.Y.Z` without a ceiling — CI and reviewers will reject it.
6154. Run `uv lock` to regenerate `uv.lock` with hashes.
616
617Reference: #2810 (bounds pass), #9801 (SHA pinning + audit CI).
618
619---
620
621## Adding Configuration
622
623### config.yaml options:
6241. Add to `DEFAULT_CONFIG` in `hermes_cli/config.py`
6252. Bump `_config_version` (check the current value at the top of `DEFAULT_CONFIG`)
626 ONLY if you need to actively migrate/transform existing user config
627 (renaming keys, changing structure). Adding a new key to an existing
628 section is handled automatically by the deep-merge and does NOT require
629 a version bump.
630
631### Top-level `config.yaml` sections (non-exhaustive):
632
633`model`, `agent`, `terminal`, `compression`, `display`, `stt`, `tts`,
634`memory`, `security`, `delegation`, `smart_model_routing`, `checkpoints`,
635`auxiliary`, `curator`, `skills`, `gateway`, `logging`, `cron`, `profiles`,
636`plugins`, `honcho`.
637
638`auxiliary` holds per-task overrides for side-LLM work (curator, vision,
639embedding, title generation, session_search, etc.) — each task can pin
640its own provider/model/base_url/max_tokens/reasoning_effort. See
641`agent/auxiliary_client.py::_resolve_auto` for resolution order.
642
643`curator` holds the background skill-maintenance config —
644`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
645`archive_after_days`, `backup` (nested).
646
647### .env variables (SECRETS ONLY — API keys, tokens, passwords):
6481. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
649```python
650"NEW_API_KEY": {
651 "description": "What it's for",
652 "prompt": "Display name",
653 "url": "https://...",
654 "password": True,
655 "category": "tool", # provider, tool, messaging, setting
656},
657```
658
659Non-secret settings (timeouts, thresholds, feature flags, paths, display
660preferences) belong in `config.yaml`, not `.env`. If internal code needs an
661env var mirror for backward compatibility, bridge it from `config.yaml` to
662the env var in code (see `gateway_timeout`, `terminal.cwd` → `TERMINAL_CWD`).
663
664### Config loaders (three paths — know which one you're in):
665
666| Loader | Used by | Location |
667|--------|---------|----------|
668| `load_cli_config()` | CLI mode | `cli.py` — merges CLI-specific defaults + user YAML |
669| `load_config()` | `hermes tools`, `hermes setup`, most CLI subcommands | `hermes_cli/config.py` — merges `DEFAULT_CONFIG` + user YAML |
670| Direct YAML load | Gateway runtime | `gateway/run.py` + `gateway/config.py` — reads user YAML raw |
671
672If you add a new key and the CLI sees it but the gateway doesn't (or vice
673versa), you're on the wrong loader. Check `DEFAULT_CONFIG` coverage.
674
675### Working directory:
676- **CLI** — uses the process's current directory (`os.getcwd()`).
677- **Messaging** — uses `terminal.cwd` from `config.yaml`. The gateway bridges this
678 to the `TERMINAL_CWD` env var for child tools. **`MESSAGING_CWD` has been
679 removed** — the config loader prints a deprecation warning if it's set in
680 `.env`. Same for `TERMINAL_CWD` in `.env`; the canonical setting is
681 `terminal.cwd` in `config.yaml`.
682
683---
684
685## Skin/Theme System
686
687The 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.
688
689### Architecture
690
691```
692hermes_cli/skin_engine.py # SkinConfig dataclass, built-in skins, YAML loader
693~/.hermes/skins/*.yaml # User-installed custom skins (drop-in)
694```
695
696- `init_skin_from_config()` — called at CLI startup, reads `display.skin` from config
697- `get_active_skin()` — returns cached `SkinConfig` for the current skin
698- `set_active_skin(name)` — switches skin at runtime (used by `/skin` command)
699- `load_skin(name)` — loads from user skins first, then built-ins, then falls back to default
700- Missing skin values inherit from the `default` skin automatically
701
702### What skins customize
703
704| Element | Skin Key | Used By |
705|---------|----------|---------|
706| Banner panel border | `colors.banner_border` | `banner.py` |
707| Banner panel title | `colors.banner_title` | `banner.py` |
708| Banner section headers | `colors.banner_accent` | `banner.py` |
709| Banner dim text | `colors.banner_dim` | `banner.py` |
710| Banner body text | `colors.banner_text` | `banner.py` |
711| Response box border | `colors.response_border` | `cli.py` |
712| Spinner faces (waiting) | `spinner.waiting_faces` | `display.py` |
713| Spinner faces (thinking) | `spinner.thinking_faces` | `display.py` |
714| Spinner verbs | `spinner.thinking_verbs` | `display.py` |
715| Spinner wings (optional) | `spinner.wings` | `display.py` |
716| Tool output prefix | `tool_prefix` | `display.py` |
717| Per-tool emojis | `tool_emojis` | `display.py` → `get_tool_emoji()` |
718| Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
719| Welcome message | `branding.welcome` | `cli.py` |
720| Response box label | `branding.response_label` | `cli.py` |
721| Prompt symbol | `branding.prompt_symbol` | `cli.py` |
722
723### Built-in skins
724
725- `default` — Classic Hermes gold/kawaii (the current look)
726- `ares` — Crimson/bronze war-god theme with custom spinner wings
727- `mono` — Clean grayscale monochrome
728- `slate` — Cool blue developer-focused theme
729
730### Adding a built-in skin
731
732Add to `_BUILTIN_SKINS` dict in `hermes_cli/skin_engine.py`:
733
734```python
735"mytheme": {
736 "name": "mytheme",
737 "description": "Short description",
738 "colors": { ... },
739 "spinner": { ... },
740 "branding": { ... },
741 "tool_prefix": "┊",
742},
743```
744
745### User skins (YAML)
746
747Users create `~/.hermes/skins/<name>.yaml`:
748
749```yaml
750name: cyberpunk
751description: Neon-soaked terminal theme
752
753colors:
754 banner_border: "#FF00FF"
755 banner_title: "#00FFFF"
756 banner_accent: "#FF1493"
757
758spinner:
759 thinking_verbs: ["jacking in", "decrypting", "uploading"]
760 wings:
761 - ["⟨⚡", "⚡⟩"]
762
763branding:
764 agent_name: "Cyber Agent"
765 response_label: " ⚡ Cyber "
766
767tool_prefix: "▏"
768```
769
770Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
771
772---
773
774## Plugins
775
776Hermes has two plugin surfaces. Both live under `plugins/` in the repo so
777repo-shipped plugins can be discovered alongside user-installed ones in
778`~/.hermes/plugins/` and pip-installed entry points.
779
780### General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
781
782`PluginManager` discovers plugins from `~/.hermes/plugins/`, `./.hermes/plugins/`,
783and pip entry points. Each plugin exposes a `register(ctx)` function that
784can:
785
786- Register Python-callback lifecycle hooks:
787 `pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`,
788 `on_session_start`, `on_session_end`
789- Register new tools via `ctx.register_tool(...)`
790- Register CLI subcommands via `ctx.register_cli_command(...)` — the
791 plugin's argparse tree is wired into `hermes` at startup so
792 `hermes <pluginname> <subcmd>` works with no change to `main.py`
793
794Hooks are invoked from `model_tools.py` (pre/post tool) and `run_agent.py`
795(lifecycle). **Discovery timing pitfall:** `discover_plugins()` only runs
796as a side effect of importing `model_tools.py`. Code paths that read plugin
797state without importing `model_tools.py` first must call `discover_plugins()`
798explicitly (it's idempotent).
799
800#### Native plugin compatibility policy
801
802The canonical contract and deprecation policy live in
803`website/docs/developer-guide/plugins/index.md#native-plugin-compatibility-contract`.
804Compatibility is enforced as a behavior contract, not through a monolithic
805`PLUGIN_API_VERSION`, a manifest-wide native `api:` match, or version literals
806on unrelated payloads. Keep documented plugin surfaces additive:
807
808- add hook payload data as keyword fields; signature-inspect callbacks so old
809 narrow signatures receive only fields they declare, while `**kwargs`
810 callbacks receive the complete payload;
811- do not remove or rename `PluginContext` methods; make new parameters optional
812 with defaults and keyword-only where possible;
813- ignore unknown native manifest fields;
814- give new provider methods default implementations, and signature-inspect
815 optional callback kwargs rather than forwarding them unconditionally;
816- use a local schema version only for a capability with a wire or persisted
817 contract, and preserve old state/config/session replay or ship a migration.
818
819Deprecations require a once-per-process warning, a documented replacement and
820migration note, and at least two subsequent minor releases before removal.
821Compatibility tests must load frozen plugins through the real discovery path
822and assert outcomes. Do not replace these with exact registry/catalog counts,
823source-reading tests, or assertions that a global version literal changed.
824
825### Memory-provider plugins (`plugins/memory/<name>/`)
826
827Separate discovery system for pluggable memory backends. Current built-in
828providers include **honcho, mem0, supermemory, byterover, hindsight,
829holographic, openviking, retaindb**.
830
831Discovery covers the same four sources as the general `PluginManager` —
832bundled, `$HERMES_HOME/plugins/`, `./.hermes/plugins/` (opt-in via
833`HERMES_ENABLE_PROJECT_PLUGINS`), and `hermes_agent.memory_providers` entry
834points — but with **bundled-first** precedence, the reverse of the general
835system's later-wins order: a memory provider is activated by name, so a
836dropped-in directory must not be able to shadow a shipped one. Discovery
837enumerates without importing; nothing runs until `memory.provider` names it.
838
839Each provider implements the `MemoryProvider` ABC (see `agent/memory_provider.py`)
840and is orchestrated by `agent/memory_manager.py`. Lifecycle hooks include
841`sync_turn(turn_messages)`, `prefetch(query)`, `shutdown()`, and optional
842`post_setup(hermes_home, config)` for setup-wizard integration.
843
844**CLI commands via `plugins/memory/<name>/cli.py`:** if a memory plugin
845defines `register_cli(subparser)`, `discover_plugin_cli_commands()` finds
846it at argparse setup time and wires it into `hermes <plugin>`. The
847framework only exposes CLI commands for the **currently active** memory
848provider (read from `memory.provider` in config.yaml), so disabled
849providers don't clutter `hermes --help`.
850
851**Rule (Teknium, May 2026):** plugins MUST NOT modify core files
852(`run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.).
853If a plugin needs a capability the framework doesn't expose, expand the
854generic plugin surface (new hook, new ctx method) — never hardcode
855plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
856honcho argparse from `main.py` for exactly this reason.
857
858**No new in-tree memory providers (policy, May 2026):** the set of
859built-in memory providers under `plugins/memory/` is closed. New memory
860backends must ship as **standalone plugin repos** that users install
861into `~/.hermes/plugins/` (or via pip entry points) — they implement
862the same `MemoryProvider` ABC, register through the same discovery
863path, and integrate via `hermes memory setup` / `post_setup()` without
864landing in this tree. PRs that add a new directory under
865`plugins/memory/` will be closed with a pointer to publish the
866provider as its own repo. Existing in-tree providers stay; bug fixes
867to them are welcome.
868
869**No new third-party-product plugins in-tree (policy, June 2026):** the
870same rule applies beyond memory providers. Plugins that integrate
871someone else's product or project — observability/metrics backends,
872vendor SaaS connectors, analytics dashboards, paid-service tie-ins —
873must ship as **standalone plugin repos** that users install into
874`~/.hermes/plugins/` (or via pip entry points). They register through
875the existing plugin discovery path and use the ABCs/hooks/ctx surface
876we expose; nothing special is needed in core. The reason is
877maintenance load: every product we absorb into the tree becomes our
878burden to keep working against a fast-moving core, for a backend we
879don't own. Promote standalone plugins in the Nous Research Discord
880(`#plugins-skills-and-skins`). PRs that add such a directory under
881`plugins/` are closed with a pointer to publish it as its own repo —
882this is a coupling decision, not a quality judgment. (The
883`observability/`, `kanban/`, `disk-cleanup/`, etc. directories already
884in the tree are existing precedent, not an invitation to add more
885third-party-product plugins alongside them.)
886
887### Model-provider plugins (`plugins/model-providers/<name>/`)
888
889Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …)
890ships as a plugin here. Each plugin's `__init__.py` calls
891`providers.register_provider(ProviderProfile(...))` at module load.
892`providers/__init__.py._discover_providers()` is a **lazy, separate
893discovery system** — scanned on first `get_provider_profile()` or
894`list_providers()` call, NOT by the general PluginManager.
895
896Scan order:
8971. Bundled: `<repo>/plugins/model-providers/<name>/`
8982. User: `$HERMES_HOME/plugins/model-providers/<name>/`
8993. Legacy: `<repo>/providers/<name>.py` (back-compat)
900
901User plugins of the same name override bundled ones — `register_provider()`
902is last-writer-wins. This lets third parties swap out any built-in
903profile without a repo patch.
904
905The general PluginManager records `kind: model-provider` manifests but does
906NOT import them (would double-instantiate `ProviderProfile`). Plugins
907without an explicit `kind:` get auto-coerced via a source-text heuristic
908(`register_provider` + `ProviderProfile` in `__init__.py`).
909
910Full authoring guide: `website/docs/developer-guide/model-provider-plugin.md`.
911
912### Dashboard / context-engine / image-gen plugin directories
913
914`plugins/context_engine/`, `plugins/image_gen/`, etc. follow the same
915pattern (ABC + orchestrator + per-plugin directory). Context engines
916plug into `agent/context_engine.py`; image-gen providers into
917`agent/image_gen_provider.py`. Reference / docs-companion plugins
918(`example-dashboard`, `strike-freedom-cockpit`, `plugin-llm-example`,
919`plugin-llm-async-example`) live in the
920[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins)
921companion repo, not in this tree.
922
923---
924
925## Skills
926
927Two parallel surfaces:
928
929- **`skills/`** — built-in skills shipped and loadable by default.
930 Organized by category directories (e.g. `skills/github/`, `skills/mlops/`).
931- **`optional-skills/`** — heavier or niche skills shipped with the repo but
932 NOT active by default. Installed explicitly via
933 `hermes skills install official/<category>/<skill>`. Adapter lives in
934 `tools/skills_hub.py` (`OptionalSkillSource`). Categories include
935 `autonomous-ai-agents`, `blockchain`, `communication`, `creative`,
936 `devops`, `email`, `health`, `mcp`, `migration`, `mlops`, `productivity`,
937 `research`, `security`, `web-development`.
938
939When reviewing skill PRs, check which directory they target — heavy-dep or
940niche skills belong in `optional-skills/`.
941
942### SKILL.md frontmatter
943
944Standard fields: `name`, `description`, `version`, `author`, `license`,
945`platforms` (OS-gating list: `[macos]`, `[linux, macos]`, ...),
946`metadata.hermes.tags`, `metadata.hermes.category`,
947`metadata.hermes.related_skills`, `metadata.hermes.config` (config.yaml
948settings the skill needs — stored under `skills.config.<key>`, prompted
949during setup, injected at load time).
950
951Top-level `tags:` and `category:` are also accepted and mirrored from
952`metadata.hermes.*` by the loader.
953
954### Skill authoring standards (HARDLINE)
955
956Every new or modernized skill — bundled, optional, or contributed —
957must meet these standards before merge. Reviewers reject PRs that
958violate them.
959
9601. **`description` ≤ 60 characters, one sentence, ends with a period.**
961 Long descriptions bloat skill listings and dilute the model's
962 attention when many skills are loaded. State the capability, not
963 the implementation. No marketing words ("powerful",
964 "comprehensive", "seamless", "advanced"). Don't repeat the skill
965 name. Verify with:
966 ```python
967 import re, pathlib
968 m = re.search(r'^description: (.*)$',
969 pathlib.Path('skills/<cat>/<name>/SKILL.md').read_text(),
970 re.MULTILINE)
971 assert len(m.group(1)) <= 60, len(m.group(1))
972 ```
973
9742. **Tools referenced in SKILL.md prose must be native Hermes tools or
975 MCP servers the skill explicitly expects.** When the skill needs a
976 capability, point at the proper tool by name in backticks
977 (`` `terminal` ``, `` `web_extract` ``, `` `read_file` ``,
978 `` `patch` ``, `` `search_files` ``, `` `vision_analyze` ``,
979 `` `browser_navigate` ``, `` `delegate_task` ``, etc.). Do NOT
980 name shell utilities the agent already has wrapped — `grep` →
981 `search_files`, `cat`/`head`/`tail` → `read_file`, `sed`/`awk` →
982 `patch`, `find`/`ls` → `search_files target='files'`. If the skill
983 depends on an MCP server, name the MCP server and document the
984 expected setup in `## Prerequisites`. Anything else (third-party
985 CLIs, shell pipelines, etc.) is fair game inside script files but
986 should not be the headline interaction surface in the prose.
987
9883. **`platforms:` gating audited against actual script imports.**
989 Skills that use POSIX-only primitives (`fcntl`, `termios`,
990 `os.setsid`, `os.kill(pid, 0)` for liveness, `/proc`, `/tmp`
991 hardcoded, `signal.SIGKILL`, bash heredocs, `osascript`, `apt`,
992 `systemctl`) must declare their supported platforms. Default
993 posture: try to fix it cross-platform first — `tempfile.gettempdir`,
994 `pathlib.Path`, `psutil.pid_exists`, Python-level filtering instead
995 of `grep`. Gate to a narrower set only when the dependency is
996 genuinely platform-bound.
997
9984. **`author` credits the human contributor first.** For external
999 contributions, the contributor's real name + GitHub handle goes
1000 first; "Hermes Agent" is the secondary collaborator. If the
1001 contributor's commit shows "Hermes Agent" as author (because they
1002 used Hermes to draft the skill), replace it with their actual name
1003 — credit the human, not the tool.
1004
10055. **SKILL.md body uses the modern section order.** `# <Skill> Skill`
1006 title, 2-3 sentence intro stating what it does and doesn't do,
1007 `## When to Use`, `## Prerequisites`, `## How to Run`,
1008 `## Quick Reference`, `## Procedure`, `## Pitfalls`,
1009 `## Verification`. Target ~200 lines for a complex skill,
1010 ~100 lines for a simple one. Cut redundant intro fluff, marketing
1011 prose, and re-explanations of env vars already in
1012 `## Prerequisites`.
1013
10146. **Scripts go in `scripts/`, references in `references/`,
1015 templates in `templates/`.** Don't expect the model to inline-write
1016 parsers, XML walkers, or non-trivial logic every call — ship a
1017 helper script. Reference it from SKILL.md by path relative to the
1018 skill directory.
1019
10207. **Tests live at `tests/skills/test_<skill>_skill.py`** and use only
1021 stdlib + pytest + `unittest.mock`. No live network calls. Run via
1022 `scripts/run_tests.sh tests/skills/test_<skill>_skill.py -q`.
1023
10248. **`.env.example` additions are isolated to a clearly delimited
1025 block.** Don't touch the surrounding file — contributor-supplied
1026 `.env.example` versions are usually stale and edits outside the
1027 skill's own block must be dropped during salvage.
1028
1029The full salvage / modernization checklist for external skill PRs
1030lives in the `hermes-agent-dev` skill at
1031`references/new-skill-pr-salvage.md` — load it before polishing
1032contributor skill PRs.
1033
1034---
1035
1036## Toolsets
1037
1038All toolsets are defined in `toolsets.py` as a single `TOOLSETS` dict.
1039Each platform's adapter picks a base toolset (e.g. Telegram uses
1040`"messaging"`); `_HERMES_CORE_TOOLS` is the default bundle most
1041platforms inherit from.
1042
1043Current toolset keys: `browser`, `clarify`, `code_execution`, `cronjob`,
1044`debugging`, `delegation`, `discord`, `discord_admin`, `feishu_doc`,
1045`feishu_drive`, `file`, `homeassistant`, `image_gen`, `kanban`, `memory`,
1046`messaging`, `moa`, `rl`, `safe`, `search`, `session_search`, `skills`,
1047`spotify`, `terminal`, `todo`, `tts`, `video`, `vision`, `web`, `yuanbao`.
1048
1049Enable/disable per platform via `hermes tools` (the curses UI) or the
1050`tools.<platform>.enabled` / `tools.<platform>.disabled` lists in
1051`config.yaml`.
1052
1053---
1054
1055## Delegation (`delegate_task`)
1056
1057`tools/delegate_tool.py` spawns a subagent with an isolated
1058context + terminal session. By default the parent waits for the
1059child's summary before continuing its own loop. With `background=true`,
1060Hermes returns a delegation id immediately and the result re-enters the
1061conversation later through the async-delegation completion queue.
1062
1063Two shapes:
1064
1065- **Single:** pass `goal` (+ optional `context`, `toolsets`).
1066- **Batch (parallel):** pass `tasks: [...]` — each gets its own subagent
1067 running concurrently. Concurrency is capped by
1068 `delegation.max_concurrent_children` (default 3).
1069
1070Roles:
1071
1072- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
1073 `clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code`
1074 (programmatic tool calling).
1075- `role="orchestrator"` — retains `delegate_task` so it can spawn its
1076 own workers. Gated by `delegation.orchestrator_enabled` (default true)
1077 and bounded by `delegation.max_spawn_depth` (default 2).
1078
1079Key config knobs (under `delegation:` in `config.yaml`):
1080`max_concurrent_children`, `max_spawn_depth`, `child_timeout_seconds`,
1081`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
1082`max_iterations`.
1083
1084Durability rule: background `delegate_task` is detached from the current
1085turn but still process-local. For work that must survive process restart, use
1086`cronjob` or `terminal(background=True, notify_on_complete=True)` instead.
1087
1088---
1089
1090## Curator (skill lifecycle)
1091
1092Background skill-maintenance system that tracks usage on agent-created
1093skills and auto-archives stale ones. Users never lose skills; archives
1094go to `~/.hermes/skills/.archive/` and are restorable.
1095
1096- **Core:** `agent/curator.py` (review loop, auto-transitions, LLM review
1097 prompt) + `agent/curator_backup.py` (pre-run tar.gz snapshots).
1098- **CLI:** `hermes_cli/curator.py` wires `hermes curator <verb>` where
1099 verbs are: `status`, `run`, `pause`, `resume`, `pin`, `unpin`,
1100 `archive`, `restore`, `prune`, `backup`, `rollback`.
1101- **Telemetry:** `tools/skill_usage.py` owns the sidecar
1102 `~/.hermes/skills/.usage.json` — per-skill `use_count`, `view_count`,
1103 `patch_count`, `last_activity_at`, `state` (active / stale /
1104 archived), `pinned`.
1105
1106Invariants:
1107- Curator only touches skills with `created_by: "agent"` provenance —
1108 bundled + hub-installed skills are off-limits.
1109- Never deletes; max destructive action is archive.
1110- Pinned skills are exempt from every auto-transition and from the
1111 LLM review pass.
1112- `skill_manage(action="delete")` refuses pinned skills; patch/edit/
1113 write_file/remove_file go through so the agent can keep improving
1114 pinned skills.
1115
1116Config section (`curator:` in `config.yaml`):
1117`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
1118`archive_after_days`, `backup.*`.
1119
1120Full user-facing docs: `website/docs/user-guide/features/curator.md`.
1121
1122---
1123
1124## Cron (scheduled jobs)
1125
1126`cron/jobs.py` (job store) + `cron/scheduler.py` (tick loop). Agents
1127schedule jobs via the `cronjob` tool; users via `hermes cron <verb>`
1128(`list`, `add`, `edit`, `pause`, `resume`, `run`, `remove`) or the
1129`/cron` slash command.
1130
1131Supported schedule formats:
1132- Duration: `"30m"`, `"2h"`, `"1d"`
1133- "every" phrase: `"every 2h"`, `"every monday 9am"`
1134- 5-field cron expression: `"0 9 * * *"`
1135- ISO timestamp (one-shot): `"2026-06-01T09:00:00Z"`
1136
1137Per-job fields include `skills` (load specific skills), `model` /
1138`provider` overrides, `script` (pre-run data-collection script whose
1139stdout is injected into the prompt; `no_agent=True` turns the script
1140into the entire job), `context_from` (chain job A's last output into
1141job B's prompt), `workdir` (run in a specific directory with its
1142`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery.
1143
1144Hardening invariants:
1145- **3-minute hard interrupt** on cron sessions — runaway agent loops
1146 cannot monopolize the scheduler.
1147- Catchup window: half the job's period, clamped to 120s–2h.
1148- Grace window: 120s for one-shot jobs whose fire time was missed.
1149- File lock at `~/.hermes/cron/.tick.lock` prevents duplicate ticks
1150 across processes.
1151- Cron sessions pass `skip_memory=True` by default; memory providers
1152 intentionally do not run during cron.
1153
1154Cron deliveries are **not** mirrored into the target gateway session —
1155they land in their own cron session with a header/footer frame so the
1156main conversation's message-role alternation stays intact.
1157
1158---
1159
1160## Kanban (multi-agent work queue)
1161
1162Durable SQLite-backed board that lets multiple profiles / workers
1163collaborate on shared tasks. Users drive it via `hermes kanban <verb>`;
1164workers spawned by the dispatcher drive it via a dedicated `kanban_*`
1165toolset so their schema footprint is zero when they're not inside a
1166kanban task.
1167
1168- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
1169 `init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
1170 `unlink`, `comment`, `attach`, `attachments`, `attach-rm`, `complete`,
1171 `request-review`, `request-changes`, `reopen-review`, `block`, `unblock`, `archive`,
1172 `tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`,
1173 `assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`.
1174- **Worker/orchestrator toolset:** `tools/kanban_tools.py` exposes
1175 `kanban_show`, `kanban_complete`, `kanban_request_review`,
1176 `kanban_request_changes`, `kanban_block`,
1177 `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`,
1178 `kanban_attach`, `kanban_attach_url`, `kanban_attachments`; profiles that
1179 explicitly enable the `kanban` toolset outside a dispatcher-spawned
1180 task also get `kanban_list` and `kanban_unblock` for board routing.
1181- **Dispatcher:** long-lived loop that (default every 60s) reclaims
1182 stale claims, promotes ready tasks, atomically claims, and spawns
1183 assigned profiles. Runs **inside the gateway** by default via
1184 `kanban.dispatch_in_gateway: true`.
1185- **Plugin assets:** `plugins/kanban/dashboard/` (web UI) +
1186 `plugins/kanban/systemd/` (`hermes-kanban-dispatcher.service` for
1187 standalone dispatcher deployment).
1188
1189Isolation model:
1190- **Board** is the hard boundary — workers are spawned with
1191 `HERMES_KANBAN_BOARD` pinned in their env so they can't see other
1192 boards.
1193- **Tenant** is a soft namespace *within* a board — one specialist
1194 fleet can serve multiple businesses with workspace-path + memory-key
1195 isolation.
1196- After `kanban.failure_limit` consecutive non-success attempts on the
1197 same task (default: 2), the dispatcher auto-blocks it to prevent spin
1198 loops.
1199
1200Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
1201
1202---
1203
1204## Important Policies
1205
1206### Prompt Caching Must Not Break
1207
1208Hermes-Agent ensures caching remains valid throughout a conversation. **Do NOT implement changes that would:**
1209- Alter past context mid-conversation
1210- Change toolsets mid-conversation
1211- Reload memories or rebuild system prompts mid-conversation
1212
1213Cache-breaking forces dramatically higher costs. The ONLY time we alter context is during context compression.
1214
1215Slash commands that mutate system-prompt state (skills, tools, memory, etc.)
1216must be **cache-aware**: default to deferred invalidation (change takes
1217effect next session), with an opt-in `--now` flag for immediate
1218invalidation. See `/skills install --now` for the canonical pattern.
1219
1220### Background Process Notifications (Gateway)
1221
1222When `terminal(background=true, notify_on_complete=true)` is used, the gateway runs a watcher that
1223detects process completion and triggers a new agent turn. Control verbosity of background process
1224messages with `display.background_process_notifications`
1225in config.yaml (or `HERMES_BACKGROUND_NOTIFICATIONS` env var):
1226
1227- `concise` — one-line status message on completion; failures append a short output tail (default)
1228- `all` — running-output updates + final raw-output message
1229- `result` — only the final raw-output completion message
1230- `error` — only the final raw-output message when exit code != 0
1231- `off` — no watcher messages at all
1232
1233---
1234
1235## Profiles: Multi-Instance Support
1236
1237Hermes supports **profiles** — multiple fully isolated instances, each with its own
1238`HERMES_HOME` directory (config, API keys, memory, sessions, skills, gateway, etc.).
1239
1240The core mechanism: `_apply_profile_override()` in `hermes_cli/main.py` sets
1241`HERMES_HOME` before any module imports. All `get_hermes_home()` references
1242automatically scope to the active profile.
1243
1244### Rules for profile-safe code
1245
12461. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`.
1247 NEVER hardcode `~/.hermes` or `Path.home() / ".hermes"` in code that reads/writes state.
1248 ```python
1249 # GOOD
1250 from hermes_constants import get_hermes_home
1251 config_path = get_hermes_home() / "config.yaml"
1252
1253 # BAD — breaks profiles
1254 config_path = Path.home() / ".hermes" / "config.yaml"
1255 ```
1256
12572. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`.
1258 This returns `~/.hermes` for default or `~/.hermes/profiles/<name>` for profiles.
1259 ```python
1260 # GOOD
1261 from hermes_constants import display_hermes_home
1262 print(f"Config saved to {display_hermes_home()}/config.yaml")
1263
1264 # BAD — shows wrong path for profiles
1265 print("Config saved to ~/.hermes/config.yaml")
1266 ```
1267
12683. **Module-level constants are fine** — they cache `get_hermes_home()` at import time,
1269 which is AFTER `_apply_profile_override()` sets the env var. Just use `get_hermes_home()`,
1270 not `Path.home() / ".hermes"`.
1271
12724. **Tests that mock `Path.home()` must also set `HERMES_HOME`** — since code now uses
1273 `get_hermes_home()` (reads env var), not `Path.home() / ".hermes"`:
1274 ```python
1275 with patch.object(Path, "home", return_value=tmp_path), \
1276 patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
1277 ...
1278 ```
1279
12805. **Gateway platform adapters should use token locks** — if the adapter connects with
1281 a unique credential (bot token, API key), call `acquire_scoped_lock()` from
1282 `gateway.status` in the `connect()`/`start()` method and `release_scoped_lock()` in
1283 `disconnect()`/`stop()`. This prevents two profiles from using the same credential.
1284 See `plugins/platforms/irc/adapter.py` for the canonical pattern.
1285
12866. **Profile operations are HOME-anchored, not HERMES_HOME-anchored** — `_get_profiles_root()`
1287 returns `Path.home() / ".hermes" / "profiles"`, NOT `get_hermes_home() / "profiles"`.
1288 This is intentional — it lets `hermes -p coder profile list` see all profiles regardless
1289 of which one is active.
1290
1291## Known Pitfalls
1292
1293### DO NOT hardcode `~/.hermes` paths
1294Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_hermes_home()`
1295for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile
1296has its own `HERMES_HOME` directory. This was the source of 5 bugs fixed in PR #3575.
1297
1298### All CLI menu-pickers MUST use curses.
1299Interactive menus must use `hermes_cli/curses_ui.py`. See `hermes_cli/tools_config.py` for an example.
1300
1301### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code
1302Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`.
1303
1304### `_last_resolved_tool_names` is a process-global in `model_tools.py`
1305`_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.
1306
1307### DO NOT hardcode cross-tool references in schema descriptions
1308Tool 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.
1309
1310### The gateway has TWO message guards — both must bypass approval/control commands
1311When an agent is running, messages pass through two sequential guards:
1312(1) **base adapter** (`gateway/platforms/base.py`) queues messages in
1313`_pending_messages` when `session_key in self._active_sessions`, and
1314(2) **gateway runner** (`gateway/run.py`) intercepts `/stop`, `/new`,
1315`/queue`, `/status`, `/approve`, `/deny` before they reach
1316`running_agent.interrupt()`. Any new command that must reach the runner
1317while the agent is blocked (e.g. approval prompts) MUST bypass BOTH
1318guards and be dispatched inline, not via `_process_message_background()`
1319(which races session lifecycle).
1320
1321### Squash merges from stale branches silently revert recent fixes
1322Before squash-merging a PR, ensure the branch is up to date with `main`
1323(`git fetch origin main && git reset --hard origin/main` in the worktree,
1324then re-apply the PR's commits). A stale branch's version of an unrelated
1325file will silently overwrite recent fixes on main when squashed. Verify
1326with `git diff HEAD~1..HEAD` after merging — unexpected deletions are a
1327red flag.
1328
1329### Don't wire in dead code without E2E validation
1330Unused code that was never shipped was dead for a reason. Before wiring an
1331unused module into a live code path, E2E test the real resolution chain
1332with actual imports (not mocks) against a temp `HERMES_HOME`.
1333
1334### Tests must not write to `~/.hermes/`
1335The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests.
1336
1337**Profile tests**: When testing profile features, also mock `Path.home()` so that
1338`_get_profiles_root()` and `_get_default_hermes_home()` resolve within the temp dir.
1339Use the pattern from `tests/hermes_cli/test_profiles.py`:
1340```python
1341@pytest.fixture
1342def profile_env(tmp_path, monkeypatch):
1343 home = tmp_path / ".hermes"
1344 home.mkdir()
1345 monkeypatch.setattr(Path, "home", lambda: tmp_path)
1346 monkeypatch.setenv("HERMES_HOME", str(home))
1347 return home
1348```
1349
1350---
1351
1352## Testing
1353
1354### Python
1355**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
1356hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
1357per-file subprocess isolation via `scripts/run_tests_parallel.py` — no xdist,
1358worker count auto-scaled from CPU count). Direct `pytest`
1359on a 16+ core developer machine with API keys set diverges from CI in ways
1360that have caused multiple "works locally, fails in CI" incidents (and the reverse).
1361
1362```bash
1363scripts/run_tests.sh # full suite, CI-parity
1364scripts/run_tests.sh tests/gateway/ # one directory
1365scripts/run_tests.sh tests/agent/test_foo.py -k test_x # one test (file + -k; the runner is file-granular)
1366scripts/run_tests.sh -v --tb=long # pass-through pytest flags
1367```
1368
1369**Flake policy:** the runner auto-retries a failing test FILE once in a fresh
1370subprocess (`--file-retries`, default 1; `HERMES_TEST_FILE_RETRIES=0` to
1371disable). Pass-on-retry counts as green but is printed in a `⚠ FLAKY` summary
1372section with both attempts' output. A FLAKY report is a bug to fix, not noise
1373to ignore — timing-sensitive tests must not assume a quiet runner (loose
1374wall-clock bounds ≥ 2s, event-based sync, no `assert not _wait_until(...)`
1375negative-timing races).
1376
1377#### Subprocess-per-test-file isolation
1378
1379Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
1380ContextVars from one test file cannot leak into the next.
1381
1382#### Why the wrapper
1383
1384| | Without wrapper | With wrapper |
1385| ------------------- | ------------------------------------------- | ----------------------------------------- |
1386| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. |
1387| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
1388| Timezone | Local TZ (PDT etc.) | UTC |
1389| Locale | Whatever is set | C.UTF-8 |
1390
1391### Where to place what tests
1392
1393The CI change classifier (`scripts/ci/classify_changes.py`) runs specific jobs based on what files changed. A Python test that asserts
1394about the contents of `package.json`, `package-lock.json`, `.ts`/`.tsx`
1395source, or any other JS-side artifact will not run on a PR that only touches
1396those files. This means a regression can go green on a PR and red on `main` (where the
1397classifier fails open and runs everything).
1398
1399Any test that reads or asserts about `package.json`,
1400`package-lock.json`, `tsconfig.json`, `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs`
1401source files configuration belongs in the JS (vitest) test suite, not in `tests/*.py`.
1402
1403### Don't fake the host OS
1404
1405Hermes supports Linux, macOS and native Windows, and plenty of its behaviour
1406genuinely differs per host. Those differences are tested by running on the
1407host, not by patching `sys.platform`.
1408
1409```python
1410@pytest.mark.linux_only
1411@pytest.mark.macos_only
1412@pytest.mark.windows_only
1413```
1414
1415Things that are host-independent can stay unmarked:
1416
1417- **Pure functions that take a platform as data** —
1418 `hidden_windows_child_options(opts, is_windows=True)` is input→output, not a
1419 fake host. (Contrast: setting a module-level `IS_WINDOWS` flag and then
1420 calling `windows_detach_flags()` *is* a fake.)
1421- **Declaration/packaging invariants** — "pyproject declares `tzdata` with a
1422 `sys_platform == 'win32'` marker" asserts about a file, not about runtime.
1423
1424The line: **if the test needs the interpreter to believe it is on another OS
1425in order to pass, it belongs on that OS.**
1426When one test body walks several platforms in sequence, split it.
1427Keep the host-native arm on the Linux lane and move the other arm into its own marked test.
1428
1429**Use the marker, never a bare `skipif`.** `scripts/ci/list_os_marked_tests.py`
1430decides which files the macOS/Windows lanes import by grepping for the marker
1431*name*, and the lane then filters with `-m <marker>`. A test gated with
1432`@pytest.mark.skipif(sys.platform != "win32")` therefore skips on Linux AND is
1433never imported on the Windows lane — it runs on no host at all, silently. The
1434same trap catches a file-local alias (`windows_only = pytest.mark.skipif(...)`):
1435the grep matches the name, so the file *is* listed, but `-m windows_only`
1436deselects every test in it and the lane reports green over zero coverage.
1437Equally, don't `pytest.skip()` the non-host rows of a `@parametrize` over
1438platforms — split it into one marked test per OS, or only the host's row ever
1439executes.
1440
1441### Don't write change-detector tests
1442
1443A test is a **change-detector** if it fails whenever data that is **expected
1444to change** gets updated — model catalogs, config version numbers,
1445enumeration counts, hardcoded lists of provider models. These tests add no
1446behavioral coverage; they just guarantee that routine source updates break
1447CI and cost engineering time to "fix."
1448
1449**Do not write:**
1450
1451```python
1452# catalog snapshot — breaks every model release
1453assert "gemini-2.5-pro" in _PROVIDER_MODELS["gemini"]
1454assert "MiniMax-M2.7" in models
1455
1456# config version literal — breaks every schema bump
1457assert DEFAULT_CONFIG["_config_version"] == 21
1458
1459# enumeration count — breaks every time a skill/provider is added
1460assert len(_PROVIDER_MODELS["huggingface"]) == 8
1461```
1462
1463**Do write:**
1464
1465```python
1466# behavior: does the catalog plumbing work at all?
1467assert "gemini" in _PROVIDER_MODELS
1468assert len(_PROVIDER_MODELS["gemini"]) >= 1
1469
1470# behavior: does migration bump the user's version to current latest?
1471assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
1472
1473# invariant: no plan-only model leaks into the legacy list
1474assert not (set(moonshot_models) & coding_plan_only_models)
1475
1476# invariant: every model in the catalog has a context-length entry
1477for m in _PROVIDER_MODELS["huggingface"]:
1478 assert m.lower() in DEFAULT_CONTEXT_LENGTHS_LOWER
1479```
1480
1481The rule: if the test reads like a snapshot of current data, delete it. If
1482it reads like a contract about how two pieces of data must relate, keep it.
1483When a PR adds a new provider/model and you want a test, make the test
1484assert the relationship (e.g. "catalog entries all have context lengths"),
1485not the specific names.
1486
1487Reviewers should reject new change-detector tests; authors should convert
1488them into invariants before re-requesting review.
1489
1490### Never read source code in tests
1491
1492A test that reads a source file's text is testing *the shape of the
1493source code*, not its behavior. This is a hard antipattern, banned outright.
1494Any test that reads a .py, .ts, .tsx, etc., file is suspect.
1495
1496**Why it's actively harmful, not just weak:**
1497
1498- It passes when the implementation is subtly broken (the regex matches a
1499 call site that exists but is wired wrong) and fails when a correct
1500 refactor changes formatting, variable names, or control flow with
1501 identical runtime behavior. Both directions of failure are wrong.
1502- It can't be run against a built/bundled/minified artifact, so it silently
1503 stops testing anything the moment code moves, gets renamed, or a
1504 dependency reformats it.
1505- It actively blocks refactors: reviewers see "keeps a pattern intact" tests
1506 fail during pure structural cleanup with no behavior change, and either
1507 hand-wave the failure (dangerous) or waste time updating regexes that add
1508 nothing (waste).
1509- It gives false confidence. a green suite full of source-regex tests
1510 looks like coverage but has never once executed the code path it claims
1511 to guard.
1512
1513**Do not write:**
1514
1515```ts
1516const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
1517
1518test('backend spawn hides the Windows console', () => {
1519 assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
1520})
1521```
1522
1523**Do write — extract the logic into a small pure/DI-testable function and
1524call it for real:**
1525
1526```ts
1527// backend-spawn.ts
1528export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
1529 if (!isWindows || 'windowsHide' in options) return options
1530 return { ...options, windowsHide: true }
1531}
1532
1533// backend-spawn.test.ts
1534test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
1535 assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
1536 assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
1537 assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
1538})
1539```
1540
1541If the logic lives inline in a god-file (`main.ts`, `cli.py`,
1542`gateway/run.py`) and extracting it feels disruptive: that's the actual
1543signal to do the extraction, not to regex around it.
1544
NousResearch/hermes-agent · apps/desktop/AGENTS.md
@@ +1 @@
1# Desktop Engineering Guide
2
3How to build Hermes Desktop well. This is a judgment guide, not an inventory —
4it teaches the invariants and the reasoning behind them so a change fits the app
5even as files move. Read it with the repository `AGENTS.md` (root rules still
6apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract.
7
8When a rule here and the code disagree, trust the code and fix whichever is
9wrong — but never break an invariant to make a change easier.
10
11## What this app is
12
13Desktop is its own native chat surface. It is not the browser dashboard and it
14does not embed the TUI. Three parties, each authoritative for one thing:
15
16- **Electron** owns the machine: process lifecycle, native filesystem/git/
17 windows, install/update, and a narrow, typed capability bridge.
18- **The renderer** owns the experience: navigation, presentation, and ephemeral
19 interaction state.
20- **The agent backend** owns the work: sessions, tools, model calls, streaming.
21
22Keep the seams clean. The renderer never reaches for Node or Electron directly;
23native power arrives through a deliberate capability, not a general escape hatch.
24Agent behavior lives behind the gateway, never reimplemented in React. When a
25change blurs a seam, that is the smell — fix the seam, don't widen it.
26
27## Decide state by authority
28
29The first question for any piece of state is *who is allowed to be right about
30it*, not where it is convenient to store it. Put state with its authority:
31
32- The **backend** is authoritative for anything another Hermes surface can also
33 change. Treat the renderer's copy as a cache of that truth.
34- **Electron** is authoritative for machine and runtime facts.
35- The **renderer** owns only what is purely about this window's presentation.
36
37From that, everything else follows: shared renderer state lives in small stores
38owned by the feature that owns the concern; request-shaped server data that wants
39invalidation lives in the query layer; short-lived interaction detail stays in
40the component; hot coordination that must not paint stays in a ref. Reach for the
41narrowest home that still lets the state be correct. A new global store is a
42claim that many distant surfaces need it — earn that claim.
43
44Persisted state must declare its scope in its own key: is this global, or does it
45belong to a connection, a profile, a stored session, a project, or a window?
46Getting the scope wrong is how one profile's setting bleeds into another.
47
48## Identity is not incidental
49
50Sessions have more than one identity, and conflating them is a recurring source
51of "session not found" and vanishing history. Reason about which identity a
52surface needs: durable navigation and anything the user pins or persists key off
53the stable/durable identity; live streaming keys off the runtime identity; state
54that must outlive compression keys off the lineage root. Keep the mapping between
55them explicit and translate at the boundary rather than passing the wrong id
56inward.
57
58## Server truth is cached, not owned
59
60The renderer paints from a cache of backend truth, so it must reconcile, not
61assume:
62
63- **Merge, don't clobber.** A refresh is new information layered over what you
64 already know, not a replacement that can drop live or pinned rows.
65- **Be optimistic, then honest.** Direct manipulation should paint immediately
66 from a snapshot; a failed write rolls back visibly and an authoritative
67 refresh gets the last word.
68- **Guard against the past.** Async results can arrive out of order; a stale
69 response must never overwrite newer intent. Generation counters and request
70 tokens exist for this.
71- **Isolate the foreground.** Only the surface the user is looking at may publish
72 into the shared view; background work updates its own cache quietly.
73- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but
74 let terminal transitions (a turn finishing, needing input, failing) reach the
75 user immediately.
76- **Preserve reference identity on no-ops.** Handing React a fresh array that
77 contains the same data re-renders expensive trees for nothing.
78
79## Switching context is a re-home, not a reboot
80
81Changing profile, connection, or mode is a workspace switch, not a cold start.
82The shell and whatever the user was doing stay put; only the gateway-bound view
83is cleared and repopulated, and the previous context must not leak into the next
84one. Reserve the full-screen boot/connecting experience for a genuinely unusable
85backend.
86
87There are three distinct switch shapes, and conflating them is the classic bug:
88
89- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home:
90 shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query
91 invalidation alone cannot evict live session stores — wipe them.
92- A **runtime home change** (switching the underlying `HERMES_HOME` profile) is
93 a hard re-home: the window legitimately reloads and state resets by remount.
94- A **live profile swap** in the same window activates another profile's socket
95 while background profiles keep streaming; lists merge rather than wipe, and
96 only an explicit user selection starts a fresh foreground draft.
97
98Treating a soft switch as hard flickers the app; treating a hard one as soft
99strands stale rows. After any swap, the active socket, active profile, and
100connection atoms must agree, or REST and filesystem calls route to the wrong
101backend.
102
103## Cross everything as an observable ladder
104
105Desktop lives at the seams: versions, profiles, local vs remote vs cloud,
106partially installed runtimes, stale caches, older backends. The durable technique
107for all of it is the same — an ordered ladder of candidates:
108
1091. Precedence is written down, in one place, as data or a pure function.
1102. A candidate is trusted only after it is validated at the right boundary.
111 Existence is not proof; probe what you're about to rely on.
1123. A failed *read* falls to the next rung; a failed *authoritative write*
113 surfaces or rolls back rather than silently retargeting.
1144. A missing capability and a transient failure are different: the first may
115 enable a compatibility path or a disabled state; the second should retry.
1165. Retries are bounded and end in a real recovery affordance — never an infinite
117 spinner or a hot loop.
1186. One resolver owns each policy so every caller gets the same answer. Scatter is
119 how two call sites drift apart.
120
121This is the shape of backend discovery, command/version fallbacks, connection and
122auth resolution, workspace-cwd selection, capability detection, and preview
123normalization alike. Learn the shape, not a snapshot of the current rungs.
124
125Two auth-flavored corollaries worth naming because they are easy to get wrong:
126
127- **One-time credentials are never reused.** An OAuth gateway connection mints a
128 fresh WebSocket ticket on every dial and never falls back to the cached URL.
129 Only a confirmed 401/403 (or an explicitly tagged auth rejection) means
130 reauthentication; timeout, network, malformed-response, and server failures
131 remain connectivity errors. Only long-lived token/local auth may reuse a
132 cached URL as a lower rung.
133- **A connection test must exercise the leg you'll actually use.** An HTTP
134 status probe passing while the WebSocket/auth leg fails is a false positive
135 that ships as "it said connected but nothing works."
136
137## Compatibility without carrying the past forever
138
139Desktop and its runtime update on separate clocks, so a change can meet an older
140backend. Keep those users working: preserve the current feature, keep the
141fallback narrow and tied to an identified older runtime, and cover it with a
142test. A fallback that quietly degrades the feature it's meant to protect is worse
143than the crash it replaced.
144
145## Keep the waist narrow, grow at the edges
146
147The root contribution rubric governs here too. New capability should arrive at
148the smallest surface that solves it: extend what exists, add a feature locally,
149lean on an existing seam — before you invent a framework. The shell's internal
150registries are composition seams, not a public plugin ABI; do not build a
151universal extension system, a manifest, or a plugin adapter for a single
152consumer. Design a shared contract only once more than one real consumer proves
153its shape. "Plugin" means several unrelated things across Hermes — do not assume
154one surface's extension model runs in another.
155
156When the new capability is an **agent-callable** one — a tool that acts on this
157renderer (open a pane, read the in-app browser, react to a message) — it is a
158property of the SESSION's client, not of the backend host. Wire its
159availability off the session source the app already sends on `session.create`
160(`source: 'desktop'`), never off an env var on the backend process: that
161process might be a remote or cloud gateway this app merely connected to. See
162the root AGENTS.md, "Surface capability is a property of the SESSION."
163
164## Respect the person using it
165
166Design and engineering meet at intent. The user's attention and context are
167sacred:
168
169- Never navigate, move focus, or open a surface because something *happened* in
170 the background. Offer; don't hijack.
171- The states around loading are distinct experiences — empty, loading,
172 reconnecting, degraded/stale, and exhausted-recovery each deserve their own
173 honest copy and their own way out.
174- Keyboard ownership follows focus. The focused surface wins its keys; one
175 cancel gesture does exactly one thing.
176- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden.
177 Visibility is not lifecycle.
178
179## Make it feel instant
180
181Performance is a feature the user feels, especially in drag, resize, scroll,
182typing, streaming, and terminals. The principles are timeless even as the code
183changes: keep hot-path state local or narrowly derived; don't subscribe heavy
184trees to per-frame updates; coalesce pointer work; avoid reading layout right
185after writing style; and don't mount expensive content mid-gesture. Prove speed
186against realistic content — a fast empty demo proves nothing about a long
187transcript. If motion is masking latency, remove the motion, don't tune it.
188
189## Testing as a habit of proof
190
191Test the behavior that would actually break a user, not a snapshot of today's
192data. Favor invariants over frozen values. Exercise the real path for anything
193at a seam — resolver precedence and its failure rungs, identity and scope
194boundaries, optimistic rollback and stale-response ordering, and both sides of a
195local/remote adapter with its profile routing intact. Match how the suite is
196actually run rather than inventing a command; when in doubt, read the scripts.
197
198## The taste test before you hand off
199
200- Does every piece of state live with its authority, at the narrowest scope?
201- Would a background event ever steal the foreground or the user's focus?
202- Does each resolver have one home, a validated ladder, and a bounded, recoverable
203 end?
204- Do local, remote, and profile routing still agree?
205- Does async failure leave a usable UI and a way forward?
206- Do hot interactions stay cheap under realistic load?
207- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all
208 locales?
209
210If any answer is "not sure," that's the part to go verify.
211
@@ −1 +1 @@
1−# Hermes Agent - Development Guide
1+# Desktop Engineering Guide
22
3−Instructions for AI coding assistants and developers working on the hermes-agent codebase.
3+How to build Hermes Desktop well. This is a judgment guide, not an inventory —
4+it teaches the invariants and the reasoning behind them so a change fits the app
5+even as files move. Read it with the repository `AGENTS.md` (root rules still
6+apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract.
47
5−**Never give up on the right solution.**
8+When a rule here and the code disagree, trust the code and fix whichever is
9+wrong — but never break an invariant to make a change easier.
610
7−## What Hermes Is
11+## What this app is
812
9−Hermes is a personal AI agent that runs the same agent core across a CLI, a
10−messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI,
11−and an Electron desktop app. It learns across sessions (memory + skills),
12−delegates to subagents, runs scheduled jobs, and drives a real terminal and
13−browser. It is extended primarily through **plugins and skills**, not by
14−growing the core.
13+Desktop is its own native chat surface. It is not the browser dashboard and it
14+does not embed the TUI. Three parties, each authoritative for one thing:
1515
16−Two properties shape almost every design decision and are the lens for
17−reviewing any change:
16+- **Electron** owns the machine: process lifecycle, native filesystem/git/
17+ windows, install/update, and a narrow, typed capability bridge.
18+- **The renderer** owns the experience: navigation, presentation, and ephemeral
19+ interaction state.
20+- **The agent backend** owns the work: sessions, tools, model calls, streaming.
1821
19−- **Per-conversation prompt caching is sacred.** A long-lived conversation
20− reuses a cached prefix every turn. Anything that mutates past context,
21− swaps toolsets, or rebuilds the system prompt mid-conversation invalidates
22− that cache and multiplies the user's cost. We do not do it (the one
23− exception is context compression).
24−- **The core is a narrow waist; capability lives at the edges.** Every model
25− tool we add is sent on every API call, so the bar for a new *core* tool is
26− high. Most new capability should arrive as a CLI command + skill, a
27− service-gated tool, or a plugin — not as core surface.
22+Keep the seams clean. The renderer never reaches for Node or Electron directly;
23+native power arrives through a deliberate capability, not a general escape hatch.
24+Agent behavior lives behind the gateway, never reimplemented in React. When a
25+change blurs a seam, that is the smell — fix the seam, don't widen it.
2826
29−## Contribution Rubric — What We Want / What We Don't
27+## Decide state by authority
3028
31−This is the project's intent layer. Use it two ways:
29+The first question for any piece of state is *who is allowed to be right about
30+it*, not where it is convenient to store it. Put state with its authority:
3231
33−1. **For humans and for your own work** — what gets merged and what gets
34− rejected, so a contribution aims at the target.
35−2. **For automated review (the triage sweeper)** — guidance on when a PR is
36− safe to close on the three allowed reasons (`implemented_on_main`,
37− `cannot_reproduce`, `incoherent`) and, just as important, **when NOT to
38− close** one. Taste-based "we don't want this / out of scope" closes are NOT
39− an automated decision — those stay with a human maintainer. The sweeper's
40− job here is to recognize design intent and *avoid wrongly closing a
41− legitimate contribution*, not to make the won't-implement call itself.
32+- The **backend** is authoritative for anything another Hermes surface can also
33+ change. Treat the renderer's copy as a cache of that truth.
34+- **Electron** is authoritative for machine and runtime facts.
35+- The **renderer** owns only what is purely about this window's presentation.
4236
43−Read the balance right: Hermes ships a **lot** — most merges are bug fixes to
44−real reported behavior, and the product surface (platforms, channels,
45−providers, models, desktop/TUI features) expands aggressively and on purpose.
46−The restraint below is aimed squarely at the **core agent + the model tool
47−schema**, the one place where every addition is paid for on every API call.
48−"Smallest footprint" governs *how a capability is wired into the core*, NOT
49−whether the product is allowed to grow. We are expansive at the edges and
50−conservative at the waist.
37+From that, everything else follows: shared renderer state lives in small stores
38+owned by the feature that owns the concern; request-shaped server data that wants
39+invalidation lives in the query layer; short-lived interaction detail stays in
40+the component; hot coordination that must not paint stays in a ref. Reach for the
41+narrowest home that still lets the state be correct. A new global store is a
42+claim that many distant surfaces need it — earn that claim.
5143
52−### What we want
44+Persisted state must declare its scope in its own key: is this global, or does it
45+belong to a connection, a profile, a stored session, a project, or a window?
46+Getting the scope wrong is how one profile's setting bleeds into another.
5347
54−- **Fix real bugs, well.** The bulk of what lands is `fix(...)` against an
55− actual reported symptom. A good fix reproduces the symptom on current
56− `main`, points to the exact line where it manifests, and fixes the whole bug
57− class — sibling call paths included — not just the one site the reporter hit.
58−- **Expand reach at the edges.** New platform adapters, channels, providers,
59− models, and desktop/TUI/dashboard features are welcome and land routinely,
60− including large ones (a new messaging channel, a session-cap feature, a
61− Windows PTY bridge). Breadth in the product is a goal, not a footprint
62− concern — as long as it integrates with the existing setup/config UX
63− (`hermes tools`, `hermes setup`, auto-install) rather than bolting on a raw
64− env var.
65−- **Refactor god-files into clean modules.** Extracting a multi-thousand-line
66− cluster out of `cli.py` / `run_agent.py` / `gateway/run.py` into a focused
67− mixin or module is wanted work, even when the diff is huge and mechanical
68− (large `+N/-N` refactors merge regularly). The "every line traces to the
69− request" test applies to *feature* PRs; a declared refactor's request IS the
70− extraction.
71−- **Keep the core narrow.** New *model tools* are the expensive exception —
72− every tool ships on every API call. Prefer, in order: extend existing code →
73− CLI command + skill → service-gated tool (`check_fn`) → plugin → MCP server
74− in the catalog → new core tool (last resort). See "The Footprint Ladder."
75−- **Extend, don't duplicate.** Before adding a module/manager/hook, check
76− whether existing infrastructure already covers the use case. When several PRs
77− integrate the same *category*, design one shared interface instead of merging
78− them one at a time (see the ABC + orchestrator note under the Footprint
79− Ladder).
80−- **Behavior contracts over snapshots.** Tests should assert how two pieces of
81− data must relate (invariants), not freeze a current value (model lists,
82− config version literals, enumeration counts). See "Don't write
83− change-detector tests."
84−- **E2E validation, not just green unit mocks.** For anything touching
85− resolution chains, config propagation, security boundaries, remote
86− backends, or file/network I/O, exercise the real path with real imports
87− against a temp `HERMES_HOME`. Mocks hide integration bugs.
88−- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict
89− message role alternation (never two same-role messages in a row; never a
90− synthetic user message injected mid-loop), and a system prompt that is
91− byte-stable for the life of a conversation.
92−- **Contributor credit preserved.** Salvage external work by cherry-picking
93− (rebase-merge) so authorship survives in git history; don't reimplement from
94− scratch when you can build on top.
48+## Identity is not incidental
9549
96−### What we don't want (rejected even when well-built)
50+Sessions have more than one identity, and conflating them is a recurring source
51+of "session not found" and vanishing history. Reason about which identity a
52+surface needs: durable navigation and anything the user pins or persists key off
53+the stable/durable identity; live streaming keys off the runtime identity; state
54+that must outlive compression keys off the lineage root. Keep the mapping between
55+them explicit and translate at the boundary rather than passing the wrong id
56+inward.
9757
98−- **Speculative infrastructure.** Hooks, callbacks, or extension points with no
99− concrete consumer. Adding a hook is easy; removing one after plugins depend
100− on it is hard. A hook is NOT speculative if a contributor has a real, stated
101− use case — even if the consumer ships separately.
102−- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets
103− only (API keys, tokens, passwords). All behavioral settings — timeouts,
104− thresholds, feature flags, display prefs — go in `config.yaml`. Bridge to an
105− internal env var if the mechanism needs one, but user-facing docs point to
106− `config.yaml`. Reject PRs that tell users to "set X in your .env" unless X
107− is a credential.
108−- **A new core tool when terminal + file already do the job, or when a skill
109− would.** If the only barrier is file visibility on a remote backend, fix the
110− mount, not the toolset.
111−- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit`
112− pagination on tools that load content the agent must read fully (skills,
113− prompts, playbooks). Models will read page 1 and skip the rest.
114−- **"Fixes" that destroy the feature they secure.** A mitigation that kills the
115− feature's purpose is the wrong mitigation. Read the original commit's intent
116− (`git log -p -S`) before restricting behavior; find a fix that preserves the
117− feature.
118−- **Outbound telemetry / usage attribution without opt-in gating.** No new
119− analytics, third-party identifier tagging, or attribution tags until a
120− generic user-facing opt-in (config gate + setup prompt + `hermes tools`
121− toggle) exists. Park behind a label, do not merge.
122−- **Change-detector tests, cache-breaking mid-conversation, dead code wired in
123− without E2E proof, and plugins that touch core files.** Plugins live in their
124− own directory and work within the ABCs/hooks we provide; if a plugin needs
125− more, widen the generic plugin surface, don't special-case it in core.
126−- **Third-party products / other people's projects integrated into the core
127− tree.** Observability backends, vendor SaaS integrations, analytics dashboards,
128− and similar "someone else's product" plugins do NOT land under `plugins/` in
129− this repo. They place an ongoing maintenance burden on us to keep them working
130− against a fast-moving core, for a backend we don't own. Ship them as a
131− **standalone plugin repo** users install into `~/.hermes/plugins/` (or via a
132− pip entry point), and promote them in the Nous Research Discord
133− (`#plugins-skills-and-skins`). This is a coupling-and-maintenance decision, not
134− a quality bar — the plugin can be excellent and still be a close. PRs that add
135− such a directory to the tree are closed with a pointer to publish it as its own
136− repo.
58+## Server truth is cached, not owned
13759
138−### Before you call it a bug — verify the premise (and when NOT to close)
60+The renderer paints from a cache of backend truth, so it must reconcile, not
61+assume:
13962
140−The most common reason a well-written PR gets closed is not code quality — it
141−is that the change is built on a **wrong premise**, or it treats an
142−**intentional design as a gap**. These patterns cut both ways: they tell a
143−human reviewer what to scrutinize, and they tell the automated sweeper when a
144−PR is NOT safe to close as `implemented_on_main` / `cannot_reproduce` (when in
145−doubt, leave it open for a human). They are distilled from real closes.
63+- **Merge, don't clobber.** A refresh is new information layered over what you
64+ already know, not a replacement that can drop live or pinned rows.
65+- **Be optimistic, then honest.** Direct manipulation should paint immediately
66+ from a snapshot; a failed write rolls back visibly and an authoritative
67+ refresh gets the last word.
68+- **Guard against the past.** Async results can arrive out of order; a stale
69+ response must never overwrite newer intent. Generation counters and request
70+ tokens exist for this.
71+- **Isolate the foreground.** Only the surface the user is looking at may publish
72+ into the shared view; background work updates its own cache quietly.
73+- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but
74+ let terminal transitions (a turn finishing, needing input, failing) reach the
75+ user immediately.
76+- **Preserve reference identity on no-ops.** Handing React a fresh array that
77+ contains the same data re-renders expensive trees for nothing.
14678
147−- **"Intentional design, not a gap."** A limitation that looks like an
148− oversight is often deliberate. Before "fixing" a missing link or a
149− restriction, ask whether the isolation IS the design. Example: profiles are
150− independent islands on purpose — a PR adding live config inheritance from the
151− default profile was closed because coupling profiles together is exactly what
152− the design prevents (the copy-at-creation `--clone` path already covers the
153− legitimate "start from my default" case). Read the original commit's intent
154− (`git log -p -S "<symbol>"`) before assuming something is unfinished.
155−- **"The premise doesn't hold against how X actually works."** A PR's
156− justification frequently rests on a wrong mental model of an existing
157− mechanism. Trace the real code/runtime before accepting the rationale. Two
158− real closes: a rate-limit "re-probe during cooldown" PR (the breaker only
159− trips on a *confirmed-empty* account bucket, so re-probing just hammers a
160− bucket we've already proven empty); a usage-accumulation fix whose new branch
161− **never executes at runtime** because an earlier guard already popped the
162− state it depended on. If you can't point to the exact line where the bug
163− manifests AND show the fix changes that line's behavior, you haven't verified
164− the premise.
165−- **"This fix was wrong — the absence/omission was deliberate."** Adding the
166− obvious-looking missing piece can break things the omission was protecting.
167− Example: restoring "missing" `__init__.py` files made a test tree importable
168− as a dotted package that shadowed the real plugin, deleting its `register()`
169− at import time. The absence was load-bearing.
170−- **"Overreached / resurrected an approach we'd moved past."** Scope creep that
171− supersedes an agreed-on base, or revives a direction the maintainers
172− deliberately closed, gets rejected even when the code works. Keep the change
173− to the narrow piece that was actually agreed; offer the rest as a focused
174− follow-up.
79+## Switching context is a re-home, not a reboot
17580
176−The throughline: **verify the claim AND the intent against the codebase before
177−writing or merging a fix.** A confirmed reproduction on current `main` plus a
178−line-level account of where the fix acts beats a plausible-sounding rationale
179−every time. When in doubt about intent, it is cheaper to ask than to ship a
180−fix that fights the design.
81+Changing profile, connection, or mode is a workspace switch, not a cold start.
82+The shell and whatever the user was doing stay put; only the gateway-bound view
83+is cleared and repopulated, and the previous context must not leak into the next
84+one. Reserve the full-screen boot/connecting experience for a genuinely unusable
85+backend.
18186
182−### The Footprint Ladder (new capability decision)
87+There are three distinct switch shapes, and conflating them is the classic bug:
18388
184−Each rung adds more permanent surface than the one above. Choose the highest
185−(least-footprint) rung that correctly solves the problem:
89+- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home:
90+ shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query
91+ invalidation alone cannot evict live session stores — wipe them.
92+- A **runtime home change** (switching the underlying `HERMES_HOME` profile) is
93+ a hard re-home: the window legitimately reloads and state resets by remount.
94+- A **live profile swap** in the same window activates another profile's socket
95+ while background profiles keep streaming; lists merge rather than wipe, and
96+ only an explicit user selection starts a fresh foreground draft.
18697
187−1. **Extend existing code** — the capability is a variation of something that
188− already exists. Zero new surface.
189−2. **CLI command + skill** — manages config/state/infra expressible as shell
190− commands. The agent runs `hermes <subcommand>` guided by a skill. Zero
191− model-tool footprint. Default choice for subscriptions, scheduled tasks,
192− service setup. Examples: `hermes webhook`, `hermes cron`, `hermes tools`.
193−3. **Service-gated tool (`check_fn`)** — needs structured params/returns AND
194− only appears when a prerequisite is configured. Zero footprint otherwise.
195− Examples: Home Assistant tools (gated on token), memory-provider tools.
196−4. **Plugin** — third-party/niche/user-specific capability that doesn't ship in
197− core. Lives in `~/.hermes/plugins/` or a pip package, discovered at runtime.
198−5. **MCP server (in the catalog)** — if the capability genuinely needs to be a
199− tool (structured I/O the agent invokes) but isn't core-fundamental, prefer
200− building it as an MCP server and adding it to the MCP catalog over growing
201− the core toolset. The agent connects to it through the built-in MCP client;
202− zero permanent core-schema footprint, and it's reusable by any MCP host.
203−6. **New core tool** — only when the capability is fundamental, broadly useful
204− to nearly every user, and unreachable via terminal + file (or an MCP server).
205− Examples of correct core tools: terminal, read_file, web_search,
206− browser_navigate.
98+Treating a soft switch as hard flickers the app; treating a hard one as soft
99+strands stale rows. After any swap, the active socket, active profile, and
100+connection atoms must agree, or REST and filesystem calls route to the wrong
101+backend.
207102
208−When 3+ open PRs try to integrate the same *category* of thing (memory
209−backends, providers, notifiers), don't merge them one at a time — design an
210−ABC + orchestrator, wrap the existing built-in as the first provider, and turn
211−the competing PRs into plugins against that interface.
103+## Cross everything as an observable ladder
212104
213−### Surface capability is a property of the SESSION, never of the process env
105+Desktop lives at the seams: versions, profiles, local vs remote vs cloud,
106+partially installed runtimes, stale caches, older backends. The durable technique
107+for all of it is the same — an ordered ladder of candidates:
214108
215−A tool that only works because of *who is on the other end of the connection* —
216−the desktop app's panes, the in-app browser, message reactions, Projects — must
217−resolve its availability from the **session's own source**, not from an env var
218−on the backend process.
109+1. Precedence is written down, in one place, as data or a pure function.
110+2. A candidate is trusted only after it is validated at the right boundary.
111+ Existence is not proof; probe what you're about to rely on.
112+3. A failed *read* falls to the next rung; a failed *authoritative write*
113+ surfaces or rolls back rather than silently retargeting.
114+4. A missing capability and a transient failure are different: the first may
115+ enable a compatibility path or a disabled state; the second should retry.
116+5. Retries are bounded and end in a real recovery affordance — never an infinite
117+ spinner or a hot loop.
118+6. One resolver owns each policy so every caller gets the same answer. Scatter is
119+ how two call sites drift apart.
219120
220−The client and the backend are separate machines on separate clocks. The
221−desktop app can be driving a backend Electron spawned locally, one over SSH,
222−one behind a plain URL + token, or Hermes Cloud. Only the first two are spawned
223−by us and carry `HERMES_DESKTOP=1`. Every env-keyed GUI gate is therefore a
224−silent no-op on the other half of the topologies, and the failure is invisible:
225−the tool is stripped from the schema before the model ever sees it, on the same
226−backend whose platform hint is telling the model it's *"chatting inside the
227−Hermes desktop app."*
121+This is the shape of backend discovery, command/version fallbacks, connection and
122+auth resolution, workspace-cwd selection, capability detection, and preview
123+normalization alike. Learn the shape, not a snapshot of the current rungs.
228124
229−The pattern that works:
125+Two auth-flavored corollaries worth naming because they are easy to get wrong:
230126
231−- **The toolset is the surface gate.** Keep the tools off `_HERMES_CORE_TOOLS`
232− (nobody else should pay their schema) and put them in a named toolset —
233− `desktop_ui`, `project`. The GUI gateway's `_load_enabled_toolsets(platform)`
234− folds that toolset in when the session's platform says GUI. One resolver,
235− every topology.
236−- **`check_fn` answers reachability or user opt-in, not surface.** "Is the
237− renderer bridge wired?", "did the user enable reactions?" — fine. "Was I
238− spawned by Electron?" — not fine. `check_fn` results are also TTL-cached
239− process-wide (`tools/registry.py`), so a per-session answer does not belong
240− there at all: one process serves many sessions.
241−- **Ask which identity you actually mean.** `HERMES_DESKTOP=1` legitimately
242− marks *"this backend process was spawned by the app"* — it gates the cron
243− ticker and web-dist handling correctly. It does NOT mean "a GUI is watching",
244− and the embedded terminal pane (`hermes --tui` against that same backend) is
245− the standing counterexample.
127+- **One-time credentials are never reused.** An OAuth gateway connection mints a
128+ fresh WebSocket ticket on every dial and never falls back to the cached URL.
129+ Only a confirmed 401/403 (or an explicitly tagged auth rejection) means
130+ reauthentication; timeout, network, malformed-response, and server failures
131+ remain connectivity errors. Only long-lived token/local auth may reuse a
132+ cached URL as a lower rung.
133+- **A connection test must exercise the leg you'll actually use.** An HTTP
134+ status probe passing while the WebSocket/auth leg fails is a false positive
135+ that ships as "it said connected but nothing works."
246136
247−Same test both ways: if the capability would still make sense with the client
248−on another machine, it is session-scoped. Cover it with a test that asserts the
249−GUI session gets the tool **with the env var absent** — that's the assertion
250−the original gate could never have passed.
137+## Compatibility without carrying the past forever
251138
252−## Development Environment
139+Desktop and its runtime update on separate clocks, so a change can meet an older
140+backend. Keep those users working: preserve the current feature, keep the
141+fallback narrow and tied to an identified older runtime, and cover it with a
142+test. A fallback that quietly degrades the feature it's meant to protect is worse
143+than the crash it replaced.
253144
254−```bash
255−# Prefer .venv; fall back to venv if that's what your checkout has.
256−source .venv/bin/activate # or: source venv/bin/activate
257−```
145+## Keep the waist narrow, grow at the edges
258146
259−`scripts/run_tests.sh` probes `.venv` first, then `venv`, then
260−`$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the
261−main checkout).
147+The root contribution rubric governs here too. New capability should arrive at
148+the smallest surface that solves it: extend what exists, add a feature locally,
149+lean on an existing seam — before you invent a framework. The shell's internal
150+registries are composition seams, not a public plugin ABI; do not build a
151+universal extension system, a manifest, or a plugin adapter for a single
152+consumer. Design a shared contract only once more than one real consumer proves
153+its shape. "Plugin" means several unrelated things across Hermes — do not assume
154+one surface's extension model runs in another.
262155
263−## Project Structure
156+When the new capability is an **agent-callable** one — a tool that acts on this
157+renderer (open a pane, read the in-app browser, react to a message) — it is a
158+property of the SESSION's client, not of the backend host. Wire its
159+availability off the session source the app already sends on `session.create`
160+(`source: 'desktop'`), never off an env var on the backend process: that
161+process might be a remote or cloud gateway this app merely connected to. See
162+the root AGENTS.md, "Surface capability is a property of the SESSION."
264163
265−File counts shift constantly — don't treat the tree below as exhaustive.
266−The canonical source is the filesystem. The notes call out the load-bearing
267−entry points you'll actually edit.
164+## Respect the person using it
268165
269−```
270−hermes-agent/
271−├── run_agent.py # AIAgent class — core conversation loop (~12k LOC)
272−├── model_tools.py # Tool orchestration, discover_builtin_tools(), handle_function_call()
273−├── toolsets.py # Toolset definitions, _HERMES_CORE_TOOLS list
274−├── cli.py # HermesCLI class — interactive CLI orchestrator (~11k LOC)
275−├── hermes_state.py # SessionDB — SQLite session store (FTS5 search)
276−├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths
277−├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
278−├── batch_runner.py # Parallel batch processing
279−├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
280−├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
281−├── tools/ # Tool implementations — auto-discovered via tools/registry.py
282−│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
283−├── gateway/ # Messaging gateway — run.py + session.py + platforms/
284−│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
285−│ │ # homeassistant, signal, matrix, mattermost, email, sms,
286−│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
287−│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
288−│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
289−├── plugins/ # Plugin system (see "Plugins" section below)
290−│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
291−│ ├── context_engine/ # Context-engine plugins
292−│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
293−│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
294−│ ├── hermes-achievements/ # Gamified achievement tracking
295−│ ├── observability/ # Metrics / traces / logs plugin
296−│ ├── image_gen/ # Image-generation providers
297−│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
298−│ # strike-freedom-cockpit, ...
299−├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
300−├── skills/ # Built-in skills bundled with the repo
301−├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
302−│ └── src/ # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib
303−├── tui_gateway/ # Python JSON-RPC backend for the TUI
304−├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
305−├── cron/ # Scheduler — jobs.py, scheduler.py
306−├── scripts/ # run_tests.sh, release.py, auxiliary scripts
307−├── website/ # Docusaurus docs site
308−└── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026)
309−```
166+Design and engineering meet at intent. The user's attention and context are
167+sacred:
310168
311−**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
312−**Logs:** `~/.hermes/logs/` — `agent.log` (INFO+), `errors.log` (WARNING+),
313−`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
314−Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
169+- Never navigate, move focus, or open a surface because something *happened* in
170+ the background. Offer; don't hijack.
171+- The states around loading are distinct experiences — empty, loading,
172+ reconnecting, degraded/stale, and exhausted-recovery each deserve their own
173+ honest copy and their own way out.
174+- Keyboard ownership follows focus. The focused surface wins its keys; one
175+ cancel gesture does exactly one thing.
176+- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden.
177+ Visibility is not lifecycle.
315178
316−## TypeScript Style
179+## Make it feel instant
317180
318−Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
181+Performance is a feature the user feels, especially in drag, resize, scroll,
182+typing, streaming, and terminals. The principles are timeless even as the code
183+changes: keep hot-path state local or narrowly derived; don't subscribe heavy
184+trees to per-frame updates; coalesce pointer work; avoid reading layout right
185+after writing style; and don't mount expensive content mid-gesture. Prove speed
186+against realistic content — a fast empty demo proves nothing about a long
187+transcript. If motion is masking latency, remove the motion, don't tune it.
319188
320−- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
321−- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
322−- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
323−- Do not pass state through three components when the leaf can subscribe to the atom.
324−- Keep persistence beside the atom that owns it.
325−- Keep route roots thin. They compose routes and shell; they should not become controllers.
326−- No monolithic hooks. A hook should own one narrow job.
327−- Prefer colocated action modules over hidden god hooks.
328−- If a callback is pure side effect, use the terse void form:
329− `onState={st => void setGatewayState(st)}`.
330−- Async UI handlers should make intent explicit:
331− `onClick={() => void save()}`.
332−- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
333−- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
334−- Table-driven beats condition ladders when mapping ids, routes, or views.
335−- `src/app` owns routes, pages, and page-specific components.
336−- `src/store` owns shared atoms.
337−- `src/lib` owns shared pure helpers.
189+## Testing as a habit of proof
338190
339−## File Dependency Chain
191+Test the behavior that would actually break a user, not a snapshot of today's
192+data. Favor invariants over frozen values. Exercise the real path for anything
193+at a seam — resolver precedence and its failure rungs, identity and scope
194+boundaries, optimistic rollback and stale-response ordering, and both sides of a
195+local/remote adapter with its profile routing intact. Match how the suite is
196+actually run rather than inventing a command; when in doubt, read the scripts.
340197
341−```
342−tools/registry.py (no deps — imported by all tool files)
343− ↑
344−tools/*.py (each calls registry.register() at import time)
345− ↑
346−model_tools.py (imports tools/registry + triggers tool discovery)
347− ↑
348−run_agent.py, cli.py, batch_runner.py, environments/
349−```
198+## The taste test before you hand off
350199
351−---
200+- Does every piece of state live with its authority, at the narrowest scope?
201+- Would a background event ever steal the foreground or the user's focus?
202+- Does each resolver have one home, a validated ladder, and a bounded, recoverable
203+ end?
204+- Do local, remote, and profile routing still agree?
205+- Does async failure leave a usable UI and a way forward?
206+- Do hot interactions stay cheap under realistic load?
207+- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all
208+ locales?
352209
353−## AIAgent Class (run_agent.py)
354−
355−The real `AIAgent.__init__` takes ~60 parameters (credentials, routing, callbacks,
356−session context, budget, credential pool, etc.). The signature below is the
357−minimum subset you'll usually touch — read `run_agent.py` for the full list.
358−
359−```python
360−class AIAgent:
361− def __init__(self,
362− base_url: str = None,
363− api_key: str = None,
364− provider: str = None,
365− api_mode: str = None, # "chat_completions" | "codex_responses" | ...
366− model: str = "", # empty → resolved from config/provider later
367− max_iterations: int = 500, # tool-calling iterations (shared with subagents)
368− enabled_toolsets: list = None,
369− disabled_toolsets: list = None,
370− quiet_mode: bool = False,
371− save_trajectories: bool = False,
372− platform: str = None, # "cli", "telegram", etc.
373− session_id: str = None,
374− skip_context_files: bool = False,
375− skip_memory: bool = False,
376− credential_pool=None,
377− # ... plus callbacks, thread/user/chat IDs, iteration_budget, fallback_model,
378− # checkpoints config, prefill_messages, service_tier, reasoning_config, etc.
379− ): ...
380−
381− def chat(self, message: str) -> str:
382− """Simple interface — returns final response string."""
383−
384− def run_conversation(self, user_message: str, system_message: str = None,
385− conversation_history: list = None, task_id: str = None) -> dict:
386− """Full interface — returns dict with final_response + messages."""
387−```
388−
389−### Agent Loop
390−
391−The core loop is inside `run_conversation()` — entirely synchronous, with
392−interrupt checks, budget tracking, and a one-turn grace call:
393−
394−```python
395−while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
396− or self._budget_grace_call:
397− if self._interrupt_requested: break
398− response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
399− if response.tool_calls:
400− for tool_call in response.tool_calls:
401− result = handle_function_call(tool_call.name, tool_call.args, task_id)
402− messages.append(tool_result_message(result))
403− api_call_count += 1
404− else:
405− return response.content
406−```
407−
408−Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`.
409−Reasoning content is stored in `assistant_msg["reasoning"]`.
410−
411−---
412−
413−## CLI Architecture (cli.py)
414−
415−- **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete
416−- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
417−- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
418−- **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
419−- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry
420−- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching
421−
422−### Slash Command Registry (`hermes_cli/commands.py`)
423−
424−All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically:
425−
426−- **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name
427−- **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch
428−- **Gateway help** — `gateway_help_lines()` generates `/help` output
429−- **Telegram** — `telegram_bot_commands()` generates the BotCommand menu
430−- **Slack** — `slack_subcommand_map()` generates `/hermes` subcommand routing
431−- **Autocomplete** — `COMMANDS` flat dict feeds `SlashCommandCompleter`
432−- **CLI help** — `COMMANDS_BY_CATEGORY` dict feeds `show_help()`
433−
434−### Adding a Slash Command
435−
436−1. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
437−```python
438−CommandDef("mycommand", "Description of what it does", "Session",
439− aliases=("mc",), args_hint="[arg]"),
440−```
441−2. Add handler in `HermesCLI.process_command()` in `cli.py`:
442−```python
443−elif canonical == "mycommand":
444− self._handle_mycommand(cmd_original)
445−```
446−3. If the command is available in the gateway, add a handler in `gateway/run.py`:
447−```python
448−if canonical == "mycommand":
449− return await self._handle_mycommand(event)
450−```
451−4. For persistent settings, use `save_config_value()` in `cli.py`
452−
453−**CommandDef fields:**
454−- `name` — canonical name without slash (e.g. `"background"`)
455−- `description` — human-readable description
456−- `category` — one of `"Session"`, `"Configuration"`, `"Tools & Skills"`, `"Info"`, `"Exit"`
457−- `aliases` — tuple of alternative names (e.g. `("bg",)`)
458−- `args_hint` — argument placeholder shown in help (e.g. `"<prompt>"`, `"[name]"`)
459−- `cli_only` — only available in the interactive CLI
460−- `gateway_only` — only available in messaging platforms
461−- `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.
462−
463−**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.
464−
465−---
466−
467−## TUI Architecture (ui-tui + tui_gateway)
468−
469−The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`.
470−
471−### Process Model
472−
473−```
474−hermes --tui
475− └─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway)
476− │ └─ AIAgent + tools + sessions
477− └─ renders transcript, composer, prompts, activity
478−```
479−
480−TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic.
481−
482−### Transport
483−
484−Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog.
485−
486−### Key Surfaces
487−
488−| Surface | Ink component | Gateway method |
489−|---------|---------------|----------------|
490−| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` |
491−| Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
492−| Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` |
493−| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
494−| Session picker | `sessionPicker.tsx` | `session.list/resume` |
495−| Slash commands | Local handler + fallthrough | `slash.exec` → `_SlashWorker`, `command.dispatch` |
496−| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
497−| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
498−
499−### Slash Command Flow
500−
501−1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx`
502−2. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback
503−
504−### Dev Commands
505−
506−```bash
507−cd ui-tui
508−npm install # first time
509−npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
510−npm start # production
511−npm run build # full build (hermes-ink + tsc)
512−npm run typecheck # typecheck only (tsc --noEmit)
513−npm run lint # eslint
514−npm run fmt # prettier
515−npm test # vitest
516−```
517−
518−### TUI in the Dashboard (`hermes dashboard` → `/chat`)
519−
520−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`.
521−
522−- 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.
523−- `/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).
524−- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not).
525−- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:<cols>;<rows>]` intercepted on the server and applied with `TIOCSWINSZ`.
526−
527−**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.
528−
529−**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.
530−
531−### Electron Desktop Chat App (`apps/desktop/`)
532−
533−A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.ts` + `backendSupportsServe()` in `electron/main.ts`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. For scoped Desktop architecture, state, resolver, transport, and testing rules, read `apps/desktop/AGENTS.md`.
534−
535−**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
536−
537−- **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills.
538−- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMAND_SPECS` (the built-ins and their Desktop surfaces) plus `NO_DESKTOP_SURFACE` block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
539− - `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
540− - `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`.
541− - `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.)
542−- **Dispatch** lives in `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
543−
544−**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: from `apps/desktop`, run `npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace dependencies are installed at the repo root).
545−
546−---
547−
548−## Adding New Tools
549−
550−Before adding any tool, settle the footprint question first (see "The
551−Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
552−be core tools. For custom or local-only tools, do **not** edit Hermes core.
553−Use the plugin route instead: create `~/.hermes/plugins/<name>/plugin.yaml`
554−and `~/.hermes/plugins/<name>/__init__.py`, then register tools with
555−`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
556−enabled or disabled without touching `tools/` or `toolsets.py`.
557−
558−Use the built-in route below only when the user is explicitly contributing a new
559−core Hermes tool that should ship in the base system.
560−
561−Built-in/core tools require changes in **2 files**:
562−
563−**1. Create `tools/your_tool.py`:**
564−```python
565−import json, os
566−from tools.registry import registry
567−
568−def check_requirements() -> bool:
569− return bool(os.getenv("EXAMPLE_API_KEY"))
570−
571−def example_tool(param: str, task_id: str = None) -> str:
572− return json.dumps({"success": True, "data": "..."})
573−
574−registry.register(
575− name="example_tool",
576− toolset="example",
577− schema={"name": "example_tool", "description": "...", "parameters": {...}},
578− handler=lambda args, **kw: example_tool(param=args.get("param", ""), task_id=kw.get("task_id")),
579− check_fn=check_requirements,
580− requires_env=["EXAMPLE_API_KEY"],
581−)
582−```
583−
584−**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only *exposed to an agent* if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from.
585−
586−Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step.
587−
588−The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
589−
590−**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`.
591−
592−**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.
593−
594−**Agent-level tools** (todo, memory): intercepted by `run_agent.py` before `handle_function_call()`. See `tools/todo_tool.py` for the pattern.
595−
596−---
597−
598−## Dependency Pinning Policy
599−
600−All dependencies must have upper bounds to limit supply-chain attack surface.
601−This policy was established after the litellm compromise (PR #2796, #2810) and
602−reinforced after the Mini Shai-Hulud worm campaign (May 2026).
603−
604−| Source type | Treatment | Example |
605−|---|---|---|
606−| PyPI package | `>=floor,<next_major` | `"httpx>=0.28.1,<1"` |
607−| Git URL | Commit SHA | `git+https://...@<40-char-sha>` |
608−| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@<sha> # v4` |
609−| CI-only pip | `==exact` | `pyyaml==6.0.2` |
610−
611−**When adding a new dependency to `pyproject.toml`:**
612−1. Pin to `>=current_version,<next_major` for post-1.0 (e.g. `>=1.5.0,<2`).
613−2. For pre-1.0 packages, use `<0.(current_minor + 2)` (e.g. `>=0.29,<0.32`).
614−3. Never commit a bare `>=X.Y.Z` without a ceiling — CI and reviewers will reject it.
615−4. Run `uv lock` to regenerate `uv.lock` with hashes.
616−
617−Reference: #2810 (bounds pass), #9801 (SHA pinning + audit CI).
618−
619−---
620−
621−## Adding Configuration
622−
623−### config.yaml options:
624−1. Add to `DEFAULT_CONFIG` in `hermes_cli/config.py`
625−2. Bump `_config_version` (check the current value at the top of `DEFAULT_CONFIG`)
626− ONLY if you need to actively migrate/transform existing user config
627− (renaming keys, changing structure). Adding a new key to an existing
628− section is handled automatically by the deep-merge and does NOT require
629− a version bump.
630−
631−### Top-level `config.yaml` sections (non-exhaustive):
632−
633−`model`, `agent`, `terminal`, `compression`, `display`, `stt`, `tts`,
634−`memory`, `security`, `delegation`, `smart_model_routing`, `checkpoints`,
635−`auxiliary`, `curator`, `skills`, `gateway`, `logging`, `cron`, `profiles`,
636−`plugins`, `honcho`.
637−
638−`auxiliary` holds per-task overrides for side-LLM work (curator, vision,
639−embedding, title generation, session_search, etc.) — each task can pin
640−its own provider/model/base_url/max_tokens/reasoning_effort. See
641−`agent/auxiliary_client.py::_resolve_auto` for resolution order.
642−
643−`curator` holds the background skill-maintenance config —
644−`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
645−`archive_after_days`, `backup` (nested).
646−
647−### .env variables (SECRETS ONLY — API keys, tokens, passwords):
648−1. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
649−```python
650−"NEW_API_KEY": {
651− "description": "What it's for",
652− "prompt": "Display name",
653− "url": "https://...",
654− "password": True,
655− "category": "tool", # provider, tool, messaging, setting
656−},
657−```
658−
659−Non-secret settings (timeouts, thresholds, feature flags, paths, display
660−preferences) belong in `config.yaml`, not `.env`. If internal code needs an
661−env var mirror for backward compatibility, bridge it from `config.yaml` to
662−the env var in code (see `gateway_timeout`, `terminal.cwd` → `TERMINAL_CWD`).
663−
664−### Config loaders (three paths — know which one you're in):
665−
666−| Loader | Used by | Location |
667−|--------|---------|----------|
668−| `load_cli_config()` | CLI mode | `cli.py` — merges CLI-specific defaults + user YAML |
669−| `load_config()` | `hermes tools`, `hermes setup`, most CLI subcommands | `hermes_cli/config.py` — merges `DEFAULT_CONFIG` + user YAML |
670−| Direct YAML load | Gateway runtime | `gateway/run.py` + `gateway/config.py` — reads user YAML raw |
671−
672−If you add a new key and the CLI sees it but the gateway doesn't (or vice
673−versa), you're on the wrong loader. Check `DEFAULT_CONFIG` coverage.
674−
675−### Working directory:
676−- **CLI** — uses the process's current directory (`os.getcwd()`).
677−- **Messaging** — uses `terminal.cwd` from `config.yaml`. The gateway bridges this
678− to the `TERMINAL_CWD` env var for child tools. **`MESSAGING_CWD` has been
679− removed** — the config loader prints a deprecation warning if it's set in
680− `.env`. Same for `TERMINAL_CWD` in `.env`; the canonical setting is
681− `terminal.cwd` in `config.yaml`.
682−
683−---
684−
685−## Skin/Theme System
686−
687−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.
688−
689−### Architecture
690−
691−```
692−hermes_cli/skin_engine.py # SkinConfig dataclass, built-in skins, YAML loader
693−~/.hermes/skins/*.yaml # User-installed custom skins (drop-in)
694−```
695−
696−- `init_skin_from_config()` — called at CLI startup, reads `display.skin` from config
697−- `get_active_skin()` — returns cached `SkinConfig` for the current skin
698−- `set_active_skin(name)` — switches skin at runtime (used by `/skin` command)
699−- `load_skin(name)` — loads from user skins first, then built-ins, then falls back to default
700−- Missing skin values inherit from the `default` skin automatically
701−
702−### What skins customize
703−
704−| Element | Skin Key | Used By |
705−|---------|----------|---------|
706−| Banner panel border | `colors.banner_border` | `banner.py` |
707−| Banner panel title | `colors.banner_title` | `banner.py` |
708−| Banner section headers | `colors.banner_accent` | `banner.py` |
709−| Banner dim text | `colors.banner_dim` | `banner.py` |
710−| Banner body text | `colors.banner_text` | `banner.py` |
711−| Response box border | `colors.response_border` | `cli.py` |
712−| Spinner faces (waiting) | `spinner.waiting_faces` | `display.py` |
713−| Spinner faces (thinking) | `spinner.thinking_faces` | `display.py` |
714−| Spinner verbs | `spinner.thinking_verbs` | `display.py` |
715−| Spinner wings (optional) | `spinner.wings` | `display.py` |
716−| Tool output prefix | `tool_prefix` | `display.py` |
717−| Per-tool emojis | `tool_emojis` | `display.py` → `get_tool_emoji()` |
718−| Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
719−| Welcome message | `branding.welcome` | `cli.py` |
720−| Response box label | `branding.response_label` | `cli.py` |
721−| Prompt symbol | `branding.prompt_symbol` | `cli.py` |
722−
723−### Built-in skins
724−
725−- `default` — Classic Hermes gold/kawaii (the current look)
726−- `ares` — Crimson/bronze war-god theme with custom spinner wings
727−- `mono` — Clean grayscale monochrome
728−- `slate` — Cool blue developer-focused theme
729−
730−### Adding a built-in skin
731−
732−Add to `_BUILTIN_SKINS` dict in `hermes_cli/skin_engine.py`:
733−
734−```python
735−"mytheme": {
736− "name": "mytheme",
737− "description": "Short description",
738− "colors": { ... },
739− "spinner": { ... },
740− "branding": { ... },
741− "tool_prefix": "┊",
742−},
743−```
744−
745−### User skins (YAML)
746−
747−Users create `~/.hermes/skins/<name>.yaml`:
748−
749−```yaml
750−name: cyberpunk
751−description: Neon-soaked terminal theme
752−
753−colors:
754− banner_border: "#FF00FF"
755− banner_title: "#00FFFF"
756− banner_accent: "#FF1493"
757−
758−spinner:
759− thinking_verbs: ["jacking in", "decrypting", "uploading"]
760− wings:
761− - ["⟨⚡", "⚡⟩"]
762−
763−branding:
764− agent_name: "Cyber Agent"
765− response_label: " ⚡ Cyber "
766−
767−tool_prefix: "▏"
768−```
769−
770−Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
771−
772−---
773−
774−## Plugins
775−
776−Hermes has two plugin surfaces. Both live under `plugins/` in the repo so
777−repo-shipped plugins can be discovered alongside user-installed ones in
778−`~/.hermes/plugins/` and pip-installed entry points.
779−
780−### General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
781−
782−`PluginManager` discovers plugins from `~/.hermes/plugins/`, `./.hermes/plugins/`,
783−and pip entry points. Each plugin exposes a `register(ctx)` function that
784−can:
785−
786−- Register Python-callback lifecycle hooks:
787− `pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`,
788− `on_session_start`, `on_session_end`
789−- Register new tools via `ctx.register_tool(...)`
790−- Register CLI subcommands via `ctx.register_cli_command(...)` — the
791− plugin's argparse tree is wired into `hermes` at startup so
792− `hermes <pluginname> <subcmd>` works with no change to `main.py`
793−
794−Hooks are invoked from `model_tools.py` (pre/post tool) and `run_agent.py`
795−(lifecycle). **Discovery timing pitfall:** `discover_plugins()` only runs
796−as a side effect of importing `model_tools.py`. Code paths that read plugin
797−state without importing `model_tools.py` first must call `discover_plugins()`
798−explicitly (it's idempotent).
799−
800−#### Native plugin compatibility policy
801−
802−The canonical contract and deprecation policy live in
803−`website/docs/developer-guide/plugins/index.md#native-plugin-compatibility-contract`.
804−Compatibility is enforced as a behavior contract, not through a monolithic
805−`PLUGIN_API_VERSION`, a manifest-wide native `api:` match, or version literals
806−on unrelated payloads. Keep documented plugin surfaces additive:
807−
808−- add hook payload data as keyword fields; signature-inspect callbacks so old
809− narrow signatures receive only fields they declare, while `**kwargs`
810− callbacks receive the complete payload;
811−- do not remove or rename `PluginContext` methods; make new parameters optional
812− with defaults and keyword-only where possible;
813−- ignore unknown native manifest fields;
814−- give new provider methods default implementations, and signature-inspect
815− optional callback kwargs rather than forwarding them unconditionally;
816−- use a local schema version only for a capability with a wire or persisted
817− contract, and preserve old state/config/session replay or ship a migration.
818−
819−Deprecations require a once-per-process warning, a documented replacement and
820−migration note, and at least two subsequent minor releases before removal.
821−Compatibility tests must load frozen plugins through the real discovery path
822−and assert outcomes. Do not replace these with exact registry/catalog counts,
823−source-reading tests, or assertions that a global version literal changed.
824−
825−### Memory-provider plugins (`plugins/memory/<name>/`)
826−
827−Separate discovery system for pluggable memory backends. Current built-in
828−providers include **honcho, mem0, supermemory, byterover, hindsight,
829−holographic, openviking, retaindb**.
830−
831−Discovery covers the same four sources as the general `PluginManager` —
832−bundled, `$HERMES_HOME/plugins/`, `./.hermes/plugins/` (opt-in via
833−`HERMES_ENABLE_PROJECT_PLUGINS`), and `hermes_agent.memory_providers` entry
834−points — but with **bundled-first** precedence, the reverse of the general
835−system's later-wins order: a memory provider is activated by name, so a
836−dropped-in directory must not be able to shadow a shipped one. Discovery
837−enumerates without importing; nothing runs until `memory.provider` names it.
838−
839−Each provider implements the `MemoryProvider` ABC (see `agent/memory_provider.py`)
840−and is orchestrated by `agent/memory_manager.py`. Lifecycle hooks include
841−`sync_turn(turn_messages)`, `prefetch(query)`, `shutdown()`, and optional
842−`post_setup(hermes_home, config)` for setup-wizard integration.
843−
844−**CLI commands via `plugins/memory/<name>/cli.py`:** if a memory plugin
845−defines `register_cli(subparser)`, `discover_plugin_cli_commands()` finds
846−it at argparse setup time and wires it into `hermes <plugin>`. The
847−framework only exposes CLI commands for the **currently active** memory
848−provider (read from `memory.provider` in config.yaml), so disabled
849−providers don't clutter `hermes --help`.
850−
851−**Rule (Teknium, May 2026):** plugins MUST NOT modify core files
852−(`run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.).
853−If a plugin needs a capability the framework doesn't expose, expand the
854−generic plugin surface (new hook, new ctx method) — never hardcode
855−plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
856−honcho argparse from `main.py` for exactly this reason.
857−
858−**No new in-tree memory providers (policy, May 2026):** the set of
859−built-in memory providers under `plugins/memory/` is closed. New memory
860−backends must ship as **standalone plugin repos** that users install
861−into `~/.hermes/plugins/` (or via pip entry points) — they implement
862−the same `MemoryProvider` ABC, register through the same discovery
863−path, and integrate via `hermes memory setup` / `post_setup()` without
864−landing in this tree. PRs that add a new directory under
865−`plugins/memory/` will be closed with a pointer to publish the
866−provider as its own repo. Existing in-tree providers stay; bug fixes
867−to them are welcome.
868−
869−**No new third-party-product plugins in-tree (policy, June 2026):** the
870−same rule applies beyond memory providers. Plugins that integrate
871−someone else's product or project — observability/metrics backends,
872−vendor SaaS connectors, analytics dashboards, paid-service tie-ins —
873−must ship as **standalone plugin repos** that users install into
874−`~/.hermes/plugins/` (or via pip entry points). They register through
875−the existing plugin discovery path and use the ABCs/hooks/ctx surface
876−we expose; nothing special is needed in core. The reason is
877−maintenance load: every product we absorb into the tree becomes our
878−burden to keep working against a fast-moving core, for a backend we
879−don't own. Promote standalone plugins in the Nous Research Discord
880−(`#plugins-skills-and-skins`). PRs that add such a directory under
881−`plugins/` are closed with a pointer to publish it as its own repo —
882−this is a coupling decision, not a quality judgment. (The
883−`observability/`, `kanban/`, `disk-cleanup/`, etc. directories already
884−in the tree are existing precedent, not an invitation to add more
885−third-party-product plugins alongside them.)
886−
887−### Model-provider plugins (`plugins/model-providers/<name>/`)
888−
889−Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …)
890−ships as a plugin here. Each plugin's `__init__.py` calls
891−`providers.register_provider(ProviderProfile(...))` at module load.
892−`providers/__init__.py._discover_providers()` is a **lazy, separate
893−discovery system** — scanned on first `get_provider_profile()` or
894−`list_providers()` call, NOT by the general PluginManager.
895−
896−Scan order:
897−1. Bundled: `<repo>/plugins/model-providers/<name>/`
898−2. User: `$HERMES_HOME/plugins/model-providers/<name>/`
899−3. Legacy: `<repo>/providers/<name>.py` (back-compat)
900−
901−User plugins of the same name override bundled ones — `register_provider()`
902−is last-writer-wins. This lets third parties swap out any built-in
903−profile without a repo patch.
904−
905−The general PluginManager records `kind: model-provider` manifests but does
906−NOT import them (would double-instantiate `ProviderProfile`). Plugins
907−without an explicit `kind:` get auto-coerced via a source-text heuristic
908−(`register_provider` + `ProviderProfile` in `__init__.py`).
909−
910−Full authoring guide: `website/docs/developer-guide/model-provider-plugin.md`.
911−
912−### Dashboard / context-engine / image-gen plugin directories
913−
914−`plugins/context_engine/`, `plugins/image_gen/`, etc. follow the same
915−pattern (ABC + orchestrator + per-plugin directory). Context engines
916−plug into `agent/context_engine.py`; image-gen providers into
917−`agent/image_gen_provider.py`. Reference / docs-companion plugins
918−(`example-dashboard`, `strike-freedom-cockpit`, `plugin-llm-example`,
919−`plugin-llm-async-example`) live in the
920−[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins)
921−companion repo, not in this tree.
922−
923−---
924−
925−## Skills
926−
927−Two parallel surfaces:
928−
929−- **`skills/`** — built-in skills shipped and loadable by default.
930− Organized by category directories (e.g. `skills/github/`, `skills/mlops/`).
931−- **`optional-skills/`** — heavier or niche skills shipped with the repo but
932− NOT active by default. Installed explicitly via
933− `hermes skills install official/<category>/<skill>`. Adapter lives in
934− `tools/skills_hub.py` (`OptionalSkillSource`). Categories include
935− `autonomous-ai-agents`, `blockchain`, `communication`, `creative`,
936− `devops`, `email`, `health`, `mcp`, `migration`, `mlops`, `productivity`,
937− `research`, `security`, `web-development`.
938−
939−When reviewing skill PRs, check which directory they target — heavy-dep or
940−niche skills belong in `optional-skills/`.
941−
942−### SKILL.md frontmatter
943−
944−Standard fields: `name`, `description`, `version`, `author`, `license`,
945−`platforms` (OS-gating list: `[macos]`, `[linux, macos]`, ...),
946−`metadata.hermes.tags`, `metadata.hermes.category`,
947−`metadata.hermes.related_skills`, `metadata.hermes.config` (config.yaml
948−settings the skill needs — stored under `skills.config.<key>`, prompted
949−during setup, injected at load time).
950−
951−Top-level `tags:` and `category:` are also accepted and mirrored from
952−`metadata.hermes.*` by the loader.
953−
954−### Skill authoring standards (HARDLINE)
955−
956−Every new or modernized skill — bundled, optional, or contributed —
957−must meet these standards before merge. Reviewers reject PRs that
958−violate them.
959−
960−1. **`description` ≤ 60 characters, one sentence, ends with a period.**
961− Long descriptions bloat skill listings and dilute the model's
962− attention when many skills are loaded. State the capability, not
963− the implementation. No marketing words ("powerful",
964− "comprehensive", "seamless", "advanced"). Don't repeat the skill
965− name. Verify with:
966− ```python
967− import re, pathlib
968− m = re.search(r'^description: (.*)$',
969− pathlib.Path('skills/<cat>/<name>/SKILL.md').read_text(),
970− re.MULTILINE)
971− assert len(m.group(1)) <= 60, len(m.group(1))
972− ```
973−
974−2. **Tools referenced in SKILL.md prose must be native Hermes tools or
975− MCP servers the skill explicitly expects.** When the skill needs a
976− capability, point at the proper tool by name in backticks
977− (`` `terminal` ``, `` `web_extract` ``, `` `read_file` ``,
978− `` `patch` ``, `` `search_files` ``, `` `vision_analyze` ``,
979− `` `browser_navigate` ``, `` `delegate_task` ``, etc.). Do NOT
980− name shell utilities the agent already has wrapped — `grep` →
981− `search_files`, `cat`/`head`/`tail` → `read_file`, `sed`/`awk` →
982− `patch`, `find`/`ls` → `search_files target='files'`. If the skill
983− depends on an MCP server, name the MCP server and document the
984− expected setup in `## Prerequisites`. Anything else (third-party
985− CLIs, shell pipelines, etc.) is fair game inside script files but
986− should not be the headline interaction surface in the prose.
987−
988−3. **`platforms:` gating audited against actual script imports.**
989− Skills that use POSIX-only primitives (`fcntl`, `termios`,
990− `os.setsid`, `os.kill(pid, 0)` for liveness, `/proc`, `/tmp`
991− hardcoded, `signal.SIGKILL`, bash heredocs, `osascript`, `apt`,
992− `systemctl`) must declare their supported platforms. Default
993− posture: try to fix it cross-platform first — `tempfile.gettempdir`,
994− `pathlib.Path`, `psutil.pid_exists`, Python-level filtering instead
995− of `grep`. Gate to a narrower set only when the dependency is
996− genuinely platform-bound.
997−
998−4. **`author` credits the human contributor first.** For external
999− contributions, the contributor's real name + GitHub handle goes
1000− first; "Hermes Agent" is the secondary collaborator. If the
1001− contributor's commit shows "Hermes Agent" as author (because they
1002− used Hermes to draft the skill), replace it with their actual name
1003− — credit the human, not the tool.
1004−
1005−5. **SKILL.md body uses the modern section order.** `# <Skill> Skill`
1006− title, 2-3 sentence intro stating what it does and doesn't do,
1007− `## When to Use`, `## Prerequisites`, `## How to Run`,
1008− `## Quick Reference`, `## Procedure`, `## Pitfalls`,
1009− `## Verification`. Target ~200 lines for a complex skill,
1010− ~100 lines for a simple one. Cut redundant intro fluff, marketing
1011− prose, and re-explanations of env vars already in
1012− `## Prerequisites`.
1013−
1014−6. **Scripts go in `scripts/`, references in `references/`,
1015− templates in `templates/`.** Don't expect the model to inline-write
1016− parsers, XML walkers, or non-trivial logic every call — ship a
1017− helper script. Reference it from SKILL.md by path relative to the
1018− skill directory.
1019−
1020−7. **Tests live at `tests/skills/test_<skill>_skill.py`** and use only
1021− stdlib + pytest + `unittest.mock`. No live network calls. Run via
1022− `scripts/run_tests.sh tests/skills/test_<skill>_skill.py -q`.
1023−
1024−8. **`.env.example` additions are isolated to a clearly delimited
1025− block.** Don't touch the surrounding file — contributor-supplied
1026− `.env.example` versions are usually stale and edits outside the
1027− skill's own block must be dropped during salvage.
1028−
1029−The full salvage / modernization checklist for external skill PRs
1030−lives in the `hermes-agent-dev` skill at
1031−`references/new-skill-pr-salvage.md` — load it before polishing
1032−contributor skill PRs.
1033−
1034−---
1035−
1036−## Toolsets
1037−
1038−All toolsets are defined in `toolsets.py` as a single `TOOLSETS` dict.
1039−Each platform's adapter picks a base toolset (e.g. Telegram uses
1040−`"messaging"`); `_HERMES_CORE_TOOLS` is the default bundle most
1041−platforms inherit from.
1042−
1043−Current toolset keys: `browser`, `clarify`, `code_execution`, `cronjob`,
1044−`debugging`, `delegation`, `discord`, `discord_admin`, `feishu_doc`,
1045−`feishu_drive`, `file`, `homeassistant`, `image_gen`, `kanban`, `memory`,
1046−`messaging`, `moa`, `rl`, `safe`, `search`, `session_search`, `skills`,
1047−`spotify`, `terminal`, `todo`, `tts`, `video`, `vision`, `web`, `yuanbao`.
1048−
1049−Enable/disable per platform via `hermes tools` (the curses UI) or the
1050−`tools.<platform>.enabled` / `tools.<platform>.disabled` lists in
1051−`config.yaml`.
1052−
1053−---
1054−
1055−## Delegation (`delegate_task`)
1056−
1057−`tools/delegate_tool.py` spawns a subagent with an isolated
1058−context + terminal session. By default the parent waits for the
1059−child's summary before continuing its own loop. With `background=true`,
1060−Hermes returns a delegation id immediately and the result re-enters the
1061−conversation later through the async-delegation completion queue.
1062−
1063−Two shapes:
1064−
1065−- **Single:** pass `goal` (+ optional `context`, `toolsets`).
1066−- **Batch (parallel):** pass `tasks: [...]` — each gets its own subagent
1067− running concurrently. Concurrency is capped by
1068− `delegation.max_concurrent_children` (default 3).
1069−
1070−Roles:
1071−
1072−- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
1073− `clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code`
1074− (programmatic tool calling).
1075−- `role="orchestrator"` — retains `delegate_task` so it can spawn its
1076− own workers. Gated by `delegation.orchestrator_enabled` (default true)
1077− and bounded by `delegation.max_spawn_depth` (default 2).
1078−
1079−Key config knobs (under `delegation:` in `config.yaml`):
1080−`max_concurrent_children`, `max_spawn_depth`, `child_timeout_seconds`,
1081−`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
1082−`max_iterations`.
1083−
1084−Durability rule: background `delegate_task` is detached from the current
1085−turn but still process-local. For work that must survive process restart, use
1086−`cronjob` or `terminal(background=True, notify_on_complete=True)` instead.
1087−
1088−---
1089−
1090−## Curator (skill lifecycle)
1091−
1092−Background skill-maintenance system that tracks usage on agent-created
1093−skills and auto-archives stale ones. Users never lose skills; archives
1094−go to `~/.hermes/skills/.archive/` and are restorable.
1095−
1096−- **Core:** `agent/curator.py` (review loop, auto-transitions, LLM review
1097− prompt) + `agent/curator_backup.py` (pre-run tar.gz snapshots).
1098−- **CLI:** `hermes_cli/curator.py` wires `hermes curator <verb>` where
1099− verbs are: `status`, `run`, `pause`, `resume`, `pin`, `unpin`,
1100− `archive`, `restore`, `prune`, `backup`, `rollback`.
1101−- **Telemetry:** `tools/skill_usage.py` owns the sidecar
1102− `~/.hermes/skills/.usage.json` — per-skill `use_count`, `view_count`,
1103− `patch_count`, `last_activity_at`, `state` (active / stale /
1104− archived), `pinned`.
1105−
1106−Invariants:
1107−- Curator only touches skills with `created_by: "agent"` provenance —
1108− bundled + hub-installed skills are off-limits.
1109−- Never deletes; max destructive action is archive.
1110−- Pinned skills are exempt from every auto-transition and from the
1111− LLM review pass.
1112−- `skill_manage(action="delete")` refuses pinned skills; patch/edit/
1113− write_file/remove_file go through so the agent can keep improving
1114− pinned skills.
1115−
1116−Config section (`curator:` in `config.yaml`):
1117−`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
1118−`archive_after_days`, `backup.*`.
1119−
1120−Full user-facing docs: `website/docs/user-guide/features/curator.md`.
1121−
1122−---
1123−
1124−## Cron (scheduled jobs)
1125−
1126−`cron/jobs.py` (job store) + `cron/scheduler.py` (tick loop). Agents
1127−schedule jobs via the `cronjob` tool; users via `hermes cron <verb>`
1128−(`list`, `add`, `edit`, `pause`, `resume`, `run`, `remove`) or the
1129−`/cron` slash command.
1130−
1131−Supported schedule formats:
1132−- Duration: `"30m"`, `"2h"`, `"1d"`
1133−- "every" phrase: `"every 2h"`, `"every monday 9am"`
1134−- 5-field cron expression: `"0 9 * * *"`
1135−- ISO timestamp (one-shot): `"2026-06-01T09:00:00Z"`
1136−
1137−Per-job fields include `skills` (load specific skills), `model` /
1138−`provider` overrides, `script` (pre-run data-collection script whose
1139−stdout is injected into the prompt; `no_agent=True` turns the script
1140−into the entire job), `context_from` (chain job A's last output into
1141−job B's prompt), `workdir` (run in a specific directory with its
1142−`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery.
1143−
1144−Hardening invariants:
1145−- **3-minute hard interrupt** on cron sessions — runaway agent loops
1146− cannot monopolize the scheduler.
1147−- Catchup window: half the job's period, clamped to 120s–2h.
1148−- Grace window: 120s for one-shot jobs whose fire time was missed.
1149−- File lock at `~/.hermes/cron/.tick.lock` prevents duplicate ticks
1150− across processes.
1151−- Cron sessions pass `skip_memory=True` by default; memory providers
1152− intentionally do not run during cron.
1153−
1154−Cron deliveries are **not** mirrored into the target gateway session —
1155−they land in their own cron session with a header/footer frame so the
1156−main conversation's message-role alternation stays intact.
1157−
1158−---
1159−
1160−## Kanban (multi-agent work queue)
1161−
1162−Durable SQLite-backed board that lets multiple profiles / workers
1163−collaborate on shared tasks. Users drive it via `hermes kanban <verb>`;
1164−workers spawned by the dispatcher drive it via a dedicated `kanban_*`
1165−toolset so their schema footprint is zero when they're not inside a
1166−kanban task.
1167−
1168−- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
1169− `init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
1170− `unlink`, `comment`, `attach`, `attachments`, `attach-rm`, `complete`,
1171− `request-review`, `request-changes`, `reopen-review`, `block`, `unblock`, `archive`,
1172− `tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`,
1173− `assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`.
1174−- **Worker/orchestrator toolset:** `tools/kanban_tools.py` exposes
1175− `kanban_show`, `kanban_complete`, `kanban_request_review`,
1176− `kanban_request_changes`, `kanban_block`,
1177− `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`,
1178− `kanban_attach`, `kanban_attach_url`, `kanban_attachments`; profiles that
1179− explicitly enable the `kanban` toolset outside a dispatcher-spawned
1180− task also get `kanban_list` and `kanban_unblock` for board routing.
1181−- **Dispatcher:** long-lived loop that (default every 60s) reclaims
1182− stale claims, promotes ready tasks, atomically claims, and spawns
1183− assigned profiles. Runs **inside the gateway** by default via
1184− `kanban.dispatch_in_gateway: true`.
1185−- **Plugin assets:** `plugins/kanban/dashboard/` (web UI) +
1186− `plugins/kanban/systemd/` (`hermes-kanban-dispatcher.service` for
1187− standalone dispatcher deployment).
1188−
1189−Isolation model:
1190−- **Board** is the hard boundary — workers are spawned with
1191− `HERMES_KANBAN_BOARD` pinned in their env so they can't see other
1192− boards.
1193−- **Tenant** is a soft namespace *within* a board — one specialist
1194− fleet can serve multiple businesses with workspace-path + memory-key
1195− isolation.
1196−- After `kanban.failure_limit` consecutive non-success attempts on the
1197− same task (default: 2), the dispatcher auto-blocks it to prevent spin
1198− loops.
1199−
1200−Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
1201−
1202−---
1203−
1204−## Important Policies
1205−
1206−### Prompt Caching Must Not Break
1207−
1208−Hermes-Agent ensures caching remains valid throughout a conversation. **Do NOT implement changes that would:**
1209−- Alter past context mid-conversation
1210−- Change toolsets mid-conversation
1211−- Reload memories or rebuild system prompts mid-conversation
1212−
1213−Cache-breaking forces dramatically higher costs. The ONLY time we alter context is during context compression.
1214−
1215−Slash commands that mutate system-prompt state (skills, tools, memory, etc.)
1216−must be **cache-aware**: default to deferred invalidation (change takes
1217−effect next session), with an opt-in `--now` flag for immediate
1218−invalidation. See `/skills install --now` for the canonical pattern.
1219−
1220−### Background Process Notifications (Gateway)
1221−
1222−When `terminal(background=true, notify_on_complete=true)` is used, the gateway runs a watcher that
1223−detects process completion and triggers a new agent turn. Control verbosity of background process
1224−messages with `display.background_process_notifications`
1225−in config.yaml (or `HERMES_BACKGROUND_NOTIFICATIONS` env var):
1226−
1227−- `concise` — one-line status message on completion; failures append a short output tail (default)
1228−- `all` — running-output updates + final raw-output message
1229−- `result` — only the final raw-output completion message
1230−- `error` — only the final raw-output message when exit code != 0
1231−- `off` — no watcher messages at all
1232−
1233−---
1234−
1235−## Profiles: Multi-Instance Support
1236−
1237−Hermes supports **profiles** — multiple fully isolated instances, each with its own
1238−`HERMES_HOME` directory (config, API keys, memory, sessions, skills, gateway, etc.).
1239−
1240−The core mechanism: `_apply_profile_override()` in `hermes_cli/main.py` sets
1241−`HERMES_HOME` before any module imports. All `get_hermes_home()` references
1242−automatically scope to the active profile.
1243−
1244−### Rules for profile-safe code
1245−
1246−1. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`.
1247− NEVER hardcode `~/.hermes` or `Path.home() / ".hermes"` in code that reads/writes state.
1248− ```python
1249− # GOOD
1250− from hermes_constants import get_hermes_home
1251− config_path = get_hermes_home() / "config.yaml"
1252−
1253− # BAD — breaks profiles
1254− config_path = Path.home() / ".hermes" / "config.yaml"
1255− ```
1256−
1257−2. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`.
1258− This returns `~/.hermes` for default or `~/.hermes/profiles/<name>` for profiles.
1259− ```python
1260− # GOOD
1261− from hermes_constants import display_hermes_home
1262− print(f"Config saved to {display_hermes_home()}/config.yaml")
1263−
1264− # BAD — shows wrong path for profiles
1265− print("Config saved to ~/.hermes/config.yaml")
1266− ```
1267−
1268−3. **Module-level constants are fine** — they cache `get_hermes_home()` at import time,
1269− which is AFTER `_apply_profile_override()` sets the env var. Just use `get_hermes_home()`,
1270− not `Path.home() / ".hermes"`.
1271−
1272−4. **Tests that mock `Path.home()` must also set `HERMES_HOME`** — since code now uses
1273− `get_hermes_home()` (reads env var), not `Path.home() / ".hermes"`:
1274− ```python
1275− with patch.object(Path, "home", return_value=tmp_path), \
1276− patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
1277− ...
1278− ```
1279−
1280−5. **Gateway platform adapters should use token locks** — if the adapter connects with
1281− a unique credential (bot token, API key), call `acquire_scoped_lock()` from
1282− `gateway.status` in the `connect()`/`start()` method and `release_scoped_lock()` in
1283− `disconnect()`/`stop()`. This prevents two profiles from using the same credential.
1284− See `plugins/platforms/irc/adapter.py` for the canonical pattern.
1285−
1286−6. **Profile operations are HOME-anchored, not HERMES_HOME-anchored** — `_get_profiles_root()`
1287− returns `Path.home() / ".hermes" / "profiles"`, NOT `get_hermes_home() / "profiles"`.
1288− This is intentional — it lets `hermes -p coder profile list` see all profiles regardless
1289− of which one is active.
1290−
1291−## Known Pitfalls
1292−
1293−### DO NOT hardcode `~/.hermes` paths
1294−Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_hermes_home()`
1295−for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile
1296−has its own `HERMES_HOME` directory. This was the source of 5 bugs fixed in PR #3575.
1297−
1298−### All CLI menu-pickers MUST use curses.
1299−Interactive menus must use `hermes_cli/curses_ui.py`. See `hermes_cli/tools_config.py` for an example.
1300−
1301−### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code
1302−Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`.
1303−
1304−### `_last_resolved_tool_names` is a process-global in `model_tools.py`
1305−`_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.
1306−
1307−### DO NOT hardcode cross-tool references in schema descriptions
1308−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.
1309−
1310−### The gateway has TWO message guards — both must bypass approval/control commands
1311−When an agent is running, messages pass through two sequential guards:
1312−(1) **base adapter** (`gateway/platforms/base.py`) queues messages in
1313−`_pending_messages` when `session_key in self._active_sessions`, and
1314−(2) **gateway runner** (`gateway/run.py`) intercepts `/stop`, `/new`,
1315−`/queue`, `/status`, `/approve`, `/deny` before they reach
1316−`running_agent.interrupt()`. Any new command that must reach the runner
1317−while the agent is blocked (e.g. approval prompts) MUST bypass BOTH
1318−guards and be dispatched inline, not via `_process_message_background()`
1319−(which races session lifecycle).
1320−
1321−### Squash merges from stale branches silently revert recent fixes
1322−Before squash-merging a PR, ensure the branch is up to date with `main`
1323−(`git fetch origin main && git reset --hard origin/main` in the worktree,
1324−then re-apply the PR's commits). A stale branch's version of an unrelated
1325−file will silently overwrite recent fixes on main when squashed. Verify
1326−with `git diff HEAD~1..HEAD` after merging — unexpected deletions are a
1327−red flag.
1328−
1329−### Don't wire in dead code without E2E validation
1330−Unused code that was never shipped was dead for a reason. Before wiring an
1331−unused module into a live code path, E2E test the real resolution chain
1332−with actual imports (not mocks) against a temp `HERMES_HOME`.
1333−
1334−### Tests must not write to `~/.hermes/`
1335−The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests.
1336−
1337−**Profile tests**: When testing profile features, also mock `Path.home()` so that
1338−`_get_profiles_root()` and `_get_default_hermes_home()` resolve within the temp dir.
1339−Use the pattern from `tests/hermes_cli/test_profiles.py`:
1340−```python
1341−@pytest.fixture
1342−def profile_env(tmp_path, monkeypatch):
1343− home = tmp_path / ".hermes"
1344− home.mkdir()
1345− monkeypatch.setattr(Path, "home", lambda: tmp_path)
1346− monkeypatch.setenv("HERMES_HOME", str(home))
1347− return home
1348−```
1349−
1350−---
1351−
1352−## Testing
1353−
1354−### Python
1355−**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
1356−hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
1357−per-file subprocess isolation via `scripts/run_tests_parallel.py` — no xdist,
1358−worker count auto-scaled from CPU count). Direct `pytest`
1359−on a 16+ core developer machine with API keys set diverges from CI in ways
1360−that have caused multiple "works locally, fails in CI" incidents (and the reverse).
1361−
1362−```bash
1363−scripts/run_tests.sh # full suite, CI-parity
1364−scripts/run_tests.sh tests/gateway/ # one directory
1365−scripts/run_tests.sh tests/agent/test_foo.py -k test_x # one test (file + -k; the runner is file-granular)
1366−scripts/run_tests.sh -v --tb=long # pass-through pytest flags
1367−```
1368−
1369−**Flake policy:** the runner auto-retries a failing test FILE once in a fresh
1370−subprocess (`--file-retries`, default 1; `HERMES_TEST_FILE_RETRIES=0` to
1371−disable). Pass-on-retry counts as green but is printed in a `⚠ FLAKY` summary
1372−section with both attempts' output. A FLAKY report is a bug to fix, not noise
1373−to ignore — timing-sensitive tests must not assume a quiet runner (loose
1374−wall-clock bounds ≥ 2s, event-based sync, no `assert not _wait_until(...)`
1375−negative-timing races).
1376−
1377−#### Subprocess-per-test-file isolation
1378−
1379−Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
1380−ContextVars from one test file cannot leak into the next.
1381−
1382−#### Why the wrapper
1383−
1384−| | Without wrapper | With wrapper |
1385−| ------------------- | ------------------------------------------- | ----------------------------------------- |
1386−| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. |
1387−| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
1388−| Timezone | Local TZ (PDT etc.) | UTC |
1389−| Locale | Whatever is set | C.UTF-8 |
1390−
1391−### Where to place what tests
1392−
1393−The CI change classifier (`scripts/ci/classify_changes.py`) runs specific jobs based on what files changed. A Python test that asserts
1394−about the contents of `package.json`, `package-lock.json`, `.ts`/`.tsx`
1395−source, or any other JS-side artifact will not run on a PR that only touches
1396−those files. This means a regression can go green on a PR and red on `main` (where the
1397−classifier fails open and runs everything).
1398−
1399−Any test that reads or asserts about `package.json`,
1400−`package-lock.json`, `tsconfig.json`, `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs`
1401−source files configuration belongs in the JS (vitest) test suite, not in `tests/*.py`.
1402−
1403−### Don't fake the host OS
1404−
1405−Hermes supports Linux, macOS and native Windows, and plenty of its behaviour
1406−genuinely differs per host. Those differences are tested by running on the
1407−host, not by patching `sys.platform`.
1408−
1409−```python
1410−@pytest.mark.linux_only
1411−@pytest.mark.macos_only
1412−@pytest.mark.windows_only
1413−```
1414−
1415−Things that are host-independent can stay unmarked:
1416−
1417−- **Pure functions that take a platform as data** —
1418− `hidden_windows_child_options(opts, is_windows=True)` is input→output, not a
1419− fake host. (Contrast: setting a module-level `IS_WINDOWS` flag and then
1420− calling `windows_detach_flags()` *is* a fake.)
1421−- **Declaration/packaging invariants** — "pyproject declares `tzdata` with a
1422− `sys_platform == 'win32'` marker" asserts about a file, not about runtime.
1423−
1424−The line: **if the test needs the interpreter to believe it is on another OS
1425−in order to pass, it belongs on that OS.**
1426−When one test body walks several platforms in sequence, split it.
1427−Keep the host-native arm on the Linux lane and move the other arm into its own marked test.
1428−
1429−**Use the marker, never a bare `skipif`.** `scripts/ci/list_os_marked_tests.py`
1430−decides which files the macOS/Windows lanes import by grepping for the marker
1431−*name*, and the lane then filters with `-m <marker>`. A test gated with
1432−`@pytest.mark.skipif(sys.platform != "win32")` therefore skips on Linux AND is
1433−never imported on the Windows lane — it runs on no host at all, silently. The
1434−same trap catches a file-local alias (`windows_only = pytest.mark.skipif(...)`):
1435−the grep matches the name, so the file *is* listed, but `-m windows_only`
1436−deselects every test in it and the lane reports green over zero coverage.
1437−Equally, don't `pytest.skip()` the non-host rows of a `@parametrize` over
1438−platforms — split it into one marked test per OS, or only the host's row ever
1439−executes.
1440−
1441−### Don't write change-detector tests
1442−
1443−A test is a **change-detector** if it fails whenever data that is **expected
1444−to change** gets updated — model catalogs, config version numbers,
1445−enumeration counts, hardcoded lists of provider models. These tests add no
1446−behavioral coverage; they just guarantee that routine source updates break
1447−CI and cost engineering time to "fix."
1448−
1449−**Do not write:**
1450−
1451−```python
1452−# catalog snapshot — breaks every model release
1453−assert "gemini-2.5-pro" in _PROVIDER_MODELS["gemini"]
1454−assert "MiniMax-M2.7" in models
1455−
1456−# config version literal — breaks every schema bump
1457−assert DEFAULT_CONFIG["_config_version"] == 21
1458−
1459−# enumeration count — breaks every time a skill/provider is added
1460−assert len(_PROVIDER_MODELS["huggingface"]) == 8
1461−```
1462−
1463−**Do write:**
1464−
1465−```python
1466−# behavior: does the catalog plumbing work at all?
1467−assert "gemini" in _PROVIDER_MODELS
1468−assert len(_PROVIDER_MODELS["gemini"]) >= 1
1469−
1470−# behavior: does migration bump the user's version to current latest?
1471−assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
1472−
1473−# invariant: no plan-only model leaks into the legacy list
1474−assert not (set(moonshot_models) & coding_plan_only_models)
1475−
1476−# invariant: every model in the catalog has a context-length entry
1477−for m in _PROVIDER_MODELS["huggingface"]:
1478− assert m.lower() in DEFAULT_CONTEXT_LENGTHS_LOWER
1479−```
1480−
1481−The rule: if the test reads like a snapshot of current data, delete it. If
1482−it reads like a contract about how two pieces of data must relate, keep it.
1483−When a PR adds a new provider/model and you want a test, make the test
1484−assert the relationship (e.g. "catalog entries all have context lengths"),
1485−not the specific names.
1486−
1487−Reviewers should reject new change-detector tests; authors should convert
1488−them into invariants before re-requesting review.
1489−
1490−### Never read source code in tests
1491−
1492−A test that reads a source file's text is testing *the shape of the
1493−source code*, not its behavior. This is a hard antipattern, banned outright.
1494−Any test that reads a .py, .ts, .tsx, etc., file is suspect.
1495−
1496−**Why it's actively harmful, not just weak:**
1497−
1498−- It passes when the implementation is subtly broken (the regex matches a
1499− call site that exists but is wired wrong) and fails when a correct
1500− refactor changes formatting, variable names, or control flow with
1501− identical runtime behavior. Both directions of failure are wrong.
1502−- It can't be run against a built/bundled/minified artifact, so it silently
1503− stops testing anything the moment code moves, gets renamed, or a
1504− dependency reformats it.
1505−- It actively blocks refactors: reviewers see "keeps a pattern intact" tests
1506− fail during pure structural cleanup with no behavior change, and either
1507− hand-wave the failure (dangerous) or waste time updating regexes that add
1508− nothing (waste).
1509−- It gives false confidence. a green suite full of source-regex tests
1510− looks like coverage but has never once executed the code path it claims
1511− to guard.
1512−
1513−**Do not write:**
1514−
1515−```ts
1516−const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
1517−
1518−test('backend spawn hides the Windows console', () => {
1519− assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
1520−})
1521−```
1522−
1523−**Do write — extract the logic into a small pure/DI-testable function and
1524−call it for real:**
1525−
1526−```ts
1527−// backend-spawn.ts
1528−export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
1529− if (!isWindows || 'windowsHide' in options) return options
1530− return { ...options, windowsHide: true }
1531−}
1532−
1533−// backend-spawn.test.ts
1534−test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
1535− assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
1536− assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
1537− assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
1538−})
1539−```
1540−
1541−If the logic lives inline in a god-file (`main.ts`, `cli.py`,
1542−`gateway/run.py`) and extracting it feels disruptive: that's the actual
1543−signal to do the extraction, not to regex around it.
210+If any answer is "not sure," that's the part to go verify.
1544211
