RuleStack

Configs

Stacks

Compare

Diff

RuleStack

Configs

Stacks

Compare

Diff

Read API

RuleStack

Configs

Stacks

Compare

Diff

Read API

Diff/nousresearch-hermes-agent-agents ↔ nousresearch-hermes-agent-apps-desktop-agents

Comparison

A · AGENTS.md · NousResearch/hermes-agentB · AGENTS.md · NousResearch/hermes-agent
What each file covers, counted
DimensionSharedOnly in AOnly in BOverlap
Sections060130%
Commands01600%
Section tags312020%

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)
  • − 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
  • − DO NOT introduce new `simple_term_menu` usage
  • + 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 · 16 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

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

+161 added−1394 removed42 unchanged2.9% identical
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## Development Environment
 
 
214 
215```bash
216# Prefer .venv; fall back to venv if that's what your checkout has.
217source .venv/bin/activate # or: source venv/bin/activate
218```
 
 
 
 
 
 
 
219 
220`scripts/run_tests.sh` probes `.venv` first, then `venv`, then
221`$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the
222main checkout).
223 
224## Project Structure
225 
226File counts shift constantly — don't treat the tree below as exhaustive.
227The canonical source is the filesystem. The notes call out the load-bearing
228entry points you'll actually edit.
 
 
 
 
 
 
229 
230```
231hermes-agent/
232├── run_agent.py # AIAgent class — core conversation loop (~12k LOC)
233├── model_tools.py # Tool orchestration, discover_builtin_tools(), handle_function_call()
234├── toolsets.py # Toolset definitions, _HERMES_CORE_TOOLS list
235├── cli.py # HermesCLI class — interactive CLI orchestrator (~11k LOC)
236├── hermes_state.py # SessionDB — SQLite session store (FTS5 search)
237├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths
238├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
239├── batch_runner.py # Parallel batch processing
240├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
241├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
242├── tools/ # Tool implementations — auto-discovered via tools/registry.py
243│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
244├── gateway/ # Messaging gateway — run.py + session.py + platforms/
245│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
246│ │ # homeassistant, signal, matrix, mattermost, email, sms,
247│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
248│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
249│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
250├── plugins/ # Plugin system (see "Plugins" section below)
251│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
252│ ├── context_engine/ # Context-engine plugins
253│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
254│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
255│ ├── hermes-achievements/ # Gamified achievement tracking
256│ ├── observability/ # Metrics / traces / logs plugin
257│ ├── image_gen/ # Image-generation providers
258│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
259│ # strike-freedom-cockpit, ...
260├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
261├── skills/ # Built-in skills bundled with the repo
262├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
263│ └── src/ # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib
264├── tui_gateway/ # Python JSON-RPC backend for the TUI
265├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
266├── cron/ # Scheduler — jobs.py, scheduler.py
267├── scripts/ # run_tests.sh, release.py, auxiliary scripts
268├── website/ # Docusaurus docs site
269└── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026)
270```
271 
272**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
273**Logs:** `~/.hermes/logs/` — `agent.log` (INFO+), `errors.log` (WARNING+),
274`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
275Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
 
276 
277## TypeScript Style
278 
279Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
 
 
 
 
 
 
 
280 
281- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
282- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
283- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
284- Do not pass state through three components when the leaf can subscribe to the atom.
285- Keep persistence beside the atom that owns it.
286- Keep route roots thin. They compose routes and shell; they should not become controllers.
287- No monolithic hooks. A hook should own one narrow job.
288- Prefer colocated action modules over hidden god hooks.
289- If a callback is pure side effect, use the terse void form:
290 `onState={st => void setGatewayState(st)}`.
291- Async UI handlers should make intent explicit:
292 `onClick={() => void save()}`.
293- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
294- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
295- Table-driven beats condition ladders when mapping ids, routes, or views.
296- `src/app` owns routes, pages, and page-specific components.
297- `src/store` owns shared atoms.
298- `src/lib` owns shared pure helpers.
299 
300## File Dependency Chain
 
301 
302```
303tools/registry.py (no deps — imported by all tool files)
304 ↑
305tools/*.py (each calls registry.register() at import time)
306 ↑
307model_tools.py (imports tools/registry + triggers tool discovery)
308 ↑
309run_agent.py, cli.py, batch_runner.py, environments/
310```
311 
312---
313 
314## AIAgent Class (run_agent.py)
 
 
 
 
 
 
315 
316The real `AIAgent.__init__` takes ~60 parameters (credentials, routing, callbacks,
317session context, budget, credential pool, etc.). The signature below is the
318minimum subset you'll usually touch — read `run_agent.py` for the full list.
319 
320```python
321class AIAgent:
322 def __init__(self,
323 base_url: str = None,
324 api_key: str = None,
325 provider: str = None,
326 api_mode: str = None, # "chat_completions" | "codex_responses" | ...
327 model: str = "", # empty → resolved from config/provider later
328 max_iterations: int = 500, # tool-calling iterations (shared with subagents)
329 enabled_toolsets: list = None,
330 disabled_toolsets: list = None,
331 quiet_mode: bool = False,
332 save_trajectories: bool = False,
333 platform: str = None, # "cli", "telegram", etc.
334 session_id: str = None,
335 skip_context_files: bool = False,
336 skip_memory: bool = False,
337 credential_pool=None,
338 # ... plus callbacks, thread/user/chat IDs, iteration_budget, fallback_model,
339 # checkpoints config, prefill_messages, service_tier, reasoning_config, etc.
340 ): ...
341 
342 def chat(self, message: str) -> str:
343 """Simple interface — returns final response string."""
344 
345 def run_conversation(self, user_message: str, system_message: str = None,
346 conversation_history: list = None, task_id: str = None) -> dict:
347 """Full interface — returns dict with final_response + messages."""
348```
 
 
 
 
 
349 
350### Agent Loop
351 
352The core loop is inside `run_conversation()` — entirely synchronous, with
353interrupt checks, budget tracking, and a one-turn grace call:
354 
355```python
356while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
357 or self._budget_grace_call:
358 if self._interrupt_requested: break
359 response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
360 if response.tool_calls:
361 for tool_call in response.tool_calls:
362 result = handle_function_call(tool_call.name, tool_call.args, task_id)
363 messages.append(tool_result_message(result))
364 api_call_count += 1
365 else:
366 return response.content
367```
368 
369Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`.
370Reasoning content is stored in `assistant_msg["reasoning"]`.
371 
372---
373 
374## CLI Architecture (cli.py)
375 
376- **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete
377- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
378- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
379- **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
380- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry
381- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching
382 
383### Slash Command Registry (`hermes_cli/commands.py`)
384 
385All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically:
386 
387- **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name
388- **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch
389- **Gateway help** — `gateway_help_lines()` generates `/help` output
390- **Telegram** — `telegram_bot_commands()` generates the BotCommand menu
391- **Slack** — `slack_subcommand_map()` generates `/hermes` subcommand routing
392- **Autocomplete** — `COMMANDS` flat dict feeds `SlashCommandCompleter`
393- **CLI help** — `COMMANDS_BY_CATEGORY` dict feeds `show_help()`
394 
395### Adding a Slash Command
396 
3971. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
398```python
399CommandDef("mycommand", "Description of what it does", "Session",
400 aliases=("mc",), args_hint="[arg]"),
401```
4022. Add handler in `HermesCLI.process_command()` in `cli.py`:
403```python
404elif canonical == "mycommand":
405 self._handle_mycommand(cmd_original)
406```
4073. If the command is available in the gateway, add a handler in `gateway/run.py`:
408```python
409if canonical == "mycommand":
410 return await self._handle_mycommand(event)
411```
4124. For persistent settings, use `save_config_value()` in `cli.py`
413 
414**CommandDef fields:**
415- `name` — canonical name without slash (e.g. `"background"`)
416- `description` — human-readable description
417- `category` — one of `"Session"`, `"Configuration"`, `"Tools & Skills"`, `"Info"`, `"Exit"`
418- `aliases` — tuple of alternative names (e.g. `("bg",)`)
419- `args_hint` — argument placeholder shown in help (e.g. `"<prompt>"`, `"[name]"`)
420- `cli_only` — only available in the interactive CLI
421- `gateway_only` — only available in messaging platforms
422- `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.
423 
424**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.
425 
426---
427 
428## TUI Architecture (ui-tui + tui_gateway)
429 
430The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`.
431 
432### Process Model
433 
434```
435hermes --tui
436 └─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway)
437 │ └─ AIAgent + tools + sessions
438 └─ renders transcript, composer, prompts, activity
439```
440 
441TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic.
442 
443### Transport
444 
445Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog.
446 
447### Key Surfaces
448 
449| Surface | Ink component | Gateway method |
450|---------|---------------|----------------|
451| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` |
452| Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
453| Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` |
454| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
455| Session picker | `sessionPicker.tsx` | `session.list/resume` |
456| Slash commands | Local handler + fallthrough | `slash.exec` → `_SlashWorker`, `command.dispatch` |
457| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
458| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
459 
460### Slash Command Flow
461 
4621. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx`
4632. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback
464 
465### Dev Commands
466 
467```bash
468cd ui-tui
469npm install # first time
470npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
471npm start # production
472npm run build # full build (hermes-ink + tsc)
473npm run typecheck # typecheck only (tsc --noEmit)
474npm run lint # eslint
475npm run fmt # prettier
476npm test # vitest
477```
478 
479### TUI in the Dashboard (`hermes dashboard` → `/chat`)
480 
481The 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`.
482 
483- 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.
484- `/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).
485- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not).
486- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:<cols>;<rows>]` intercepted on the server and applied with `TIOCSWINSZ`.
487 
488**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.
489 
490**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.
491 
492### Electron Desktop Chat App (`apps/desktop/`)
493 
494A **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`.
495 
496**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
497 
498- **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.
499- **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.
500 - `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
501 - `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`.
502 - `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.)
503- **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.
504 
505**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).
506 
507---
508 
509## Adding New Tools
510 
511Before adding any tool, settle the footprint question first (see "The
512Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
513be core tools. For custom or local-only tools, do **not** edit Hermes core.
514Use the plugin route instead: create `~/.hermes/plugins/<name>/plugin.yaml`
515and `~/.hermes/plugins/<name>/__init__.py`, then register tools with
516`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
517enabled or disabled without touching `tools/` or `toolsets.py`.
518 
519Use the built-in route below only when the user is explicitly contributing a new
520core Hermes tool that should ship in the base system.
521 
522Built-in/core tools require changes in **2 files**:
523 
524**1. Create `tools/your_tool.py`:**
525```python
526import json, os
527from tools.registry import registry
528 
529def check_requirements() -> bool:
530 return bool(os.getenv("EXAMPLE_API_KEY"))
531 
532def example_tool(param: str, task_id: str = None) -> str:
533 return json.dumps({"success": True, "data": "..."})
534 
535registry.register(
536 name="example_tool",
537 toolset="example",
538 schema={"name": "example_tool", "description": "...", "parameters": {...}},
539 handler=lambda args, **kw: example_tool(param=args.get("param", ""), task_id=kw.get("task_id")),
540 check_fn=check_requirements,
541 requires_env=["EXAMPLE_API_KEY"],
542)
543```
544 
545**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.
546 
547Auto-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.
548 
549The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
550 
551**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`.
552 
553**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.
554 
555**Agent-level tools** (todo, memory): intercepted by `run_agent.py` before `handle_function_call()`. See `tools/todo_tool.py` for the pattern.
556 
557---
558 
559## Dependency Pinning Policy
560 
561All dependencies must have upper bounds to limit supply-chain attack surface.
562This policy was established after the litellm compromise (PR #2796, #2810) and
563reinforced after the Mini Shai-Hulud worm campaign (May 2026).
564 
565| Source type | Treatment | Example |
566|---|---|---|
567| PyPI package | `>=floor,<next_major` | `"httpx>=0.28.1,<1"` |
568| Git URL | Commit SHA | `git+https://...@<40-char-sha>` |
569| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@<sha> # v4` |
570| CI-only pip | `==exact` | `pyyaml==6.0.2` |
571 
572**When adding a new dependency to `pyproject.toml`:**
5731. Pin to `>=current_version,<next_major` for post-1.0 (e.g. `>=1.5.0,<2`).
5742. For pre-1.0 packages, use `<0.(current_minor + 2)` (e.g. `>=0.29,<0.32`).
5753. Never commit a bare `>=X.Y.Z` without a ceiling — CI and reviewers will reject it.
5764. Run `uv lock` to regenerate `uv.lock` with hashes.
577 
578Reference: #2810 (bounds pass), #9801 (SHA pinning + audit CI).
579 
580---
581 
582## Adding Configuration
583 
584### config.yaml options:
5851. Add to `DEFAULT_CONFIG` in `hermes_cli/config.py`
5862. Bump `_config_version` (check the current value at the top of `DEFAULT_CONFIG`)
587 ONLY if you need to actively migrate/transform existing user config
588 (renaming keys, changing structure). Adding a new key to an existing
589 section is handled automatically by the deep-merge and does NOT require
590 a version bump.
591 
592### Top-level `config.yaml` sections (non-exhaustive):
593 
594`model`, `agent`, `terminal`, `compression`, `display`, `stt`, `tts`,
595`memory`, `security`, `delegation`, `smart_model_routing`, `checkpoints`,
596`auxiliary`, `curator`, `skills`, `gateway`, `logging`, `cron`, `profiles`,
597`plugins`, `honcho`.
598 
599`auxiliary` holds per-task overrides for side-LLM work (curator, vision,
600embedding, title generation, session_search, etc.) — each task can pin
601its own provider/model/base_url/max_tokens/reasoning_effort. See
602`agent/auxiliary_client.py::_resolve_auto` for resolution order.
603 
604`curator` holds the background skill-maintenance config —
605`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
606`archive_after_days`, `backup` (nested).
607 
608### .env variables (SECRETS ONLY — API keys, tokens, passwords):
6091. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
610```python
611"NEW_API_KEY": {
612 "description": "What it's for",
613 "prompt": "Display name",
614 "url": "https://...",
615 "password": True,
616 "category": "tool", # provider, tool, messaging, setting
617},
618```
619 
620Non-secret settings (timeouts, thresholds, feature flags, paths, display
621preferences) belong in `config.yaml`, not `.env`. If internal code needs an
622env var mirror for backward compatibility, bridge it from `config.yaml` to
623the env var in code (see `gateway_timeout`, `terminal.cwd` → `TERMINAL_CWD`).
624 
625### Config loaders (three paths — know which one you're in):
626 
627| Loader | Used by | Location |
628|--------|---------|----------|
629| `load_cli_config()` | CLI mode | `cli.py` — merges CLI-specific defaults + user YAML |
630| `load_config()` | `hermes tools`, `hermes setup`, most CLI subcommands | `hermes_cli/config.py` — merges `DEFAULT_CONFIG` + user YAML |
631| Direct YAML load | Gateway runtime | `gateway/run.py` + `gateway/config.py` — reads user YAML raw |
632 
633If you add a new key and the CLI sees it but the gateway doesn't (or vice
634versa), you're on the wrong loader. Check `DEFAULT_CONFIG` coverage.
635 
636### Working directory:
637- **CLI** — uses the process's current directory (`os.getcwd()`).
638- **Messaging** — uses `terminal.cwd` from `config.yaml`. The gateway bridges this
639 to the `TERMINAL_CWD` env var for child tools. **`MESSAGING_CWD` has been
640 removed** — the config loader prints a deprecation warning if it's set in
641 `.env`. Same for `TERMINAL_CWD` in `.env`; the canonical setting is
642 `terminal.cwd` in `config.yaml`.
643 
644---
645 
646## Skin/Theme System
647 
648The 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.
649 
650### Architecture
651 
652```
653hermes_cli/skin_engine.py # SkinConfig dataclass, built-in skins, YAML loader
654~/.hermes/skins/*.yaml # User-installed custom skins (drop-in)
655```
656 
657- `init_skin_from_config()` — called at CLI startup, reads `display.skin` from config
658- `get_active_skin()` — returns cached `SkinConfig` for the current skin
659- `set_active_skin(name)` — switches skin at runtime (used by `/skin` command)
660- `load_skin(name)` — loads from user skins first, then built-ins, then falls back to default
661- Missing skin values inherit from the `default` skin automatically
662 
663### What skins customize
664 
665| Element | Skin Key | Used By |
666|---------|----------|---------|
667| Banner panel border | `colors.banner_border` | `banner.py` |
668| Banner panel title | `colors.banner_title` | `banner.py` |
669| Banner section headers | `colors.banner_accent` | `banner.py` |
670| Banner dim text | `colors.banner_dim` | `banner.py` |
671| Banner body text | `colors.banner_text` | `banner.py` |
672| Response box border | `colors.response_border` | `cli.py` |
673| Spinner faces (waiting) | `spinner.waiting_faces` | `display.py` |
674| Spinner faces (thinking) | `spinner.thinking_faces` | `display.py` |
675| Spinner verbs | `spinner.thinking_verbs` | `display.py` |
676| Spinner wings (optional) | `spinner.wings` | `display.py` |
677| Tool output prefix | `tool_prefix` | `display.py` |
678| Per-tool emojis | `tool_emojis` | `display.py` → `get_tool_emoji()` |
679| Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
680| Welcome message | `branding.welcome` | `cli.py` |
681| Response box label | `branding.response_label` | `cli.py` |
682| Prompt symbol | `branding.prompt_symbol` | `cli.py` |
683 
684### Built-in skins
685 
686- `default` — Classic Hermes gold/kawaii (the current look)
687- `ares` — Crimson/bronze war-god theme with custom spinner wings
688- `mono` — Clean grayscale monochrome
689- `slate` — Cool blue developer-focused theme
690 
691### Adding a built-in skin
692 
693Add to `_BUILTIN_SKINS` dict in `hermes_cli/skin_engine.py`:
694 
695```python
696"mytheme": {
697 "name": "mytheme",
698 "description": "Short description",
699 "colors": { ... },
700 "spinner": { ... },
701 "branding": { ... },
702 "tool_prefix": "┊",
703},
704```
705 
706### User skins (YAML)
707 
708Users create `~/.hermes/skins/<name>.yaml`:
709 
710```yaml
711name: cyberpunk
712description: Neon-soaked terminal theme
713 
714colors:
715 banner_border: "#FF00FF"
716 banner_title: "#00FFFF"
717 banner_accent: "#FF1493"
718 
719spinner:
720 thinking_verbs: ["jacking in", "decrypting", "uploading"]
721 wings:
722 - ["⟨⚡", "⚡⟩"]
723 
724branding:
725 agent_name: "Cyber Agent"
726 response_label: " ⚡ Cyber "
727 
728tool_prefix: "▏"
729```
730 
731Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
732 
733---
734 
735## Plugins
736 
737Hermes has two plugin surfaces. Both live under `plugins/` in the repo so
738repo-shipped plugins can be discovered alongside user-installed ones in
739`~/.hermes/plugins/` and pip-installed entry points.
740 
741### General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
742 
743`PluginManager` discovers plugins from `~/.hermes/plugins/`, `./.hermes/plugins/`,
744and pip entry points. Each plugin exposes a `register(ctx)` function that
745can:
746 
747- Register Python-callback lifecycle hooks:
748 `pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`,
749 `on_session_start`, `on_session_end`
750- Register new tools via `ctx.register_tool(...)`
751- Register CLI subcommands via `ctx.register_cli_command(...)` — the
752 plugin's argparse tree is wired into `hermes` at startup so
753 `hermes <pluginname> <subcmd>` works with no change to `main.py`
754 
755Hooks are invoked from `model_tools.py` (pre/post tool) and `run_agent.py`
756(lifecycle). **Discovery timing pitfall:** `discover_plugins()` only runs
757as a side effect of importing `model_tools.py`. Code paths that read plugin
758state without importing `model_tools.py` first must call `discover_plugins()`
759explicitly (it's idempotent).
760 
761### Memory-provider plugins (`plugins/memory/<name>/`)
762 
763Separate discovery system for pluggable memory backends. Current built-in
764providers include **honcho, mem0, supermemory, byterover, hindsight,
765holographic, openviking, retaindb**.
766 
767Each provider implements the `MemoryProvider` ABC (see `agent/memory_provider.py`)
768and is orchestrated by `agent/memory_manager.py`. Lifecycle hooks include
769`sync_turn(turn_messages)`, `prefetch(query)`, `shutdown()`, and optional
770`post_setup(hermes_home, config)` for setup-wizard integration.
771 
772**CLI commands via `plugins/memory/<name>/cli.py`:** if a memory plugin
773defines `register_cli(subparser)`, `discover_plugin_cli_commands()` finds
774it at argparse setup time and wires it into `hermes <plugin>`. The
775framework only exposes CLI commands for the **currently active** memory
776provider (read from `memory.provider` in config.yaml), so disabled
777providers don't clutter `hermes --help`.
778 
779**Rule (Teknium, May 2026):** plugins MUST NOT modify core files
780(`run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.).
781If a plugin needs a capability the framework doesn't expose, expand the
782generic plugin surface (new hook, new ctx method) — never hardcode
783plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
784honcho argparse from `main.py` for exactly this reason.
785 
786**No new in-tree memory providers (policy, May 2026):** the set of
787built-in memory providers under `plugins/memory/` is closed. New memory
788backends must ship as **standalone plugin repos** that users install
789into `~/.hermes/plugins/` (or via pip entry points) — they implement
790the same `MemoryProvider` ABC, register through the same discovery
791path, and integrate via `hermes memory setup` / `post_setup()` without
792landing in this tree. PRs that add a new directory under
793`plugins/memory/` will be closed with a pointer to publish the
794provider as its own repo. Existing in-tree providers stay; bug fixes
795to them are welcome.
796 
797**No new third-party-product plugins in-tree (policy, June 2026):** the
798same rule applies beyond memory providers. Plugins that integrate
799someone else's product or project — observability/metrics backends,
800vendor SaaS connectors, analytics dashboards, paid-service tie-ins —
801must ship as **standalone plugin repos** that users install into
802`~/.hermes/plugins/` (or via pip entry points). They register through
803the existing plugin discovery path and use the ABCs/hooks/ctx surface
804we expose; nothing special is needed in core. The reason is
805maintenance load: every product we absorb into the tree becomes our
806burden to keep working against a fast-moving core, for a backend we
807don't own. Promote standalone plugins in the Nous Research Discord
808(`#plugins-skills-and-skins`). PRs that add such a directory under
809`plugins/` are closed with a pointer to publish it as its own repo —
810this is a coupling decision, not a quality judgment. (The
811`observability/`, `kanban/`, `disk-cleanup/`, etc. directories already
812in the tree are existing precedent, not an invitation to add more
813third-party-product plugins alongside them.)
814 
815### Model-provider plugins (`plugins/model-providers/<name>/`)
816 
817Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …)
818ships as a plugin here. Each plugin's `__init__.py` calls
819`providers.register_provider(ProviderProfile(...))` at module load.
820`providers/__init__.py._discover_providers()` is a **lazy, separate
821discovery system** — scanned on first `get_provider_profile()` or
822`list_providers()` call, NOT by the general PluginManager.
823 
824Scan order:
8251. Bundled: `<repo>/plugins/model-providers/<name>/`
8262. User: `$HERMES_HOME/plugins/model-providers/<name>/`
8273. Legacy: `<repo>/providers/<name>.py` (back-compat)
828 
829User plugins of the same name override bundled ones — `register_provider()`
830is last-writer-wins. This lets third parties swap out any built-in
831profile without a repo patch.
832 
833The general PluginManager records `kind: model-provider` manifests but does
834NOT import them (would double-instantiate `ProviderProfile`). Plugins
835without an explicit `kind:` get auto-coerced via a source-text heuristic
836(`register_provider` + `ProviderProfile` in `__init__.py`).
837 
838Full authoring guide: `website/docs/developer-guide/model-provider-plugin.md`.
839 
840### Dashboard / context-engine / image-gen plugin directories
841 
842`plugins/context_engine/`, `plugins/image_gen/`, etc. follow the same
843pattern (ABC + orchestrator + per-plugin directory). Context engines
844plug into `agent/context_engine.py`; image-gen providers into
845`agent/image_gen_provider.py`. Reference / docs-companion plugins
846(`example-dashboard`, `strike-freedom-cockpit`, `plugin-llm-example`,
847`plugin-llm-async-example`) live in the
848[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins)
849companion repo, not in this tree.
850 
851---
852 
853## Skills
854 
855Two parallel surfaces:
856 
857- **`skills/`** — built-in skills shipped and loadable by default.
858 Organized by category directories (e.g. `skills/github/`, `skills/mlops/`).
859- **`optional-skills/`** — heavier or niche skills shipped with the repo but
860 NOT active by default. Installed explicitly via
861 `hermes skills install official/<category>/<skill>`. Adapter lives in
862 `tools/skills_hub.py` (`OptionalSkillSource`). Categories include
863 `autonomous-ai-agents`, `blockchain`, `communication`, `creative`,
864 `devops`, `email`, `health`, `mcp`, `migration`, `mlops`, `productivity`,
865 `research`, `security`, `web-development`.
866 
867When reviewing skill PRs, check which directory they target — heavy-dep or
868niche skills belong in `optional-skills/`.
869 
870### SKILL.md frontmatter
871 
872Standard fields: `name`, `description`, `version`, `author`, `license`,
873`platforms` (OS-gating list: `[macos]`, `[linux, macos]`, ...),
874`metadata.hermes.tags`, `metadata.hermes.category`,
875`metadata.hermes.related_skills`, `metadata.hermes.config` (config.yaml
876settings the skill needs — stored under `skills.config.<key>`, prompted
877during setup, injected at load time).
878 
879Top-level `tags:` and `category:` are also accepted and mirrored from
880`metadata.hermes.*` by the loader.
881 
882### Skill authoring standards (HARDLINE)
883 
884Every new or modernized skill — bundled, optional, or contributed —
885must meet these standards before merge. Reviewers reject PRs that
886violate them.
887 
8881. **`description` ≤ 60 characters, one sentence, ends with a period.**
889 Long descriptions bloat skill listings and dilute the model's
890 attention when many skills are loaded. State the capability, not
891 the implementation. No marketing words ("powerful",
892 "comprehensive", "seamless", "advanced"). Don't repeat the skill
893 name. Verify with:
894 ```python
895 import re, pathlib
896 m = re.search(r'^description: (.*)$',
897 pathlib.Path('skills/<cat>/<name>/SKILL.md').read_text(),
898 re.MULTILINE)
899 assert len(m.group(1)) <= 60, len(m.group(1))
900 ```
901 
9022. **Tools referenced in SKILL.md prose must be native Hermes tools or
903 MCP servers the skill explicitly expects.** When the skill needs a
904 capability, point at the proper tool by name in backticks
905 (`` `terminal` ``, `` `web_extract` ``, `` `read_file` ``,
906 `` `patch` ``, `` `search_files` ``, `` `vision_analyze` ``,
907 `` `browser_navigate` ``, `` `delegate_task` ``, etc.). Do NOT
908 name shell utilities the agent already has wrapped — `grep` →
909 `search_files`, `cat`/`head`/`tail` → `read_file`, `sed`/`awk` →
910 `patch`, `find`/`ls` → `search_files target='files'`. If the skill
911 depends on an MCP server, name the MCP server and document the
912 expected setup in `## Prerequisites`. Anything else (third-party
913 CLIs, shell pipelines, etc.) is fair game inside script files but
914 should not be the headline interaction surface in the prose.
915 
9163. **`platforms:` gating audited against actual script imports.**
917 Skills that use POSIX-only primitives (`fcntl`, `termios`,
918 `os.setsid`, `os.kill(pid, 0)` for liveness, `/proc`, `/tmp`
919 hardcoded, `signal.SIGKILL`, bash heredocs, `osascript`, `apt`,
920 `systemctl`) must declare their supported platforms. Default
921 posture: try to fix it cross-platform first — `tempfile.gettempdir`,
922 `pathlib.Path`, `psutil.pid_exists`, Python-level filtering instead
923 of `grep`. Gate to a narrower set only when the dependency is
924 genuinely platform-bound.
925 
9264. **`author` credits the human contributor first.** For external
927 contributions, the contributor's real name + GitHub handle goes
928 first; "Hermes Agent" is the secondary collaborator. If the
929 contributor's commit shows "Hermes Agent" as author (because they
930 used Hermes to draft the skill), replace it with their actual name
931 — credit the human, not the tool.
932 
9335. **SKILL.md body uses the modern section order.** `# <Skill> Skill`
934 title, 2-3 sentence intro stating what it does and doesn't do,
935 `## When to Use`, `## Prerequisites`, `## How to Run`,
936 `## Quick Reference`, `## Procedure`, `## Pitfalls`,
937 `## Verification`. Target ~200 lines for a complex skill,
938 ~100 lines for a simple one. Cut redundant intro fluff, marketing
939 prose, and re-explanations of env vars already in
940 `## Prerequisites`.
941 
9426. **Scripts go in `scripts/`, references in `references/`,
943 templates in `templates/`.** Don't expect the model to inline-write
944 parsers, XML walkers, or non-trivial logic every call — ship a
945 helper script. Reference it from SKILL.md by path relative to the
946 skill directory.
947 
9487. **Tests live at `tests/skills/test_<skill>_skill.py`** and use only
949 stdlib + pytest + `unittest.mock`. No live network calls. Run via
950 `scripts/run_tests.sh tests/skills/test_<skill>_skill.py -q`.
951 
9528. **`.env.example` additions are isolated to a clearly delimited
953 block.** Don't touch the surrounding file — contributor-supplied
954 `.env.example` versions are usually stale and edits outside the
955 skill's own block must be dropped during salvage.
956 
957The full salvage / modernization checklist for external skill PRs
958lives in the `hermes-agent-dev` skill at
959`references/new-skill-pr-salvage.md` — load it before polishing
960contributor skill PRs.
961 
962---
963 
964## Toolsets
965 
966All toolsets are defined in `toolsets.py` as a single `TOOLSETS` dict.
967Each platform's adapter picks a base toolset (e.g. Telegram uses
968`"messaging"`); `_HERMES_CORE_TOOLS` is the default bundle most
969platforms inherit from.
970 
971Current toolset keys: `browser`, `clarify`, `code_execution`, `cronjob`,
972`debugging`, `delegation`, `discord`, `discord_admin`, `feishu_doc`,
973`feishu_drive`, `file`, `homeassistant`, `image_gen`, `kanban`, `memory`,
974`messaging`, `moa`, `rl`, `safe`, `search`, `session_search`, `skills`,
975`spotify`, `terminal`, `todo`, `tts`, `video`, `vision`, `web`, `yuanbao`.
976 
977Enable/disable per platform via `hermes tools` (the curses UI) or the
978`tools.<platform>.enabled` / `tools.<platform>.disabled` lists in
979`config.yaml`.
980 
981---
982 
983## Delegation (`delegate_task`)
984 
985`tools/delegate_tool.py` spawns a subagent with an isolated
986context + terminal session. By default the parent waits for the
987child's summary before continuing its own loop. With `background=true`,
988Hermes returns a delegation id immediately and the result re-enters the
989conversation later through the async-delegation completion queue.
990 
991Two shapes:
992 
993- **Single:** pass `goal` (+ optional `context`, `toolsets`).
994- **Batch (parallel):** pass `tasks: [...]` — each gets its own subagent
995 running concurrently. Concurrency is capped by
996 `delegation.max_concurrent_children` (default 3).
997 
998Roles:
999 
1000- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
1001 `clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code`
1002 (programmatic tool calling).
1003- `role="orchestrator"` — retains `delegate_task` so it can spawn its
1004 own workers. Gated by `delegation.orchestrator_enabled` (default true)
1005 and bounded by `delegation.max_spawn_depth` (default 2).
1006 
1007Key config knobs (under `delegation:` in `config.yaml`):
1008`max_concurrent_children`, `max_spawn_depth`, `child_timeout_seconds`,
1009`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
1010`max_iterations`.
1011 
1012Durability rule: background `delegate_task` is detached from the current
1013turn but still process-local. For work that must survive process restart, use
1014`cronjob` or `terminal(background=True, notify_on_complete=True)` instead.
1015 
1016---
1017 
1018## Curator (skill lifecycle)
1019 
1020Background skill-maintenance system that tracks usage on agent-created
1021skills and auto-archives stale ones. Users never lose skills; archives
1022go to `~/.hermes/skills/.archive/` and are restorable.
1023 
1024- **Core:** `agent/curator.py` (review loop, auto-transitions, LLM review
1025 prompt) + `agent/curator_backup.py` (pre-run tar.gz snapshots).
1026- **CLI:** `hermes_cli/curator.py` wires `hermes curator <verb>` where
1027 verbs are: `status`, `run`, `pause`, `resume`, `pin`, `unpin`,
1028 `archive`, `restore`, `prune`, `backup`, `rollback`.
1029- **Telemetry:** `tools/skill_usage.py` owns the sidecar
1030 `~/.hermes/skills/.usage.json` — per-skill `use_count`, `view_count`,
1031 `patch_count`, `last_activity_at`, `state` (active / stale /
1032 archived), `pinned`.
1033 
1034Invariants:
1035- Curator only touches skills with `created_by: "agent"` provenance —
1036 bundled + hub-installed skills are off-limits.
1037- Never deletes; max destructive action is archive.
1038- Pinned skills are exempt from every auto-transition and from the
1039 LLM review pass.
1040- `skill_manage(action="delete")` refuses pinned skills; patch/edit/
1041 write_file/remove_file go through so the agent can keep improving
1042 pinned skills.
1043 
1044Config section (`curator:` in `config.yaml`):
1045`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
1046`archive_after_days`, `backup.*`.
1047 
1048Full user-facing docs: `website/docs/user-guide/features/curator.md`.
1049 
1050---
1051 
1052## Cron (scheduled jobs)
1053 
1054`cron/jobs.py` (job store) + `cron/scheduler.py` (tick loop). Agents
1055schedule jobs via the `cronjob` tool; users via `hermes cron <verb>`
1056(`list`, `add`, `edit`, `pause`, `resume`, `run`, `remove`) or the
1057`/cron` slash command.
1058 
1059Supported schedule formats:
1060- Duration: `"30m"`, `"2h"`, `"1d"`
1061- "every" phrase: `"every 2h"`, `"every monday 9am"`
1062- 5-field cron expression: `"0 9 * * *"`
1063- ISO timestamp (one-shot): `"2026-06-01T09:00:00Z"`
1064 
1065Per-job fields include `skills` (load specific skills), `model` /
1066`provider` overrides, `script` (pre-run data-collection script whose
1067stdout is injected into the prompt; `no_agent=True` turns the script
1068into the entire job), `context_from` (chain job A's last output into
1069job B's prompt), `workdir` (run in a specific directory with its
1070`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery.
1071 
1072Hardening invariants:
1073- **3-minute hard interrupt** on cron sessions — runaway agent loops
1074 cannot monopolize the scheduler.
1075- Catchup window: half the job's period, clamped to 120s–2h.
1076- Grace window: 120s for one-shot jobs whose fire time was missed.
1077- File lock at `~/.hermes/cron/.tick.lock` prevents duplicate ticks
1078 across processes.
1079- Cron sessions pass `skip_memory=True` by default; memory providers
1080 intentionally do not run during cron.
1081 
1082Cron deliveries are **not** mirrored into the target gateway session —
1083they land in their own cron session with a header/footer frame so the
1084main conversation's message-role alternation stays intact.
1085 
1086---
1087 
1088## Kanban (multi-agent work queue)
1089 
1090Durable SQLite-backed board that lets multiple profiles / workers
1091collaborate on shared tasks. Users drive it via `hermes kanban <verb>`;
1092workers spawned by the dispatcher drive it via a dedicated `kanban_*`
1093toolset so their schema footprint is zero when they're not inside a
1094kanban task.
1095 
1096- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
1097 `init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
1098 `unlink`, `comment`, `attach`, `attachments`, `attach-rm`, `complete`,
1099 `block`, `unblock`, `archive`, `tail`, plus less-commonly-used `watch`,
1100 `stats`, `runs`, `log`, `assignees`, `heartbeat`, `notify-*`,
1101 `dispatch`, `daemon`, `gc`.
1102- **Worker/orchestrator toolset:** `tools/kanban_tools.py` exposes
1103 `kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`,
1104 `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_attach`,
1105 `kanban_attach_url`, `kanban_attachments`; profiles that explicitly
1106 enable the `kanban` toolset outside a dispatcher-spawned task also get
1107 `kanban_list` and `kanban_unblock` for board routing.
1108- **Dispatcher:** long-lived loop that (default every 60s) reclaims
1109 stale claims, promotes ready tasks, atomically claims, and spawns
1110 assigned profiles. Runs **inside the gateway** by default via
1111 `kanban.dispatch_in_gateway: true`.
1112- **Plugin assets:** `plugins/kanban/dashboard/` (web UI) +
1113 `plugins/kanban/systemd/` (`hermes-kanban-dispatcher.service` for
1114 standalone dispatcher deployment).
1115 
1116Isolation model:
1117- **Board** is the hard boundary — workers are spawned with
1118 `HERMES_KANBAN_BOARD` pinned in their env so they can't see other
1119 boards.
1120- **Tenant** is a soft namespace *within* a board — one specialist
1121 fleet can serve multiple businesses with workspace-path + memory-key
1122 isolation.
1123- After `kanban.failure_limit` consecutive non-success attempts on the
1124 same task (default: 2), the dispatcher auto-blocks it to prevent spin
1125 loops.
1126 
1127Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
1128 
1129---
1130 
1131## Important Policies
1132 
1133### Prompt Caching Must Not Break
1134 
1135Hermes-Agent ensures caching remains valid throughout a conversation. **Do NOT implement changes that would:**
1136- Alter past context mid-conversation
1137- Change toolsets mid-conversation
1138- Reload memories or rebuild system prompts mid-conversation
1139 
1140Cache-breaking forces dramatically higher costs. The ONLY time we alter context is during context compression.
1141 
1142Slash commands that mutate system-prompt state (skills, tools, memory, etc.)
1143must be **cache-aware**: default to deferred invalidation (change takes
1144effect next session), with an opt-in `--now` flag for immediate
1145invalidation. See `/skills install --now` for the canonical pattern.
1146 
1147### Background Process Notifications (Gateway)
1148 
1149When `terminal(background=true, notify_on_complete=true)` is used, the gateway runs a watcher that
1150detects process completion and triggers a new agent turn. Control verbosity of background process
1151messages with `display.background_process_notifications`
1152in config.yaml (or `HERMES_BACKGROUND_NOTIFICATIONS` env var):
1153 
1154- `all` — running-output updates + final message (default)
1155- `result` — only the final completion message
1156- `error` — only the final message when exit code != 0
1157- `off` — no watcher messages at all
1158 
1159---
1160 
1161## Profiles: Multi-Instance Support
1162 
1163Hermes supports **profiles** — multiple fully isolated instances, each with its own
1164`HERMES_HOME` directory (config, API keys, memory, sessions, skills, gateway, etc.).
1165 
1166The core mechanism: `_apply_profile_override()` in `hermes_cli/main.py` sets
1167`HERMES_HOME` before any module imports. All `get_hermes_home()` references
1168automatically scope to the active profile.
1169 
1170### Rules for profile-safe code
1171 
11721. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`.
1173 NEVER hardcode `~/.hermes` or `Path.home() / ".hermes"` in code that reads/writes state.
1174 ```python
1175 # GOOD
1176 from hermes_constants import get_hermes_home
1177 config_path = get_hermes_home() / "config.yaml"
1178 
1179 # BAD — breaks profiles
1180 config_path = Path.home() / ".hermes" / "config.yaml"
1181 ```
1182 
11832. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`.
1184 This returns `~/.hermes` for default or `~/.hermes/profiles/<name>` for profiles.
1185 ```python
1186 # GOOD
1187 from hermes_constants import display_hermes_home
1188 print(f"Config saved to {display_hermes_home()}/config.yaml")
1189 
1190 # BAD — shows wrong path for profiles
1191 print("Config saved to ~/.hermes/config.yaml")
1192 ```
1193 
11943. **Module-level constants are fine** — they cache `get_hermes_home()` at import time,
1195 which is AFTER `_apply_profile_override()` sets the env var. Just use `get_hermes_home()`,
1196 not `Path.home() / ".hermes"`.
1197 
11984. **Tests that mock `Path.home()` must also set `HERMES_HOME`** — since code now uses
1199 `get_hermes_home()` (reads env var), not `Path.home() / ".hermes"`:
1200 ```python
1201 with patch.object(Path, "home", return_value=tmp_path), \
1202 patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
1203 ...
1204 ```
1205 
12065. **Gateway platform adapters should use token locks** — if the adapter connects with
1207 a unique credential (bot token, API key), call `acquire_scoped_lock()` from
1208 `gateway.status` in the `connect()`/`start()` method and `release_scoped_lock()` in
1209 `disconnect()`/`stop()`. This prevents two profiles from using the same credential.
1210 See `plugins/platforms/irc/adapter.py` for the canonical pattern.
1211 
12126. **Profile operations are HOME-anchored, not HERMES_HOME-anchored** — `_get_profiles_root()`
1213 returns `Path.home() / ".hermes" / "profiles"`, NOT `get_hermes_home() / "profiles"`.
1214 This is intentional — it lets `hermes -p coder profile list` see all profiles regardless
1215 of which one is active.
1216 
1217## Known Pitfalls
1218 
1219### DO NOT hardcode `~/.hermes` paths
1220Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_hermes_home()`
1221for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile
1222has its own `HERMES_HOME` directory. This was the source of 5 bugs fixed in PR #3575.
1223 
1224### DO NOT introduce new `simple_term_menu` usage
1225Existing call sites in `hermes_cli/main.py` remain for legacy fallback only;
1226the preferred UI is curses (stdlib) because `simple_term_menu` has
1227ghost-duplication rendering bugs in tmux/iTerm2 with arrow keys. New
1228interactive menus must use `hermes_cli/curses_ui.py` — see
1229`hermes_cli/tools_config.py` for the canonical pattern.
1230 
1231### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code
1232Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`.
1233 
1234### `_last_resolved_tool_names` is a process-global in `model_tools.py`
1235`_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.
1236 
1237### DO NOT hardcode cross-tool references in schema descriptions
1238Tool 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.
1239 
1240### The gateway has TWO message guards — both must bypass approval/control commands
1241When an agent is running, messages pass through two sequential guards:
1242(1) **base adapter** (`gateway/platforms/base.py`) queues messages in
1243`_pending_messages` when `session_key in self._active_sessions`, and
1244(2) **gateway runner** (`gateway/run.py`) intercepts `/stop`, `/new`,
1245`/queue`, `/status`, `/approve`, `/deny` before they reach
1246`running_agent.interrupt()`. Any new command that must reach the runner
1247while the agent is blocked (e.g. approval prompts) MUST bypass BOTH
1248guards and be dispatched inline, not via `_process_message_background()`
1249(which races session lifecycle).
1250 
1251### Squash merges from stale branches silently revert recent fixes
1252Before squash-merging a PR, ensure the branch is up to date with `main`
1253(`git fetch origin main && git reset --hard origin/main` in the worktree,
1254then re-apply the PR's commits). A stale branch's version of an unrelated
1255file will silently overwrite recent fixes on main when squashed. Verify
1256with `git diff HEAD~1..HEAD` after merging — unexpected deletions are a
1257red flag.
1258 
1259### Don't wire in dead code without E2E validation
1260Unused code that was never shipped was dead for a reason. Before wiring an
1261unused module into a live code path, E2E test the real resolution chain
1262with actual imports (not mocks) against a temp `HERMES_HOME`.
1263 
1264### Tests must not write to `~/.hermes/`
1265The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests.
1266 
1267**Profile tests**: When testing profile features, also mock `Path.home()` so that
1268`_get_profiles_root()` and `_get_default_hermes_home()` resolve within the temp dir.
1269Use the pattern from `tests/hermes_cli/test_profiles.py`:
1270```python
1271@pytest.fixture
1272def profile_env(tmp_path, monkeypatch):
1273 home = tmp_path / ".hermes"
1274 home.mkdir()
1275 monkeypatch.setattr(Path, "home", lambda: tmp_path)
1276 monkeypatch.setenv("HERMES_HOME", str(home))
1277 return home
1278```
1279 
1280---
1281 
1282## Testing
1283 
1284### Python
1285**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
1286hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
1287per-file subprocess isolation via `scripts/run_tests_parallel.py` — no xdist,
1288worker count auto-scaled from CPU count). Direct `pytest`
1289on a 16+ core developer machine with API keys set diverges from CI in ways
1290that have caused multiple "works locally, fails in CI" incidents (and the reverse).
1291 
1292```bash
1293scripts/run_tests.sh # full suite, CI-parity
1294scripts/run_tests.sh tests/gateway/ # one directory
1295scripts/run_tests.sh tests/agent/test_foo.py -k test_x # one test (file + -k; the runner is file-granular)
1296scripts/run_tests.sh -v --tb=long # pass-through pytest flags
1297```
1298 
1299**Flake policy:** the runner auto-retries a failing test FILE once in a fresh
1300subprocess (`--file-retries`, default 1; `HERMES_TEST_FILE_RETRIES=0` to
1301disable). Pass-on-retry counts as green but is printed in a `⚠ FLAKY` summary
1302section with both attempts' output. A FLAKY report is a bug to fix, not noise
1303to ignore — timing-sensitive tests must not assume a quiet runner (loose
1304wall-clock bounds ≥ 2s, event-based sync, no `assert not _wait_until(...)`
1305negative-timing races).
1306 
1307#### Subprocess-per-test-file isolation
1308 
1309Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
1310ContextVars from one test file cannot leak into the next.
1311 
1312#### Why the wrapper
1313 
1314| | Without wrapper | With wrapper |
1315| ------------------- | ------------------------------------------- | ----------------------------------------- |
1316| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. |
1317| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
1318| Timezone | Local TZ (PDT etc.) | UTC |
1319| Locale | Whatever is set | C.UTF-8 |
1320 
1321### Where to place what tests
1322 
1323The CI change classifier (`scripts/ci/classify_changes.py`) runs specific jobs based on what files changed. A Python test that asserts
1324about the contents of `package.json`, `package-lock.json`, `.ts`/`.tsx`
1325source, or any other JS-side artifact will not run on a PR that only touches
1326those files. This means a regression can go green on a PR and red on `main` (where the
1327classifier fails open and runs everything).
1328 
1329Any test that reads or asserts about `package.json`,
1330`package-lock.json`, `tsconfig.json`, `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs`
1331source files configuration belongs in the JS (vitest) test suite, not in `tests/*.py`.
1332 
1333### Don't write change-detector tests
1334 
1335A test is a **change-detector** if it fails whenever data that is **expected
1336to change** gets updated — model catalogs, config version numbers,
1337enumeration counts, hardcoded lists of provider models. These tests add no
1338behavioral coverage; they just guarantee that routine source updates break
1339CI and cost engineering time to "fix."
1340 
1341**Do not write:**
1342 
1343```python
1344# catalog snapshot — breaks every model release
1345assert "gemini-2.5-pro" in _PROVIDER_MODELS["gemini"]
1346assert "MiniMax-M2.7" in models
1347 
1348# config version literal — breaks every schema bump
1349assert DEFAULT_CONFIG["_config_version"] == 21
1350 
1351# enumeration count — breaks every time a skill/provider is added
1352assert len(_PROVIDER_MODELS["huggingface"]) == 8
1353```
1354 
1355**Do write:**
1356 
1357```python
1358# behavior: does the catalog plumbing work at all?
1359assert "gemini" in _PROVIDER_MODELS
1360assert len(_PROVIDER_MODELS["gemini"]) >= 1
1361 
1362# behavior: does migration bump the user's version to current latest?
1363assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
1364 
1365# invariant: no plan-only model leaks into the legacy list
1366assert not (set(moonshot_models) & coding_plan_only_models)
1367 
1368# invariant: every model in the catalog has a context-length entry
1369for m in _PROVIDER_MODELS["huggingface"]:
1370 assert m.lower() in DEFAULT_CONTEXT_LENGTHS_LOWER
1371```
1372 
1373The rule: if the test reads like a snapshot of current data, delete it. If
1374it reads like a contract about how two pieces of data must relate, keep it.
1375When a PR adds a new provider/model and you want a test, make the test
1376assert the relationship (e.g. "catalog entries all have context lengths"),
1377not the specific names.
1378 
1379Reviewers should reject new change-detector tests; authors should convert
1380them into invariants before re-requesting review.
1381 
1382### Never read source code in tests
1383 
1384A test that reads a source file's text is testing *the shape of the
1385source code*, not its behavior. This is a hard antipattern, banned outright.
1386Any test that reads a .py, .ts, .tsx, etc., file is suspect.
1387 
1388**Why it's actively harmful, not just weak:**
1389 
1390- It passes when the implementation is subtly broken (the regex matches a
1391 call site that exists but is wired wrong) and fails when a correct
1392 refactor changes formatting, variable names, or control flow with
1393 identical runtime behavior. Both directions of failure are wrong.
1394- It can't be run against a built/bundled/minified artifact, so it silently
1395 stops testing anything the moment code moves, gets renamed, or a
1396 dependency reformats it.
1397- It actively blocks refactors: reviewers see "keeps a pattern intact" tests
1398 fail during pure structural cleanup with no behavior change, and either
1399 hand-wave the failure (dangerous) or waste time updating regexes that add
1400 nothing (waste).
1401- It gives false confidence. a green suite full of source-regex tests
1402 looks like coverage but has never once executed the code path it claims
1403 to guard.
1404 
1405**Do not write:**
1406 
1407```ts
1408const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
1409 
1410test('backend spawn hides the Windows console', () => {
1411 assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
1412})
1413```
1414 
1415**Do write — extract the logic into a small pure/DI-testable function and
1416call it for real:**
1417 
1418```ts
1419// backend-spawn.ts
1420export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
1421 if (!isWindows || 'windowsHide' in options) return options
1422 return { ...options, windowsHide: true }
1423}
1424 
1425// backend-spawn.test.ts
1426test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
1427 assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
1428 assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
1429 assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
1430})
1431```
1432 
1433If the logic lives inline in a god-file (`main.ts`, `cli.py`,
1434`gateway/run.py`) and extracting it feels disruptive: that's the actual
1435signal to do the extraction, not to regex around it.
1436 
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 
156## Respect the person using it
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157 
158Design and engineering meet at intent. The user's attention and context are
159sacred:
160 
161- Never navigate, move focus, or open a surface because something *happened* in
162 the background. Offer; don't hijack.
163- The states around loading are distinct experiences — empty, loading,
164 reconnecting, degraded/stale, and exhausted-recovery each deserve their own
165 honest copy and their own way out.
166- Keyboard ownership follows focus. The focused surface wins its keys; one
167 cancel gesture does exactly one thing.
168- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden.
169 Visibility is not lifecycle.
170 
171## Make it feel instant
172 
173Performance is a feature the user feels, especially in drag, resize, scroll,
174typing, streaming, and terminals. The principles are timeless even as the code
175changes: keep hot-path state local or narrowly derived; don't subscribe heavy
176trees to per-frame updates; coalesce pointer work; avoid reading layout right
177after writing style; and don't mount expensive content mid-gesture. Prove speed
178against realistic content — a fast empty demo proves nothing about a long
179transcript. If motion is masking latency, remove the motion, don't tune it.
180 
181## Testing as a habit of proof
 
 
182 
183Test the behavior that would actually break a user, not a snapshot of today's
184data. Favor invariants over frozen values. Exercise the real path for anything
185at a seam — resolver precedence and its failure rungs, identity and scope
186boundaries, optimistic rollback and stale-response ordering, and both sides of a
187local/remote adapter with its profile routing intact. Match how the suite is
188actually run rather than inventing a command; when in doubt, read the scripts.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189 
190## The taste test before you hand off
 
191 
192- Does every piece of state live with its authority, at the narrowest scope?
193- Would a background event ever steal the foreground or the user's focus?
194- Does each resolver have one home, a validated ladder, and a bounded, recoverable
195 end?
196- Do local, remote, and profile routing still agree?
197- Does async failure leave a usable UI and a way forward?
198- Do hot interactions stay cheap under realistic load?
199- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all
200 locales?
201 
202If any answer is "not sure," that's the part to go verify.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203 
@@ −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−## Development Environment
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−```bash
216−# Prefer .venv; fall back to venv if that's what your checkout has.
217−source .venv/bin/activate # or: source venv/bin/activate
218−```
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−`scripts/run_tests.sh` probes `.venv` first, then `venv`, then
221−`$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the
222−main checkout).
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.
223124  
224−## Project Structure
125+Two auth-flavored corollaries worth naming because they are easy to get wrong:
225126  
226−File counts shift constantly — don't treat the tree below as exhaustive.
227−The canonical source is the filesystem. The notes call out the load-bearing
228−entry points you'll actually edit.
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."
229136  
230−```
231−hermes-agent/
232−├── run_agent.py # AIAgent class — core conversation loop (~12k LOC)
233−├── model_tools.py # Tool orchestration, discover_builtin_tools(), handle_function_call()
234−├── toolsets.py # Toolset definitions, _HERMES_CORE_TOOLS list
235−├── cli.py # HermesCLI class — interactive CLI orchestrator (~11k LOC)
236−├── hermes_state.py # SessionDB — SQLite session store (FTS5 search)
237−├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths
238−├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
239−├── batch_runner.py # Parallel batch processing
240−├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
241−├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
242−├── tools/ # Tool implementations — auto-discovered via tools/registry.py
243−│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
244−├── gateway/ # Messaging gateway — run.py + session.py + platforms/
245−│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
246−│ │ # homeassistant, signal, matrix, mattermost, email, sms,
247−│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
248−│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
249−│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
250−├── plugins/ # Plugin system (see "Plugins" section below)
251−│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
252−│ ├── context_engine/ # Context-engine plugins
253−│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
254−│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
255−│ ├── hermes-achievements/ # Gamified achievement tracking
256−│ ├── observability/ # Metrics / traces / logs plugin
257−│ ├── image_gen/ # Image-generation providers
258−│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
259−│ # strike-freedom-cockpit, ...
260−├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
261−├── skills/ # Built-in skills bundled with the repo
262−├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
263−│ └── src/ # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib
264−├── tui_gateway/ # Python JSON-RPC backend for the TUI
265−├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
266−├── cron/ # Scheduler — jobs.py, scheduler.py
267−├── scripts/ # run_tests.sh, release.py, auxiliary scripts
268−├── website/ # Docusaurus docs site
269−└── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026)
270−```
137+## Compatibility without carrying the past forever
271138  
272−**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
273−**Logs:** `~/.hermes/logs/` — `agent.log` (INFO+), `errors.log` (WARNING+),
274−`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
275−Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
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.
276144  
277−## TypeScript Style
145+## Keep the waist narrow, grow at the edges
278146  
279−Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
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.
280155  
281−- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
282−- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
283−- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
284−- Do not pass state through three components when the leaf can subscribe to the atom.
285−- Keep persistence beside the atom that owns it.
286−- Keep route roots thin. They compose routes and shell; they should not become controllers.
287−- No monolithic hooks. A hook should own one narrow job.
288−- Prefer colocated action modules over hidden god hooks.
289−- If a callback is pure side effect, use the terse void form:
290− `onState={st => void setGatewayState(st)}`.
291−- Async UI handlers should make intent explicit:
292− `onClick={() => void save()}`.
293−- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
294−- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
295−- Table-driven beats condition ladders when mapping ids, routes, or views.
296−- `src/app` owns routes, pages, and page-specific components.
297−- `src/store` owns shared atoms.
298−- `src/lib` owns shared pure helpers.
156+## Respect the person using it
299157  
300−## File Dependency Chain
158+Design and engineering meet at intent. The user's attention and context are
159+sacred:
301160  
302−```
303−tools/registry.py (no deps — imported by all tool files)
304− ↑
305−tools/*.py (each calls registry.register() at import time)
306− ↑
307−model_tools.py (imports tools/registry + triggers tool discovery)
308− ↑
309−run_agent.py, cli.py, batch_runner.py, environments/
310−```
161+- Never navigate, move focus, or open a surface because something *happened* in
162+ the background. Offer; don't hijack.
163+- The states around loading are distinct experiences — empty, loading,
164+ reconnecting, degraded/stale, and exhausted-recovery each deserve their own
165+ honest copy and their own way out.
166+- Keyboard ownership follows focus. The focused surface wins its keys; one
167+ cancel gesture does exactly one thing.
168+- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden.
169+ Visibility is not lifecycle.
311170  
312−---
171+## Make it feel instant
313172  
314−## AIAgent Class (run_agent.py)
173+Performance is a feature the user feels, especially in drag, resize, scroll,
174+typing, streaming, and terminals. The principles are timeless even as the code
175+changes: keep hot-path state local or narrowly derived; don't subscribe heavy
176+trees to per-frame updates; coalesce pointer work; avoid reading layout right
177+after writing style; and don't mount expensive content mid-gesture. Prove speed
178+against realistic content — a fast empty demo proves nothing about a long
179+transcript. If motion is masking latency, remove the motion, don't tune it.
315180  
316−The real `AIAgent.__init__` takes ~60 parameters (credentials, routing, callbacks,
317−session context, budget, credential pool, etc.). The signature below is the
318−minimum subset you'll usually touch — read `run_agent.py` for the full list.
181+## Testing as a habit of proof
319182  
320−```python
321−class AIAgent:
322− def __init__(self,
323− base_url: str = None,
324− api_key: str = None,
325− provider: str = None,
326− api_mode: str = None, # "chat_completions" | "codex_responses" | ...
327− model: str = "", # empty → resolved from config/provider later
328− max_iterations: int = 500, # tool-calling iterations (shared with subagents)
329− enabled_toolsets: list = None,
330− disabled_toolsets: list = None,
331− quiet_mode: bool = False,
332− save_trajectories: bool = False,
333− platform: str = None, # "cli", "telegram", etc.
334− session_id: str = None,
335− skip_context_files: bool = False,
336− skip_memory: bool = False,
337− credential_pool=None,
338− # ... plus callbacks, thread/user/chat IDs, iteration_budget, fallback_model,
339− # checkpoints config, prefill_messages, service_tier, reasoning_config, etc.
340− ): ...
183+Test the behavior that would actually break a user, not a snapshot of today's
184+data. Favor invariants over frozen values. Exercise the real path for anything
185+at a seam — resolver precedence and its failure rungs, identity and scope
186+boundaries, optimistic rollback and stale-response ordering, and both sides of a
187+local/remote adapter with its profile routing intact. Match how the suite is
188+actually run rather than inventing a command; when in doubt, read the scripts.
341189  
342− def chat(self, message: str) -> str:
343− """Simple interface — returns final response string."""
190+## The taste test before you hand off
344191  
345− def run_conversation(self, user_message: str, system_message: str = None,
346− conversation_history: list = None, task_id: str = None) -> dict:
347− """Full interface — returns dict with final_response + messages."""
348−```
192+- Does every piece of state live with its authority, at the narrowest scope?
193+- Would a background event ever steal the foreground or the user's focus?
194+- Does each resolver have one home, a validated ladder, and a bounded, recoverable
195+ end?
196+- Do local, remote, and profile routing still agree?
197+- Does async failure leave a usable UI and a way forward?
198+- Do hot interactions stay cheap under realistic load?
199+- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all
200+ locales?
349201  
350−### Agent Loop
351− 
352−The core loop is inside `run_conversation()` — entirely synchronous, with
353−interrupt checks, budget tracking, and a one-turn grace call:
354− 
355−```python
356−while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
357− or self._budget_grace_call:
358− if self._interrupt_requested: break
359− response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
360− if response.tool_calls:
361− for tool_call in response.tool_calls:
362− result = handle_function_call(tool_call.name, tool_call.args, task_id)
363− messages.append(tool_result_message(result))
364− api_call_count += 1
365− else:
366− return response.content
367−```
368− 
369−Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`.
370−Reasoning content is stored in `assistant_msg["reasoning"]`.
371− 
372−---
373− 
374−## CLI Architecture (cli.py)
375− 
376−- **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete
377−- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
378−- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
379−- **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
380−- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry
381−- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching
382− 
383−### Slash Command Registry (`hermes_cli/commands.py`)
384− 
385−All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically:
386− 
387−- **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name
388−- **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch
389−- **Gateway help** — `gateway_help_lines()` generates `/help` output
390−- **Telegram** — `telegram_bot_commands()` generates the BotCommand menu
391−- **Slack** — `slack_subcommand_map()` generates `/hermes` subcommand routing
392−- **Autocomplete** — `COMMANDS` flat dict feeds `SlashCommandCompleter`
393−- **CLI help** — `COMMANDS_BY_CATEGORY` dict feeds `show_help()`
394− 
395−### Adding a Slash Command
396− 
397−1. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
398−```python
399−CommandDef("mycommand", "Description of what it does", "Session",
400− aliases=("mc",), args_hint="[arg]"),
401−```
402−2. Add handler in `HermesCLI.process_command()` in `cli.py`:
403−```python
404−elif canonical == "mycommand":
405− self._handle_mycommand(cmd_original)
406−```
407−3. If the command is available in the gateway, add a handler in `gateway/run.py`:
408−```python
409−if canonical == "mycommand":
410− return await self._handle_mycommand(event)
411−```
412−4. For persistent settings, use `save_config_value()` in `cli.py`
413− 
414−**CommandDef fields:**
415−- `name` — canonical name without slash (e.g. `"background"`)
416−- `description` — human-readable description
417−- `category` — one of `"Session"`, `"Configuration"`, `"Tools & Skills"`, `"Info"`, `"Exit"`
418−- `aliases` — tuple of alternative names (e.g. `("bg",)`)
419−- `args_hint` — argument placeholder shown in help (e.g. `"<prompt>"`, `"[name]"`)
420−- `cli_only` — only available in the interactive CLI
421−- `gateway_only` — only available in messaging platforms
422−- `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.
423− 
424−**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.
425− 
426−---
427− 
428−## TUI Architecture (ui-tui + tui_gateway)
429− 
430−The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`.
431− 
432−### Process Model
433− 
434−```
435−hermes --tui
436− └─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway)
437− │ └─ AIAgent + tools + sessions
438− └─ renders transcript, composer, prompts, activity
439−```
440− 
441−TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic.
442− 
443−### Transport
444− 
445−Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog.
446− 
447−### Key Surfaces
448− 
449−| Surface | Ink component | Gateway method |
450−|---------|---------------|----------------|
451−| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` |
452−| Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
453−| Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` |
454−| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
455−| Session picker | `sessionPicker.tsx` | `session.list/resume` |
456−| Slash commands | Local handler + fallthrough | `slash.exec` → `_SlashWorker`, `command.dispatch` |
457−| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
458−| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
459− 
460−### Slash Command Flow
461− 
462−1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx`
463−2. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback
464− 
465−### Dev Commands
466− 
467−```bash
468−cd ui-tui
469−npm install # first time
470−npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
471−npm start # production
472−npm run build # full build (hermes-ink + tsc)
473−npm run typecheck # typecheck only (tsc --noEmit)
474−npm run lint # eslint
475−npm run fmt # prettier
476−npm test # vitest
477−```
478− 
479−### TUI in the Dashboard (`hermes dashboard` → `/chat`)
480− 
481−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`.
482− 
483−- 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.
484−- `/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).
485−- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not).
486−- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:<cols>;<rows>]` intercepted on the server and applied with `TIOCSWINSZ`.
487− 
488−**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.
489− 
490−**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.
491− 
492−### Electron Desktop Chat App (`apps/desktop/`)
493− 
494−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`.
495− 
496−**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
497− 
498−- **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.
499−- **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.
500− - `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
501− - `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`.
502− - `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.)
503−- **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.
504− 
505−**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).
506− 
507−---
508− 
509−## Adding New Tools
510− 
511−Before adding any tool, settle the footprint question first (see "The
512−Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
513−be core tools. For custom or local-only tools, do **not** edit Hermes core.
514−Use the plugin route instead: create `~/.hermes/plugins/<name>/plugin.yaml`
515−and `~/.hermes/plugins/<name>/__init__.py`, then register tools with
516−`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
517−enabled or disabled without touching `tools/` or `toolsets.py`.
518− 
519−Use the built-in route below only when the user is explicitly contributing a new
520−core Hermes tool that should ship in the base system.
521− 
522−Built-in/core tools require changes in **2 files**:
523− 
524−**1. Create `tools/your_tool.py`:**
525−```python
526−import json, os
527−from tools.registry import registry
528− 
529−def check_requirements() -> bool:
530− return bool(os.getenv("EXAMPLE_API_KEY"))
531− 
532−def example_tool(param: str, task_id: str = None) -> str:
533− return json.dumps({"success": True, "data": "..."})
534− 
535−registry.register(
536− name="example_tool",
537− toolset="example",
538− schema={"name": "example_tool", "description": "...", "parameters": {...}},
539− handler=lambda args, **kw: example_tool(param=args.get("param", ""), task_id=kw.get("task_id")),
540− check_fn=check_requirements,
541− requires_env=["EXAMPLE_API_KEY"],
542−)
543−```
544− 
545−**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.
546− 
547−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.
548− 
549−The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
550− 
551−**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`.
552− 
553−**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.
554− 
555−**Agent-level tools** (todo, memory): intercepted by `run_agent.py` before `handle_function_call()`. See `tools/todo_tool.py` for the pattern.
556− 
557−---
558− 
559−## Dependency Pinning Policy
560− 
561−All dependencies must have upper bounds to limit supply-chain attack surface.
562−This policy was established after the litellm compromise (PR #2796, #2810) and
563−reinforced after the Mini Shai-Hulud worm campaign (May 2026).
564− 
565−| Source type | Treatment | Example |
566−|---|---|---|
567−| PyPI package | `>=floor,<next_major` | `"httpx>=0.28.1,<1"` |
568−| Git URL | Commit SHA | `git+https://...@<40-char-sha>` |
569−| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@<sha> # v4` |
570−| CI-only pip | `==exact` | `pyyaml==6.0.2` |
571− 
572−**When adding a new dependency to `pyproject.toml`:**
573−1. Pin to `>=current_version,<next_major` for post-1.0 (e.g. `>=1.5.0,<2`).
574−2. For pre-1.0 packages, use `<0.(current_minor + 2)` (e.g. `>=0.29,<0.32`).
575−3. Never commit a bare `>=X.Y.Z` without a ceiling — CI and reviewers will reject it.
576−4. Run `uv lock` to regenerate `uv.lock` with hashes.
577− 
578−Reference: #2810 (bounds pass), #9801 (SHA pinning + audit CI).
579− 
580−---
581− 
582−## Adding Configuration
583− 
584−### config.yaml options:
585−1. Add to `DEFAULT_CONFIG` in `hermes_cli/config.py`
586−2. Bump `_config_version` (check the current value at the top of `DEFAULT_CONFIG`)
587− ONLY if you need to actively migrate/transform existing user config
588− (renaming keys, changing structure). Adding a new key to an existing
589− section is handled automatically by the deep-merge and does NOT require
590− a version bump.
591− 
592−### Top-level `config.yaml` sections (non-exhaustive):
593− 
594−`model`, `agent`, `terminal`, `compression`, `display`, `stt`, `tts`,
595−`memory`, `security`, `delegation`, `smart_model_routing`, `checkpoints`,
596−`auxiliary`, `curator`, `skills`, `gateway`, `logging`, `cron`, `profiles`,
597−`plugins`, `honcho`.
598− 
599−`auxiliary` holds per-task overrides for side-LLM work (curator, vision,
600−embedding, title generation, session_search, etc.) — each task can pin
601−its own provider/model/base_url/max_tokens/reasoning_effort. See
602−`agent/auxiliary_client.py::_resolve_auto` for resolution order.
603− 
604−`curator` holds the background skill-maintenance config —
605−`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
606−`archive_after_days`, `backup` (nested).
607− 
608−### .env variables (SECRETS ONLY — API keys, tokens, passwords):
609−1. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
610−```python
611−"NEW_API_KEY": {
612− "description": "What it's for",
613− "prompt": "Display name",
614− "url": "https://...",
615− "password": True,
616− "category": "tool", # provider, tool, messaging, setting
617−},
618−```
619− 
620−Non-secret settings (timeouts, thresholds, feature flags, paths, display
621−preferences) belong in `config.yaml`, not `.env`. If internal code needs an
622−env var mirror for backward compatibility, bridge it from `config.yaml` to
623−the env var in code (see `gateway_timeout`, `terminal.cwd` → `TERMINAL_CWD`).
624− 
625−### Config loaders (three paths — know which one you're in):
626− 
627−| Loader | Used by | Location |
628−|--------|---------|----------|
629−| `load_cli_config()` | CLI mode | `cli.py` — merges CLI-specific defaults + user YAML |
630−| `load_config()` | `hermes tools`, `hermes setup`, most CLI subcommands | `hermes_cli/config.py` — merges `DEFAULT_CONFIG` + user YAML |
631−| Direct YAML load | Gateway runtime | `gateway/run.py` + `gateway/config.py` — reads user YAML raw |
632− 
633−If you add a new key and the CLI sees it but the gateway doesn't (or vice
634−versa), you're on the wrong loader. Check `DEFAULT_CONFIG` coverage.
635− 
636−### Working directory:
637−- **CLI** — uses the process's current directory (`os.getcwd()`).
638−- **Messaging** — uses `terminal.cwd` from `config.yaml`. The gateway bridges this
639− to the `TERMINAL_CWD` env var for child tools. **`MESSAGING_CWD` has been
640− removed** — the config loader prints a deprecation warning if it's set in
641− `.env`. Same for `TERMINAL_CWD` in `.env`; the canonical setting is
642− `terminal.cwd` in `config.yaml`.
643− 
644−---
645− 
646−## Skin/Theme System
647− 
648−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.
649− 
650−### Architecture
651− 
652−```
653−hermes_cli/skin_engine.py # SkinConfig dataclass, built-in skins, YAML loader
654−~/.hermes/skins/*.yaml # User-installed custom skins (drop-in)
655−```
656− 
657−- `init_skin_from_config()` — called at CLI startup, reads `display.skin` from config
658−- `get_active_skin()` — returns cached `SkinConfig` for the current skin
659−- `set_active_skin(name)` — switches skin at runtime (used by `/skin` command)
660−- `load_skin(name)` — loads from user skins first, then built-ins, then falls back to default
661−- Missing skin values inherit from the `default` skin automatically
662− 
663−### What skins customize
664− 
665−| Element | Skin Key | Used By |
666−|---------|----------|---------|
667−| Banner panel border | `colors.banner_border` | `banner.py` |
668−| Banner panel title | `colors.banner_title` | `banner.py` |
669−| Banner section headers | `colors.banner_accent` | `banner.py` |
670−| Banner dim text | `colors.banner_dim` | `banner.py` |
671−| Banner body text | `colors.banner_text` | `banner.py` |
672−| Response box border | `colors.response_border` | `cli.py` |
673−| Spinner faces (waiting) | `spinner.waiting_faces` | `display.py` |
674−| Spinner faces (thinking) | `spinner.thinking_faces` | `display.py` |
675−| Spinner verbs | `spinner.thinking_verbs` | `display.py` |
676−| Spinner wings (optional) | `spinner.wings` | `display.py` |
677−| Tool output prefix | `tool_prefix` | `display.py` |
678−| Per-tool emojis | `tool_emojis` | `display.py` → `get_tool_emoji()` |
679−| Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
680−| Welcome message | `branding.welcome` | `cli.py` |
681−| Response box label | `branding.response_label` | `cli.py` |
682−| Prompt symbol | `branding.prompt_symbol` | `cli.py` |
683− 
684−### Built-in skins
685− 
686−- `default` — Classic Hermes gold/kawaii (the current look)
687−- `ares` — Crimson/bronze war-god theme with custom spinner wings
688−- `mono` — Clean grayscale monochrome
689−- `slate` — Cool blue developer-focused theme
690− 
691−### Adding a built-in skin
692− 
693−Add to `_BUILTIN_SKINS` dict in `hermes_cli/skin_engine.py`:
694− 
695−```python
696−"mytheme": {
697− "name": "mytheme",
698− "description": "Short description",
699− "colors": { ... },
700− "spinner": { ... },
701− "branding": { ... },
702− "tool_prefix": "┊",
703−},
704−```
705− 
706−### User skins (YAML)
707− 
708−Users create `~/.hermes/skins/<name>.yaml`:
709− 
710−```yaml
711−name: cyberpunk
712−description: Neon-soaked terminal theme
713− 
714−colors:
715− banner_border: "#FF00FF"
716− banner_title: "#00FFFF"
717− banner_accent: "#FF1493"
718− 
719−spinner:
720− thinking_verbs: ["jacking in", "decrypting", "uploading"]
721− wings:
722− - ["⟨⚡", "⚡⟩"]
723− 
724−branding:
725− agent_name: "Cyber Agent"
726− response_label: " ⚡ Cyber "
727− 
728−tool_prefix: "▏"
729−```
730− 
731−Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
732− 
733−---
734− 
735−## Plugins
736− 
737−Hermes has two plugin surfaces. Both live under `plugins/` in the repo so
738−repo-shipped plugins can be discovered alongside user-installed ones in
739−`~/.hermes/plugins/` and pip-installed entry points.
740− 
741−### General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
742− 
743−`PluginManager` discovers plugins from `~/.hermes/plugins/`, `./.hermes/plugins/`,
744−and pip entry points. Each plugin exposes a `register(ctx)` function that
745−can:
746− 
747−- Register Python-callback lifecycle hooks:
748− `pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`,
749− `on_session_start`, `on_session_end`
750−- Register new tools via `ctx.register_tool(...)`
751−- Register CLI subcommands via `ctx.register_cli_command(...)` — the
752− plugin's argparse tree is wired into `hermes` at startup so
753− `hermes <pluginname> <subcmd>` works with no change to `main.py`
754− 
755−Hooks are invoked from `model_tools.py` (pre/post tool) and `run_agent.py`
756−(lifecycle). **Discovery timing pitfall:** `discover_plugins()` only runs
757−as a side effect of importing `model_tools.py`. Code paths that read plugin
758−state without importing `model_tools.py` first must call `discover_plugins()`
759−explicitly (it's idempotent).
760− 
761−### Memory-provider plugins (`plugins/memory/<name>/`)
762− 
763−Separate discovery system for pluggable memory backends. Current built-in
764−providers include **honcho, mem0, supermemory, byterover, hindsight,
765−holographic, openviking, retaindb**.
766− 
767−Each provider implements the `MemoryProvider` ABC (see `agent/memory_provider.py`)
768−and is orchestrated by `agent/memory_manager.py`. Lifecycle hooks include
769−`sync_turn(turn_messages)`, `prefetch(query)`, `shutdown()`, and optional
770−`post_setup(hermes_home, config)` for setup-wizard integration.
771− 
772−**CLI commands via `plugins/memory/<name>/cli.py`:** if a memory plugin
773−defines `register_cli(subparser)`, `discover_plugin_cli_commands()` finds
774−it at argparse setup time and wires it into `hermes <plugin>`. The
775−framework only exposes CLI commands for the **currently active** memory
776−provider (read from `memory.provider` in config.yaml), so disabled
777−providers don't clutter `hermes --help`.
778− 
779−**Rule (Teknium, May 2026):** plugins MUST NOT modify core files
780−(`run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.).
781−If a plugin needs a capability the framework doesn't expose, expand the
782−generic plugin surface (new hook, new ctx method) — never hardcode
783−plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
784−honcho argparse from `main.py` for exactly this reason.
785− 
786−**No new in-tree memory providers (policy, May 2026):** the set of
787−built-in memory providers under `plugins/memory/` is closed. New memory
788−backends must ship as **standalone plugin repos** that users install
789−into `~/.hermes/plugins/` (or via pip entry points) — they implement
790−the same `MemoryProvider` ABC, register through the same discovery
791−path, and integrate via `hermes memory setup` / `post_setup()` without
792−landing in this tree. PRs that add a new directory under
793−`plugins/memory/` will be closed with a pointer to publish the
794−provider as its own repo. Existing in-tree providers stay; bug fixes
795−to them are welcome.
796− 
797−**No new third-party-product plugins in-tree (policy, June 2026):** the
798−same rule applies beyond memory providers. Plugins that integrate
799−someone else's product or project — observability/metrics backends,
800−vendor SaaS connectors, analytics dashboards, paid-service tie-ins —
801−must ship as **standalone plugin repos** that users install into
802−`~/.hermes/plugins/` (or via pip entry points). They register through
803−the existing plugin discovery path and use the ABCs/hooks/ctx surface
804−we expose; nothing special is needed in core. The reason is
805−maintenance load: every product we absorb into the tree becomes our
806−burden to keep working against a fast-moving core, for a backend we
807−don't own. Promote standalone plugins in the Nous Research Discord
808−(`#plugins-skills-and-skins`). PRs that add such a directory under
809−`plugins/` are closed with a pointer to publish it as its own repo —
810−this is a coupling decision, not a quality judgment. (The
811−`observability/`, `kanban/`, `disk-cleanup/`, etc. directories already
812−in the tree are existing precedent, not an invitation to add more
813−third-party-product plugins alongside them.)
814− 
815−### Model-provider plugins (`plugins/model-providers/<name>/`)
816− 
817−Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …)
818−ships as a plugin here. Each plugin's `__init__.py` calls
819−`providers.register_provider(ProviderProfile(...))` at module load.
820−`providers/__init__.py._discover_providers()` is a **lazy, separate
821−discovery system** — scanned on first `get_provider_profile()` or
822−`list_providers()` call, NOT by the general PluginManager.
823− 
824−Scan order:
825−1. Bundled: `<repo>/plugins/model-providers/<name>/`
826−2. User: `$HERMES_HOME/plugins/model-providers/<name>/`
827−3. Legacy: `<repo>/providers/<name>.py` (back-compat)
828− 
829−User plugins of the same name override bundled ones — `register_provider()`
830−is last-writer-wins. This lets third parties swap out any built-in
831−profile without a repo patch.
832− 
833−The general PluginManager records `kind: model-provider` manifests but does
834−NOT import them (would double-instantiate `ProviderProfile`). Plugins
835−without an explicit `kind:` get auto-coerced via a source-text heuristic
836−(`register_provider` + `ProviderProfile` in `__init__.py`).
837− 
838−Full authoring guide: `website/docs/developer-guide/model-provider-plugin.md`.
839− 
840−### Dashboard / context-engine / image-gen plugin directories
841− 
842−`plugins/context_engine/`, `plugins/image_gen/`, etc. follow the same
843−pattern (ABC + orchestrator + per-plugin directory). Context engines
844−plug into `agent/context_engine.py`; image-gen providers into
845−`agent/image_gen_provider.py`. Reference / docs-companion plugins
846−(`example-dashboard`, `strike-freedom-cockpit`, `plugin-llm-example`,
847−`plugin-llm-async-example`) live in the
848−[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins)
849−companion repo, not in this tree.
850− 
851−---
852− 
853−## Skills
854− 
855−Two parallel surfaces:
856− 
857−- **`skills/`** — built-in skills shipped and loadable by default.
858− Organized by category directories (e.g. `skills/github/`, `skills/mlops/`).
859−- **`optional-skills/`** — heavier or niche skills shipped with the repo but
860− NOT active by default. Installed explicitly via
861− `hermes skills install official/<category>/<skill>`. Adapter lives in
862− `tools/skills_hub.py` (`OptionalSkillSource`). Categories include
863− `autonomous-ai-agents`, `blockchain`, `communication`, `creative`,
864− `devops`, `email`, `health`, `mcp`, `migration`, `mlops`, `productivity`,
865− `research`, `security`, `web-development`.
866− 
867−When reviewing skill PRs, check which directory they target — heavy-dep or
868−niche skills belong in `optional-skills/`.
869− 
870−### SKILL.md frontmatter
871− 
872−Standard fields: `name`, `description`, `version`, `author`, `license`,
873−`platforms` (OS-gating list: `[macos]`, `[linux, macos]`, ...),
874−`metadata.hermes.tags`, `metadata.hermes.category`,
875−`metadata.hermes.related_skills`, `metadata.hermes.config` (config.yaml
876−settings the skill needs — stored under `skills.config.<key>`, prompted
877−during setup, injected at load time).
878− 
879−Top-level `tags:` and `category:` are also accepted and mirrored from
880−`metadata.hermes.*` by the loader.
881− 
882−### Skill authoring standards (HARDLINE)
883− 
884−Every new or modernized skill — bundled, optional, or contributed —
885−must meet these standards before merge. Reviewers reject PRs that
886−violate them.
887− 
888−1. **`description` ≤ 60 characters, one sentence, ends with a period.**
889− Long descriptions bloat skill listings and dilute the model's
890− attention when many skills are loaded. State the capability, not
891− the implementation. No marketing words ("powerful",
892− "comprehensive", "seamless", "advanced"). Don't repeat the skill
893− name. Verify with:
894− ```python
895− import re, pathlib
896− m = re.search(r'^description: (.*)$',
897− pathlib.Path('skills/<cat>/<name>/SKILL.md').read_text(),
898− re.MULTILINE)
899− assert len(m.group(1)) <= 60, len(m.group(1))
900− ```
901− 
902−2. **Tools referenced in SKILL.md prose must be native Hermes tools or
903− MCP servers the skill explicitly expects.** When the skill needs a
904− capability, point at the proper tool by name in backticks
905− (`` `terminal` ``, `` `web_extract` ``, `` `read_file` ``,
906− `` `patch` ``, `` `search_files` ``, `` `vision_analyze` ``,
907− `` `browser_navigate` ``, `` `delegate_task` ``, etc.). Do NOT
908− name shell utilities the agent already has wrapped — `grep` →
909− `search_files`, `cat`/`head`/`tail` → `read_file`, `sed`/`awk` →
910− `patch`, `find`/`ls` → `search_files target='files'`. If the skill
911− depends on an MCP server, name the MCP server and document the
912− expected setup in `## Prerequisites`. Anything else (third-party
913− CLIs, shell pipelines, etc.) is fair game inside script files but
914− should not be the headline interaction surface in the prose.
915− 
916−3. **`platforms:` gating audited against actual script imports.**
917− Skills that use POSIX-only primitives (`fcntl`, `termios`,
918− `os.setsid`, `os.kill(pid, 0)` for liveness, `/proc`, `/tmp`
919− hardcoded, `signal.SIGKILL`, bash heredocs, `osascript`, `apt`,
920− `systemctl`) must declare their supported platforms. Default
921− posture: try to fix it cross-platform first — `tempfile.gettempdir`,
922− `pathlib.Path`, `psutil.pid_exists`, Python-level filtering instead
923− of `grep`. Gate to a narrower set only when the dependency is
924− genuinely platform-bound.
925− 
926−4. **`author` credits the human contributor first.** For external
927− contributions, the contributor's real name + GitHub handle goes
928− first; "Hermes Agent" is the secondary collaborator. If the
929− contributor's commit shows "Hermes Agent" as author (because they
930− used Hermes to draft the skill), replace it with their actual name
931− — credit the human, not the tool.
932− 
933−5. **SKILL.md body uses the modern section order.** `# <Skill> Skill`
934− title, 2-3 sentence intro stating what it does and doesn't do,
935− `## When to Use`, `## Prerequisites`, `## How to Run`,
936− `## Quick Reference`, `## Procedure`, `## Pitfalls`,
937− `## Verification`. Target ~200 lines for a complex skill,
938− ~100 lines for a simple one. Cut redundant intro fluff, marketing
939− prose, and re-explanations of env vars already in
940− `## Prerequisites`.
941− 
942−6. **Scripts go in `scripts/`, references in `references/`,
943− templates in `templates/`.** Don't expect the model to inline-write
944− parsers, XML walkers, or non-trivial logic every call — ship a
945− helper script. Reference it from SKILL.md by path relative to the
946− skill directory.
947− 
948−7. **Tests live at `tests/skills/test_<skill>_skill.py`** and use only
949− stdlib + pytest + `unittest.mock`. No live network calls. Run via
950− `scripts/run_tests.sh tests/skills/test_<skill>_skill.py -q`.
951− 
952−8. **`.env.example` additions are isolated to a clearly delimited
953− block.** Don't touch the surrounding file — contributor-supplied
954− `.env.example` versions are usually stale and edits outside the
955− skill's own block must be dropped during salvage.
956− 
957−The full salvage / modernization checklist for external skill PRs
958−lives in the `hermes-agent-dev` skill at
959−`references/new-skill-pr-salvage.md` — load it before polishing
960−contributor skill PRs.
961− 
962−---
963− 
964−## Toolsets
965− 
966−All toolsets are defined in `toolsets.py` as a single `TOOLSETS` dict.
967−Each platform's adapter picks a base toolset (e.g. Telegram uses
968−`"messaging"`); `_HERMES_CORE_TOOLS` is the default bundle most
969−platforms inherit from.
970− 
971−Current toolset keys: `browser`, `clarify`, `code_execution`, `cronjob`,
972−`debugging`, `delegation`, `discord`, `discord_admin`, `feishu_doc`,
973−`feishu_drive`, `file`, `homeassistant`, `image_gen`, `kanban`, `memory`,
974−`messaging`, `moa`, `rl`, `safe`, `search`, `session_search`, `skills`,
975−`spotify`, `terminal`, `todo`, `tts`, `video`, `vision`, `web`, `yuanbao`.
976− 
977−Enable/disable per platform via `hermes tools` (the curses UI) or the
978−`tools.<platform>.enabled` / `tools.<platform>.disabled` lists in
979−`config.yaml`.
980− 
981−---
982− 
983−## Delegation (`delegate_task`)
984− 
985−`tools/delegate_tool.py` spawns a subagent with an isolated
986−context + terminal session. By default the parent waits for the
987−child's summary before continuing its own loop. With `background=true`,
988−Hermes returns a delegation id immediately and the result re-enters the
989−conversation later through the async-delegation completion queue.
990− 
991−Two shapes:
992− 
993−- **Single:** pass `goal` (+ optional `context`, `toolsets`).
994−- **Batch (parallel):** pass `tasks: [...]` — each gets its own subagent
995− running concurrently. Concurrency is capped by
996− `delegation.max_concurrent_children` (default 3).
997− 
998−Roles:
999− 
1000−- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
1001− `clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code`
1002− (programmatic tool calling).
1003−- `role="orchestrator"` — retains `delegate_task` so it can spawn its
1004− own workers. Gated by `delegation.orchestrator_enabled` (default true)
1005− and bounded by `delegation.max_spawn_depth` (default 2).
1006− 
1007−Key config knobs (under `delegation:` in `config.yaml`):
1008−`max_concurrent_children`, `max_spawn_depth`, `child_timeout_seconds`,
1009−`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
1010−`max_iterations`.
1011− 
1012−Durability rule: background `delegate_task` is detached from the current
1013−turn but still process-local. For work that must survive process restart, use
1014−`cronjob` or `terminal(background=True, notify_on_complete=True)` instead.
1015− 
1016−---
1017− 
1018−## Curator (skill lifecycle)
1019− 
1020−Background skill-maintenance system that tracks usage on agent-created
1021−skills and auto-archives stale ones. Users never lose skills; archives
1022−go to `~/.hermes/skills/.archive/` and are restorable.
1023− 
1024−- **Core:** `agent/curator.py` (review loop, auto-transitions, LLM review
1025− prompt) + `agent/curator_backup.py` (pre-run tar.gz snapshots).
1026−- **CLI:** `hermes_cli/curator.py` wires `hermes curator <verb>` where
1027− verbs are: `status`, `run`, `pause`, `resume`, `pin`, `unpin`,
1028− `archive`, `restore`, `prune`, `backup`, `rollback`.
1029−- **Telemetry:** `tools/skill_usage.py` owns the sidecar
1030− `~/.hermes/skills/.usage.json` — per-skill `use_count`, `view_count`,
1031− `patch_count`, `last_activity_at`, `state` (active / stale /
1032− archived), `pinned`.
1033− 
1034−Invariants:
1035−- Curator only touches skills with `created_by: "agent"` provenance —
1036− bundled + hub-installed skills are off-limits.
1037−- Never deletes; max destructive action is archive.
1038−- Pinned skills are exempt from every auto-transition and from the
1039− LLM review pass.
1040−- `skill_manage(action="delete")` refuses pinned skills; patch/edit/
1041− write_file/remove_file go through so the agent can keep improving
1042− pinned skills.
1043− 
1044−Config section (`curator:` in `config.yaml`):
1045−`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
1046−`archive_after_days`, `backup.*`.
1047− 
1048−Full user-facing docs: `website/docs/user-guide/features/curator.md`.
1049− 
1050−---
1051− 
1052−## Cron (scheduled jobs)
1053− 
1054−`cron/jobs.py` (job store) + `cron/scheduler.py` (tick loop). Agents
1055−schedule jobs via the `cronjob` tool; users via `hermes cron <verb>`
1056−(`list`, `add`, `edit`, `pause`, `resume`, `run`, `remove`) or the
1057−`/cron` slash command.
1058− 
1059−Supported schedule formats:
1060−- Duration: `"30m"`, `"2h"`, `"1d"`
1061−- "every" phrase: `"every 2h"`, `"every monday 9am"`
1062−- 5-field cron expression: `"0 9 * * *"`
1063−- ISO timestamp (one-shot): `"2026-06-01T09:00:00Z"`
1064− 
1065−Per-job fields include `skills` (load specific skills), `model` /
1066−`provider` overrides, `script` (pre-run data-collection script whose
1067−stdout is injected into the prompt; `no_agent=True` turns the script
1068−into the entire job), `context_from` (chain job A's last output into
1069−job B's prompt), `workdir` (run in a specific directory with its
1070−`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery.
1071− 
1072−Hardening invariants:
1073−- **3-minute hard interrupt** on cron sessions — runaway agent loops
1074− cannot monopolize the scheduler.
1075−- Catchup window: half the job's period, clamped to 120s–2h.
1076−- Grace window: 120s for one-shot jobs whose fire time was missed.
1077−- File lock at `~/.hermes/cron/.tick.lock` prevents duplicate ticks
1078− across processes.
1079−- Cron sessions pass `skip_memory=True` by default; memory providers
1080− intentionally do not run during cron.
1081− 
1082−Cron deliveries are **not** mirrored into the target gateway session —
1083−they land in their own cron session with a header/footer frame so the
1084−main conversation's message-role alternation stays intact.
1085− 
1086−---
1087− 
1088−## Kanban (multi-agent work queue)
1089− 
1090−Durable SQLite-backed board that lets multiple profiles / workers
1091−collaborate on shared tasks. Users drive it via `hermes kanban <verb>`;
1092−workers spawned by the dispatcher drive it via a dedicated `kanban_*`
1093−toolset so their schema footprint is zero when they're not inside a
1094−kanban task.
1095− 
1096−- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
1097− `init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
1098− `unlink`, `comment`, `attach`, `attachments`, `attach-rm`, `complete`,
1099− `block`, `unblock`, `archive`, `tail`, plus less-commonly-used `watch`,
1100− `stats`, `runs`, `log`, `assignees`, `heartbeat`, `notify-*`,
1101− `dispatch`, `daemon`, `gc`.
1102−- **Worker/orchestrator toolset:** `tools/kanban_tools.py` exposes
1103− `kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`,
1104− `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_attach`,
1105− `kanban_attach_url`, `kanban_attachments`; profiles that explicitly
1106− enable the `kanban` toolset outside a dispatcher-spawned task also get
1107− `kanban_list` and `kanban_unblock` for board routing.
1108−- **Dispatcher:** long-lived loop that (default every 60s) reclaims
1109− stale claims, promotes ready tasks, atomically claims, and spawns
1110− assigned profiles. Runs **inside the gateway** by default via
1111− `kanban.dispatch_in_gateway: true`.
1112−- **Plugin assets:** `plugins/kanban/dashboard/` (web UI) +
1113− `plugins/kanban/systemd/` (`hermes-kanban-dispatcher.service` for
1114− standalone dispatcher deployment).
1115− 
1116−Isolation model:
1117−- **Board** is the hard boundary — workers are spawned with
1118− `HERMES_KANBAN_BOARD` pinned in their env so they can't see other
1119− boards.
1120−- **Tenant** is a soft namespace *within* a board — one specialist
1121− fleet can serve multiple businesses with workspace-path + memory-key
1122− isolation.
1123−- After `kanban.failure_limit` consecutive non-success attempts on the
1124− same task (default: 2), the dispatcher auto-blocks it to prevent spin
1125− loops.
1126− 
1127−Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
1128− 
1129−---
1130− 
1131−## Important Policies
1132− 
1133−### Prompt Caching Must Not Break
1134− 
1135−Hermes-Agent ensures caching remains valid throughout a conversation. **Do NOT implement changes that would:**
1136−- Alter past context mid-conversation
1137−- Change toolsets mid-conversation
1138−- Reload memories or rebuild system prompts mid-conversation
1139− 
1140−Cache-breaking forces dramatically higher costs. The ONLY time we alter context is during context compression.
1141− 
1142−Slash commands that mutate system-prompt state (skills, tools, memory, etc.)
1143−must be **cache-aware**: default to deferred invalidation (change takes
1144−effect next session), with an opt-in `--now` flag for immediate
1145−invalidation. See `/skills install --now` for the canonical pattern.
1146− 
1147−### Background Process Notifications (Gateway)
1148− 
1149−When `terminal(background=true, notify_on_complete=true)` is used, the gateway runs a watcher that
1150−detects process completion and triggers a new agent turn. Control verbosity of background process
1151−messages with `display.background_process_notifications`
1152−in config.yaml (or `HERMES_BACKGROUND_NOTIFICATIONS` env var):
1153− 
1154−- `all` — running-output updates + final message (default)
1155−- `result` — only the final completion message
1156−- `error` — only the final message when exit code != 0
1157−- `off` — no watcher messages at all
1158− 
1159−---
1160− 
1161−## Profiles: Multi-Instance Support
1162− 
1163−Hermes supports **profiles** — multiple fully isolated instances, each with its own
1164−`HERMES_HOME` directory (config, API keys, memory, sessions, skills, gateway, etc.).
1165− 
1166−The core mechanism: `_apply_profile_override()` in `hermes_cli/main.py` sets
1167−`HERMES_HOME` before any module imports. All `get_hermes_home()` references
1168−automatically scope to the active profile.
1169− 
1170−### Rules for profile-safe code
1171− 
1172−1. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`.
1173− NEVER hardcode `~/.hermes` or `Path.home() / ".hermes"` in code that reads/writes state.
1174− ```python
1175− # GOOD
1176− from hermes_constants import get_hermes_home
1177− config_path = get_hermes_home() / "config.yaml"
1178− 
1179− # BAD — breaks profiles
1180− config_path = Path.home() / ".hermes" / "config.yaml"
1181− ```
1182− 
1183−2. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`.
1184− This returns `~/.hermes` for default or `~/.hermes/profiles/<name>` for profiles.
1185− ```python
1186− # GOOD
1187− from hermes_constants import display_hermes_home
1188− print(f"Config saved to {display_hermes_home()}/config.yaml")
1189− 
1190− # BAD — shows wrong path for profiles
1191− print("Config saved to ~/.hermes/config.yaml")
1192− ```
1193− 
1194−3. **Module-level constants are fine** — they cache `get_hermes_home()` at import time,
1195− which is AFTER `_apply_profile_override()` sets the env var. Just use `get_hermes_home()`,
1196− not `Path.home() / ".hermes"`.
1197− 
1198−4. **Tests that mock `Path.home()` must also set `HERMES_HOME`** — since code now uses
1199− `get_hermes_home()` (reads env var), not `Path.home() / ".hermes"`:
1200− ```python
1201− with patch.object(Path, "home", return_value=tmp_path), \
1202− patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
1203− ...
1204− ```
1205− 
1206−5. **Gateway platform adapters should use token locks** — if the adapter connects with
1207− a unique credential (bot token, API key), call `acquire_scoped_lock()` from
1208− `gateway.status` in the `connect()`/`start()` method and `release_scoped_lock()` in
1209− `disconnect()`/`stop()`. This prevents two profiles from using the same credential.
1210− See `plugins/platforms/irc/adapter.py` for the canonical pattern.
1211− 
1212−6. **Profile operations are HOME-anchored, not HERMES_HOME-anchored** — `_get_profiles_root()`
1213− returns `Path.home() / ".hermes" / "profiles"`, NOT `get_hermes_home() / "profiles"`.
1214− This is intentional — it lets `hermes -p coder profile list` see all profiles regardless
1215− of which one is active.
1216− 
1217−## Known Pitfalls
1218− 
1219−### DO NOT hardcode `~/.hermes` paths
1220−Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_hermes_home()`
1221−for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile
1222−has its own `HERMES_HOME` directory. This was the source of 5 bugs fixed in PR #3575.
1223− 
1224−### DO NOT introduce new `simple_term_menu` usage
1225−Existing call sites in `hermes_cli/main.py` remain for legacy fallback only;
1226−the preferred UI is curses (stdlib) because `simple_term_menu` has
1227−ghost-duplication rendering bugs in tmux/iTerm2 with arrow keys. New
1228−interactive menus must use `hermes_cli/curses_ui.py` — see
1229−`hermes_cli/tools_config.py` for the canonical pattern.
1230− 
1231−### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code
1232−Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`.
1233− 
1234−### `_last_resolved_tool_names` is a process-global in `model_tools.py`
1235−`_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.
1236− 
1237−### DO NOT hardcode cross-tool references in schema descriptions
1238−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.
1239− 
1240−### The gateway has TWO message guards — both must bypass approval/control commands
1241−When an agent is running, messages pass through two sequential guards:
1242−(1) **base adapter** (`gateway/platforms/base.py`) queues messages in
1243−`_pending_messages` when `session_key in self._active_sessions`, and
1244−(2) **gateway runner** (`gateway/run.py`) intercepts `/stop`, `/new`,
1245−`/queue`, `/status`, `/approve`, `/deny` before they reach
1246−`running_agent.interrupt()`. Any new command that must reach the runner
1247−while the agent is blocked (e.g. approval prompts) MUST bypass BOTH
1248−guards and be dispatched inline, not via `_process_message_background()`
1249−(which races session lifecycle).
1250− 
1251−### Squash merges from stale branches silently revert recent fixes
1252−Before squash-merging a PR, ensure the branch is up to date with `main`
1253−(`git fetch origin main && git reset --hard origin/main` in the worktree,
1254−then re-apply the PR's commits). A stale branch's version of an unrelated
1255−file will silently overwrite recent fixes on main when squashed. Verify
1256−with `git diff HEAD~1..HEAD` after merging — unexpected deletions are a
1257−red flag.
1258− 
1259−### Don't wire in dead code without E2E validation
1260−Unused code that was never shipped was dead for a reason. Before wiring an
1261−unused module into a live code path, E2E test the real resolution chain
1262−with actual imports (not mocks) against a temp `HERMES_HOME`.
1263− 
1264−### Tests must not write to `~/.hermes/`
1265−The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests.
1266− 
1267−**Profile tests**: When testing profile features, also mock `Path.home()` so that
1268−`_get_profiles_root()` and `_get_default_hermes_home()` resolve within the temp dir.
1269−Use the pattern from `tests/hermes_cli/test_profiles.py`:
1270−```python
1271−@pytest.fixture
1272−def profile_env(tmp_path, monkeypatch):
1273− home = tmp_path / ".hermes"
1274− home.mkdir()
1275− monkeypatch.setattr(Path, "home", lambda: tmp_path)
1276− monkeypatch.setenv("HERMES_HOME", str(home))
1277− return home
1278−```
1279− 
1280−---
1281− 
1282−## Testing
1283− 
1284−### Python
1285−**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
1286−hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
1287−per-file subprocess isolation via `scripts/run_tests_parallel.py` — no xdist,
1288−worker count auto-scaled from CPU count). Direct `pytest`
1289−on a 16+ core developer machine with API keys set diverges from CI in ways
1290−that have caused multiple "works locally, fails in CI" incidents (and the reverse).
1291− 
1292−```bash
1293−scripts/run_tests.sh # full suite, CI-parity
1294−scripts/run_tests.sh tests/gateway/ # one directory
1295−scripts/run_tests.sh tests/agent/test_foo.py -k test_x # one test (file + -k; the runner is file-granular)
1296−scripts/run_tests.sh -v --tb=long # pass-through pytest flags
1297−```
1298− 
1299−**Flake policy:** the runner auto-retries a failing test FILE once in a fresh
1300−subprocess (`--file-retries`, default 1; `HERMES_TEST_FILE_RETRIES=0` to
1301−disable). Pass-on-retry counts as green but is printed in a `⚠ FLAKY` summary
1302−section with both attempts' output. A FLAKY report is a bug to fix, not noise
1303−to ignore — timing-sensitive tests must not assume a quiet runner (loose
1304−wall-clock bounds ≥ 2s, event-based sync, no `assert not _wait_until(...)`
1305−negative-timing races).
1306− 
1307−#### Subprocess-per-test-file isolation
1308− 
1309−Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
1310−ContextVars from one test file cannot leak into the next.
1311− 
1312−#### Why the wrapper
1313− 
1314−| | Without wrapper | With wrapper |
1315−| ------------------- | ------------------------------------------- | ----------------------------------------- |
1316−| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. |
1317−| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
1318−| Timezone | Local TZ (PDT etc.) | UTC |
1319−| Locale | Whatever is set | C.UTF-8 |
1320− 
1321−### Where to place what tests
1322− 
1323−The CI change classifier (`scripts/ci/classify_changes.py`) runs specific jobs based on what files changed. A Python test that asserts
1324−about the contents of `package.json`, `package-lock.json`, `.ts`/`.tsx`
1325−source, or any other JS-side artifact will not run on a PR that only touches
1326−those files. This means a regression can go green on a PR and red on `main` (where the
1327−classifier fails open and runs everything).
1328− 
1329−Any test that reads or asserts about `package.json`,
1330−`package-lock.json`, `tsconfig.json`, `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs`
1331−source files configuration belongs in the JS (vitest) test suite, not in `tests/*.py`.
1332− 
1333−### Don't write change-detector tests
1334− 
1335−A test is a **change-detector** if it fails whenever data that is **expected
1336−to change** gets updated — model catalogs, config version numbers,
1337−enumeration counts, hardcoded lists of provider models. These tests add no
1338−behavioral coverage; they just guarantee that routine source updates break
1339−CI and cost engineering time to "fix."
1340− 
1341−**Do not write:**
1342− 
1343−```python
1344−# catalog snapshot — breaks every model release
1345−assert "gemini-2.5-pro" in _PROVIDER_MODELS["gemini"]
1346−assert "MiniMax-M2.7" in models
1347− 
1348−# config version literal — breaks every schema bump
1349−assert DEFAULT_CONFIG["_config_version"] == 21
1350− 
1351−# enumeration count — breaks every time a skill/provider is added
1352−assert len(_PROVIDER_MODELS["huggingface"]) == 8
1353−```
1354− 
1355−**Do write:**
1356− 
1357−```python
1358−# behavior: does the catalog plumbing work at all?
1359−assert "gemini" in _PROVIDER_MODELS
1360−assert len(_PROVIDER_MODELS["gemini"]) >= 1
1361− 
1362−# behavior: does migration bump the user's version to current latest?
1363−assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
1364− 
1365−# invariant: no plan-only model leaks into the legacy list
1366−assert not (set(moonshot_models) & coding_plan_only_models)
1367− 
1368−# invariant: every model in the catalog has a context-length entry
1369−for m in _PROVIDER_MODELS["huggingface"]:
1370− assert m.lower() in DEFAULT_CONTEXT_LENGTHS_LOWER
1371−```
1372− 
1373−The rule: if the test reads like a snapshot of current data, delete it. If
1374−it reads like a contract about how two pieces of data must relate, keep it.
1375−When a PR adds a new provider/model and you want a test, make the test
1376−assert the relationship (e.g. "catalog entries all have context lengths"),
1377−not the specific names.
1378− 
1379−Reviewers should reject new change-detector tests; authors should convert
1380−them into invariants before re-requesting review.
1381− 
1382−### Never read source code in tests
1383− 
1384−A test that reads a source file's text is testing *the shape of the
1385−source code*, not its behavior. This is a hard antipattern, banned outright.
1386−Any test that reads a .py, .ts, .tsx, etc., file is suspect.
1387− 
1388−**Why it's actively harmful, not just weak:**
1389− 
1390−- It passes when the implementation is subtly broken (the regex matches a
1391− call site that exists but is wired wrong) and fails when a correct
1392− refactor changes formatting, variable names, or control flow with
1393− identical runtime behavior. Both directions of failure are wrong.
1394−- It can't be run against a built/bundled/minified artifact, so it silently
1395− stops testing anything the moment code moves, gets renamed, or a
1396− dependency reformats it.
1397−- It actively blocks refactors: reviewers see "keeps a pattern intact" tests
1398− fail during pure structural cleanup with no behavior change, and either
1399− hand-wave the failure (dangerous) or waste time updating regexes that add
1400− nothing (waste).
1401−- It gives false confidence. a green suite full of source-regex tests
1402− looks like coverage but has never once executed the code path it claims
1403− to guard.
1404− 
1405−**Do not write:**
1406− 
1407−```ts
1408−const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
1409− 
1410−test('backend spawn hides the Windows console', () => {
1411− assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
1412−})
1413−```
1414− 
1415−**Do write — extract the logic into a small pure/DI-testable function and
1416−call it for real:**
1417− 
1418−```ts
1419−// backend-spawn.ts
1420−export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
1421− if (!isWindows || 'windowsHide' in options) return options
1422− return { ...options, windowsHide: true }
1423−}
1424− 
1425−// backend-spawn.test.ts
1426−test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
1427− assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
1428− assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
1429− assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
1430−})
1431−```
1432− 
1433−If the logic lives inline in a god-file (`main.ts`, `cli.py`,
1434−`gateway/run.py`) and extracting it feels disruptive: that's the actual
1435−signal to do the extraction, not to regex around it.
202+If any answer is "not sure," that's the part to go verify.
1436203  
RuleStack

Built by

Kynth Studio

Directory

Configs
Stacks
Compare formats
Diff two configs
Best AGENTS.md examples

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

RuleStack

RuleStack

Built by

Kynth Studio

Directory

Configs
Stacks
Compare formats
Diff two configs
Best AGENTS.md examples

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

RuleStack

RuleStack

Built by

Kynth Studio

Directory

Configs
Stacks
Compare formats
Diff two configs
Best AGENTS.md examples

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

RuleStack