docs(agents): split AGENTS.md into root + per-area files (≤8k each, the subdirectory-hint cap)
Root AGENTS.md 100,797 → 29,295 chars: what applies everywhere (invariants, rubric, footprint ladder, layout + shape rules, commit/PR, testing) plus a routing table. Area rules move to agent/, hermes_cli/, gateway/, tools/, plugins/, tui_gateway/, web/, skills/, cron/, apps/desktop/src/ AGENTS.md (3–9k each; ceiling is now 32k after d61cff60e3, target ~8k). Long-form process-identity and skin key tables go to website/docs/developer-guide/cli-internals.md. Zero rule loss; map in /tmp/rf/agents_md_zero_loss.md. Stale Bot Mode test paths corrected to apps/desktop/src/plugins/hermes-bots/*.test.ts.
This commit is contained in:
+109
@@ -0,0 +1,109 @@
|
||||
# agent/ — AIAgent, turn loop, prompt, compression
|
||||
|
||||
Applies on top of the root `AGENTS.md` (prompt-caching invariant, facade + siblings rules).
|
||||
|
||||
## Shape
|
||||
|
||||
`run_agent.py` is the public facade: `AIAgent` is assembled from mixins (`agent/turn_facade.py`,
|
||||
`client_lifecycle.py`, `stream_delivery.py`, `session_persistence.py`, `compression_facade.py`, ...).
|
||||
Construction runs `agent/agent_init.py::init_agent`; a turn is
|
||||
`agent/conversation_loop.py::run_conversation`, which `AIAgent.run_conversation` forwards to after
|
||||
taking the session turn lease (`turn_facade_lease.py`). `AIAgent.__init__` takes ~60 parameters
|
||||
(credentials, routing, callbacks, session context, budget, credential pool, ...) — read
|
||||
`run_agent.py` for the list; the subset you usually touch: `base_url`, `api_key`, `provider`,
|
||||
`api_mode` (`"chat_completions" | "codex_responses" | ...`), `model` (empty → resolved from
|
||||
config/provider later), `max_iterations` (default 500, shared with subagents),
|
||||
`enabled_toolsets`/`disabled_toolsets`, `quiet_mode`, `save_trajectories`, `platform`
|
||||
(`"cli"`, `"telegram"`, ...), `session_id`, `skip_context_files`, `skip_memory`, `credential_pool`.
|
||||
`chat(message) -> str` is the simple interface; `run_conversation(user_message, system_message=None,
|
||||
conversation_history=None, task_id=None) -> dict` returns `final_response` + `messages`.
|
||||
|
||||
## Agent loop (`agent/conversation_loop.py` + `agent/turn_*.py`)
|
||||
|
||||
Entirely synchronous, with interrupt checks, budget tracking, and a one-turn grace call:
|
||||
|
||||
```python
|
||||
while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
|
||||
or self._budget_grace_call:
|
||||
if self._interrupt_requested: break
|
||||
response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
|
||||
if response.tool_calls:
|
||||
for tc in response.tool_calls:
|
||||
messages.append(tool_result_message(handle_function_call(tc.name, tc.args, task_id)))
|
||||
api_call_count += 1
|
||||
else:
|
||||
return response.content
|
||||
```
|
||||
|
||||
Each phase of an iteration is its own sibling, so a change to (say) overflow handling touches one
|
||||
~600-line file: `turn_preflight*`, `turn_iteration_prep`, `turn_request_assembly`/`turn_api_request`,
|
||||
`turn_api_call`, `turn_api_error`, `turn_response_intake`/`turn_response_check`,
|
||||
`turn_empty_response`, `turn_tool_round`/`turn_tool_validation`, `turn_overflow`,
|
||||
`turn_truncation`, `turn_context_compaction`, `turn_recovery`, `turn_retry_state`,
|
||||
`turn_stop_gates`, `turn_liveness`, `turn_usage`, `turn_final_response`, `turn_finalizer`,
|
||||
`turn_summary`. Find the phase with `grep -rn "def X" agent/turn_*.py`.
|
||||
|
||||
Messages use OpenAI format `{"role": "system|user|assistant|tool", ...}`; reasoning content is stored
|
||||
in `assistant_msg["reasoning"]`.
|
||||
|
||||
**Agent-level tools** (`todo`, `memory`, ...) are intercepted by `agent/tool_executor.py` through the
|
||||
`INLINE_TOOL_EXECUTORS` table in `agent/inline_tool_executors.py` before `handle_function_call()`.
|
||||
Adding one: register in that table (no `if name == ...` chain); `tools/todo_tool.py` is the pattern.
|
||||
|
||||
## Message-flow invariants (every change is reviewed against these)
|
||||
|
||||
- **Prompt caching must not break.** Never alter past context, change toolsets, reload memories,
|
||||
or rebuild the system prompt mid-conversation. The system prompt is byte-stable for the life of
|
||||
a conversation; the ONLY context mutation is compression. Anything that must inject content
|
||||
mid-conversation rides a **user message or tool result**, never the system prompt: skill slash
|
||||
commands (`agent/skill_commands.py`) inject as a user message; subdirectory `AGENTS.md` hints
|
||||
(`agent/subdirectory_hints.py`) append to the tool result (head+tail truncated past `_MAX_HINT_CHARS = 32_000`, with a warning).
|
||||
- **Strict role alternation.** Never two same-role messages in a row; never a synthetic user
|
||||
message injected mid-loop. Cron deliveries live in their own session for this reason.
|
||||
- **Context files** (`agent/prompt_builder.py`) load from the CWD only at startup and are capped
|
||||
(`CONTEXT_FILE_MAX_CHARS` / dynamic cap from the context window / `context_file_max_chars`).
|
||||
Never load an install-tree `AGENTS.md` as project context (PR #64611); subdirectory hints reject
|
||||
paths outside the working dir so `~/.codex/AGENTS.md` / `~/.claude/CLAUDE.md` never mix in.
|
||||
- **`_last_resolved_tool_names` is a process-global in `model_tools.py`.** `_run_single_child()`
|
||||
in `tools/delegate_tool.py` saves/restores it around subagent execution; code reading it may see
|
||||
a temporarily stale value during child runs.
|
||||
|
||||
## Compression (`agent/compression_facade.py`, `conversation_compression.py`, `turn_context_compaction.py`)
|
||||
|
||||
Two layers: gateway session hygiene (85% threshold) and the agent `ContextCompressor` (50%,
|
||||
configurable; per-model overrides; failure cooldown after provider-proven overflow). The algorithm
|
||||
prunes old tool results first (no LLM call), then picks boundaries, then generates a structured
|
||||
summary with the `auxiliary` compression model. In-place compaction keeps a single stable session
|
||||
id; native Responses/Codex compaction paths are provider-specific. Compression is the sanctioned
|
||||
cache break — keep it the only one. Full detail:
|
||||
`website/docs/developer-guide/context-compression-and-caching.md`.
|
||||
|
||||
## Model and provider resolution
|
||||
|
||||
- Runtime provider/model resolution and its precedence: `website/docs/developer-guide/provider-runtime.md`.
|
||||
Provider profiles are plugins (`plugins/model-providers/<name>/`, see `plugins/AGENTS.md`);
|
||||
`agent/model_metadata.py` holds context lengths and capabilities.
|
||||
- **Auxiliary (side-LLM) work** — curator, vision, embedding, title generation, session_search,
|
||||
compression — resolves through `agent/auxiliary_client.py::_resolve_auto_route`; each task can pin
|
||||
its own `provider/model/base_url/max_tokens/reasoning_effort` under `auxiliary:` in config.yaml.
|
||||
- Fallback models and credential pools are resolution-chain code: E2E them with real imports
|
||||
against a temp `HERMES_HOME`, not mocks (root rubric).
|
||||
|
||||
## Memory, context engines, curator
|
||||
|
||||
`agent/memory_provider.py` (ABC) + `agent/memory_manager.py` (orchestrator) drive memory-provider
|
||||
plugins; `agent/context_engine.py` drives context-engine plugins; `agent/image_gen_provider.py`
|
||||
image-gen plugins (all in `plugins/AGENTS.md`). `agent/curator.py` + `curator_backup.py` implement
|
||||
the skill curator (`skills/AGENTS.md`). Cron sessions pass `skip_memory=True` by default — memory
|
||||
providers intentionally do not run during cron.
|
||||
|
||||
## Tests
|
||||
|
||||
Loop/phase tests go in `tests/agent/`; patch the binding the phase actually reads (siblings often
|
||||
`from run_agent import X` inside the function — root "patch where production reads"). Assert
|
||||
message-shape invariants (alternation, byte-stable system prompt) rather than snapshotting prompt
|
||||
text.
|
||||
|
||||
Long-form: `website/docs/developer-guide/agent-loop.md`, `prompt-assembly.md`,
|
||||
`context-compression-and-caching.md`, `provider-runtime.md`, `session-storage.md`,
|
||||
`subagent-lifecycle-api.md`.
|
||||
@@ -3,7 +3,8 @@
|
||||
How to build Hermes Desktop well. This is a judgment guide, not an inventory —
|
||||
it teaches the invariants and the reasoning behind them so a change fits the app
|
||||
even as files move. Read it with the repository `AGENTS.md` (root rules still
|
||||
apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract.
|
||||
apply), [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract, and
|
||||
`src/AGENTS.md` for the backend contract, slash-palette curation, and Bot Mode.
|
||||
|
||||
When a rule here and the code disagree, trust the code and fix whichever is
|
||||
wrong — but never break an invariant to make a change easier.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# apps/desktop/src/ — backend contract, slash palette, Bot Mode
|
||||
|
||||
Applies on top of `apps/desktop/AGENTS.md` (the judgment guide) and the root `AGENTS.md`.
|
||||
Root TypeScript style rules apply.
|
||||
|
||||
## The desktop is its own chat surface on a `hermes serve` backend
|
||||
|
||||
Electron + React + nanostores (`@assistant-ui/react`) talking to a `tui_gateway` backend over
|
||||
JSON-RPC (`requestGateway(method, params)`); transport lives in the framework-agnostic `apps/shared`
|
||||
(`@hermes/shared`: `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard also consumes.
|
||||
The desktop has **no build/runtime dependency on the dashboard frontend**: it spawns a headless
|
||||
`hermes serve` (`headless_backend=True` → `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).
|
||||
`dashboard` and `serve` share `cmd_dashboard`/`start_server` but neither launches the other. It does
|
||||
NOT embed `hermes --tui` — own composer, transcript, slash pipeline.
|
||||
|
||||
**One backward-compat fallback:** `serve` is newer, so the spawn (`electron/backend-command.ts` +
|
||||
`backendSupportsServe()` in `electron/main.ts`) checks whether the resolved runtime registers `serve`
|
||||
and ONLY when it does not (older managed install / PATH `hermes` not yet updated) rewrites argv to
|
||||
legacy `dashboard --no-open`. Without it a new app against an un-upgraded runtime crashes on an
|
||||
unknown subcommand and bricks every mid-upgrade user. Keep it narrow and tested.
|
||||
|
||||
Lifecycle: `serve` dies with the app by design; the messaging gateway survives it (spawned detached
|
||||
via `/api/gateway/*`). Never re-parent the gateway under the backend — `gateway/AGENTS.md`.
|
||||
|
||||
## Slash commands: curated client-side, dispatched to the backend
|
||||
|
||||
- The backend already provides everything: `commands.catalog` and `complete.slash` include built-ins,
|
||||
user `quick_commands`, AND skill-derived commands. No new RPC is needed to see skills.
|
||||
- `src/lib/desktop-slash-commands.ts` is the load-bearing file: `DESKTOP_COMMAND_SPECS` (built-ins
|
||||
and their desktop surfaces) + `NO_DESKTOP_SURFACE` block-lists (terminal-only / messaging-only /
|
||||
picker-owned / settings-owned / advanced). `isDesktopSlashCommand(name)` gates **execution** (true
|
||||
for built-ins AND any non-built-in so typed skill/quick commands run);
|
||||
`isDesktopSlashSuggestion(name)` gates **discovery** — used by BOTH completion paths in
|
||||
`app/chat/composer/hooks/use-slash-completions.ts` and by `filterDesktopCommandsCatalog`;
|
||||
`isDesktopSlashExtensionCommand(name)` is true for anything not a known built-in, and both
|
||||
suggestion and catalog paths let extensions through (the allow-list once silently dropped every
|
||||
skill/quick command from completions even though they executed when typed).
|
||||
- Dispatch: `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`) — desktop-owned built-ins
|
||||
(`/skin`, `/help`, `/new`, ...) locally or via `commands.catalog`; everything else `slash.exec` →
|
||||
`command.dispatch` fallback; a skill command resolves to `{type: "skill", message}` and is
|
||||
submitted as a normal prompt.
|
||||
|
||||
**Rule:** palette curation hides noise (terminal-only / messaging-only built-ins), NEVER
|
||||
user-activated extensions. If you tighten `desktop-slash-commands.ts`, keep
|
||||
`isDesktopSlashExtensionCommand` flowing into both paths. Test: from `apps/desktop`,
|
||||
`npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace deps install at the repo root).
|
||||
|
||||
## Bot Mode (`src/plugins/hermes-bots/`) — one bot = ONE canonical forever-chat, identified by NAME
|
||||
|
||||
Each bot is a Hermes **profile** with a persistent identity. This invariant regressed repeatedly,
|
||||
cost users conversation history each time, and is not open for re-litigation in a routine PR.
|
||||
|
||||
The chat's only identity is **(profile, session titled exactly "Bot Chat")**; the state DB's
|
||||
UNIQUE(title) index makes that pair a registry of at most one row. Clicking a bot row:
|
||||
1. **Resolve the registry, every time:** `session.list {title, include_hidden: true}` (indexed,
|
||||
window-free; hidden rows resolve because canonical chats are always hidden; compression lineages
|
||||
resolve to the live tip). Row exists → open it. That is the whole happy path.
|
||||
2. **No row → create it**, titled `Bot Chat`, born hidden, kicked off with the bot's intro. Creation
|
||||
adopts-before-minting: it re-runs the lookup first so a concurrent/pre-existing row is opened,
|
||||
never forked (`set_session_title` silently drops conflicting titles — returns 0 rows — which is how
|
||||
the 2026-08 infinite fork loop started).
|
||||
|
||||
**There is NO session-id pin.** The old design stored a pointer in `ui_meta['hermes-bots'].chat`;
|
||||
five hardening waves (#88690, #90732, #90751, the #91791 revert, #92042) each guarded a new way it
|
||||
dangled or was stolen (rows[0] steals, `last_session` adoptions, transient clears, a pin re-anchored
|
||||
onto a cron session). A name cannot dangle; legacy `chat` keys in ui_meta are ignored and dropped.
|
||||
**Recency must never win** (#91791 → #92042): canonical Bot Chats are unconditionally hidden from the
|
||||
Sessions sidebar, so the bot row is the ONLY door — "newest visible session wins" walls the whole
|
||||
relationship off behind a row that previews one session and opens another. Side-chats ("New chat
|
||||
with this agent") are not plumbing-titled, stay visible in the sidebar, and are never the row's target.
|
||||
|
||||
Reviewer corollaries: no per-bot session browser (removed in #90732; don't add it back). Reject any
|
||||
stored session-id pointer as canonical identity — including "as a fallback tier" or "for
|
||||
verification". Reject anything consulting recency/visibility/"where the user left off" for the row's
|
||||
target; such reports are about side-chats and the fix belongs in the Sessions sidebar hide-sweep.
|
||||
The gateway reports the registry row as `canonical_session` on `profiles.list` (resolved server-side
|
||||
by title); roster preview, activity signals, and the `/new`→`/compact` guard all read it, so preview
|
||||
identity and click identity are the same row by construction. Contract tests:
|
||||
in `src/plugins/hermes-bots/`: `canonical-chat-registry.test.ts` (tripwire: the open path never
|
||||
reads/writes a stored pointer), `canonical-chat-creation.test.ts`, `canonical-chat-adopt-on-conflict.test.ts`,
|
||||
`bot-row-opens-canonical-chat.test.ts`, `hide-bot-chats.test.ts`; plus repo-root
|
||||
`tests/tui_gateway/test_profiles_list_canonical_session.py`.
|
||||
@@ -0,0 +1,61 @@
|
||||
# cron/ (+ kanban) — scheduled jobs and the multi-agent work queue
|
||||
|
||||
Applies on top of the root `AGENTS.md`. Long-form: `website/docs/developer-guide/cron-internals.md`;
|
||||
user docs `website/docs/user-guide/features/cron.md`, `kanban.md`.
|
||||
|
||||
## Cron
|
||||
|
||||
`cron/jobs.py` (job store) + `cron/scheduler.py` (tick loop; `scheduler_*.py` siblings). Agents
|
||||
schedule via the `cronjob` tool; users via `hermes cron list|add|edit|pause|resume|run|remove` or
|
||||
`/cron`. Schedules: duration (`"30m"`, `"2h"`, `"1d"`), "every" phrase (`"every 2h"`, `"every monday
|
||||
9am"`), 5-field cron (`"0 9 * * *"`), ISO one-shot (`"2026-06-01T09:00:00Z"`). Per-job fields:
|
||||
`skills`, `model`/`provider` overrides, `script` (pre-run data-collection script whose stdout is
|
||||
injected into the prompt; `no_agent=True` makes the script the whole job), `context_from` (chain job
|
||||
A's last output into job B's prompt), `workdir` (run with that directory's `AGENTS.md`/`CLAUDE.md`
|
||||
loaded), multi-platform delivery.
|
||||
|
||||
Hardening invariants — each guards a real failure; don't weaken without answering for it:
|
||||
- **3-minute hard interrupt** on cron sessions: runaway loops cannot monopolise the scheduler.
|
||||
- Catch-up window = half the period, clamped to 120s–2h; 120s grace for missed one-shots.
|
||||
- File lock `~/.hermes/cron/.tick.lock` prevents duplicate ticks across processes.
|
||||
- Cron sessions pass `skip_memory=True`; memory providers intentionally do not run during cron.
|
||||
- Deliveries are **not mirrored** into the target gateway session — they land in their own cron
|
||||
session with a header/footer frame so the main conversation's role alternation stays intact.
|
||||
- The cron ticker runs in the desktop-spawned backend when `HERMES_DESKTOP=1` — that env var means
|
||||
"spawned by the app", not "a GUI is watching" (root: capability is a property of the session).
|
||||
- Background `delegate_task` is process-local; work that must survive restarts is a cron job or a
|
||||
`terminal(background=True, notify_on_complete=True)` process.
|
||||
|
||||
## Kanban (multi-agent work queue)
|
||||
|
||||
Durable SQLite-backed board letting multiple profiles/workers collaborate. Users: `hermes kanban
|
||||
<verb>`; dispatcher-spawned workers use a dedicated `kanban_*` toolset so their schema footprint is
|
||||
zero outside a kanban task (footprint ladder rung 3).
|
||||
|
||||
- **CLI:** `hermes_cli/kanban.py` facade + 14 `kanban_*.py` siblings (`boards`, `db`, `db_connect`,
|
||||
`db_dispatch`, `db_notify`, `workspace`, ...). Verbs: `init, create, list (ls), show, assign, link,
|
||||
unlink, comment, attach, attachments, attach-rm, complete, request-review, request-changes,
|
||||
reopen-review, block, unblock, archive, tail`, plus `watch, stats, runs, log, assignees, heartbeat,
|
||||
notify-*, dispatch, daemon, gc`. Argparse alias dispatch must accept both `list` and `ls` (root).
|
||||
- **Toolset:** `tools/kanban_tools.py` — `kanban_show, kanban_complete, kanban_request_review,
|
||||
kanban_request_changes, kanban_block, kanban_heartbeat, kanban_comment, kanban_create, kanban_link,
|
||||
kanban_attach, kanban_attach_url, kanban_attachments`; profiles enabling `kanban` outside a
|
||||
dispatched task also get `kanban_list` and `kanban_unblock` for board routing.
|
||||
- **Dispatcher:** long-lived loop (default 60s) that reclaims stale claims, promotes ready tasks,
|
||||
atomically claims, and spawns assigned profiles. Runs **inside the gateway** by default
|
||||
(`kanban.dispatch_in_gateway: true`). Standalone: `plugins/kanban/systemd/hermes-kanban-dispatcher.service`.
|
||||
- **Plugin assets:** `plugins/kanban/dashboard/` (web UI) + systemd unit. `kanban_db.connect` is its
|
||||
own connection helper — do not alias it to `projects_db.connect` (a path-proximity generator did).
|
||||
|
||||
Isolation: **board** is the hard boundary — workers get `HERMES_KANBAN_BOARD` pinned in their env and
|
||||
cannot see other boards; **tenant** is a soft namespace within a board (workspace-path + memory-key
|
||||
isolation, one fleet serving several businesses). After `kanban.failure_limit` consecutive
|
||||
non-success attempts on a task (default 2) the dispatcher auto-blocks it to stop spin loops.
|
||||
Process-identity note: `kanban --preserve-cache` contains "serve" — never classify processes by argv
|
||||
substring (root).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/cron/`, `tests/hermes_cli/test_kanban*.py`, `tests/tools/test_kanban*.py`. Schedule parsing
|
||||
and catch-up windows are pure functions — test them as data. Never assert on the verb list or
|
||||
toolset size (root: no change-detectors). Time-based tests use loose bounds (≥ 2s) and event sync.
|
||||
@@ -0,0 +1,108 @@
|
||||
# gateway/ — messaging gateway, adapters, delivery
|
||||
|
||||
Applies on top of the root `AGENTS.md`. Long-form: `website/docs/developer-guide/gateway-internals.md`.
|
||||
New platform adapter: follow `gateway/platforms/ADDING_A_PLATFORM.md` step by step.
|
||||
|
||||
## Shape
|
||||
|
||||
`gateway/run.py` is the facade; phases live in `run_*.py` (startup, adapters, inbound, turn, busy,
|
||||
goals, notifications, shutdown, ...), sessions in `session*.py`, slash handlers in
|
||||
`slash_commands_*.py` mixins, authorization in `authz_mixin.py`, adapters in `platforms/<name>.py`
|
||||
over `platforms/base.py`. `builtin_hooks/` is the extension point for always-registered gateway
|
||||
hooks (none shipped). The gateway reads user YAML **raw** (`run.py` + `config.py`), not through
|
||||
`DEFAULT_CONFIG` — a key the CLI sees but the gateway doesn't means you're on the wrong loader
|
||||
(`hermes_cli/AGENTS.md`). Each adapter picks a base toolset (Telegram → `"messaging"`).
|
||||
|
||||
Slash commands: handlers are looked up by name through `_command_handler_table`; a command is
|
||||
listed in `_IDLE_COMMANDS` or `_PLAIN_COMMANDS` (works mid-run) in `run_busy.py`. No
|
||||
`if canonical == ...` chains. Registry + adding a command: `hermes_cli/AGENTS.md`.
|
||||
|
||||
## The gateway has TWO message guards — both must bypass approval/control commands
|
||||
|
||||
While an agent is running, an inbound message passes two sequential guards: (1) the **base
|
||||
adapter** (`platforms/base.py`) queues it in `_pending_messages` when `session_key in
|
||||
self._active_sessions`; (2) the **runner** (`run.py`/`run_busy.py`) intercepts `/stop`, `/new`,
|
||||
`/queue`, `/status`, `/approve`, `/deny` before they reach `running_agent.interrupt()`. Any new
|
||||
command that must reach the runner while the agent is blocked (approval prompts) MUST bypass BOTH
|
||||
guards and be dispatched inline — never via `_process_message_background()`, which races session
|
||||
lifecycle.
|
||||
|
||||
## Streaming delivery contract (stream-is-the-message adapters)
|
||||
|
||||
Adapters with `draft_stream_is_message = True` (relay Slack native streaming) keep ONE cumulative
|
||||
native stream per turn; the stream IS the final message. Four invariants, each from a live
|
||||
duplicate-final incident (NS-658 canary ledger, hermes#85796 / gateway-gateway#210); violating any
|
||||
re-creates a duplicate or frozen stream:
|
||||
|
||||
1. **Draft frames are prefix-stable.** Frame N must be a string prefix of frame N+1. Never mutate
|
||||
drafts per tick — no fence-closing (`ensure_closed_code_fences`), cursor suffix, segment-state
|
||||
resets at tool boundaries, or mrkdwn conversion. A non-prefix frame forces a whole-snapshot
|
||||
re-append ("stacked copies"). The finalize path may still transform the real final.
|
||||
2. **The consumer declares the final; the adapter never guesses.** `finish(final_text)` carries the
|
||||
completed `final_response` (verifier footer, completion explainer included). New post-stream
|
||||
augmentation MUST ride this payload — mutating `final_response` after the seal re-opens the
|
||||
`delivered_final_matches` mismatch → corrective duplicate send.
|
||||
3. **Interim sends carry `metadata["_interim_send"] = True`.** Any consumer-side `adapter.send()`
|
||||
that is not the turn-final (commentary, segment-tail flushes) must set it or seal-interception
|
||||
seals the live stream with interim text. Seal-interception exists at BOTH egress doors (`send()`
|
||||
and `send_for_platform()`); a new egress door needs the same two checks.
|
||||
4. **Reconcile by edit, never by plain send.** A lane delivering a final beside a sealed stream
|
||||
(queued follow-ups, media-accompanied finals) first tries `edit_message` on the consumer's
|
||||
`message_id`; plain `send()` only when no editable message exists. A sealed native stream is a
|
||||
regular message — `chat.update` works (live-verified).
|
||||
|
||||
Contract tests: `tests/gateway/test_stream_final_contract.py` (mutation-checked). Slack ground
|
||||
truth: `chat.*Stream` speaks STANDARD markdown, not mrkdwn; `stopStream.markdown_text` APPENDS;
|
||||
`startStream`/`stopStream` are Tier 2 (~20/min). Check `draft_stream_is_message is True` —
|
||||
MagicMock adapters in older tests auto-create truthy attributes.
|
||||
|
||||
## Background process notifications
|
||||
|
||||
`terminal(background=true, notify_on_complete=true)` starts a gateway watcher that detects
|
||||
completion and triggers a new agent turn. Verbosity: `display.background_process_notifications`
|
||||
(or `HERMES_BACKGROUND_NOTIFICATIONS`): `concise` (default; one line, failures append an output
|
||||
tail), `all` (running updates + final raw output), `result` (final raw output only), `error`
|
||||
(final raw output only on non-zero exit), `off`.
|
||||
|
||||
Cron deliveries are NOT mirrored into the target gateway session — they land in their own cron
|
||||
session with a header/footer frame so the main conversation's role alternation stays intact
|
||||
(`cron/AGENTS.md`).
|
||||
|
||||
## Gateway lifecycle vs. the Desktop app
|
||||
|
||||
`hermes serve` (control plane, desktop-spawned child) dies with the app — by design. The messaging
|
||||
gateway (`gateway run`) SURVIVES the app: the serve backend's `/api/gateway/*` endpoints spawn it
|
||||
detached (`_spawn_hermes_action` — `start_new_session` / `DETACHED_PROCESS`), so `before-quit`'s
|
||||
SIGTERM never reaches it and bots keep running. The known breach is the Windows shim-unlock
|
||||
teardown (`taskkill /T /F` on venv-shim holders, #85265), which exists to let updates proceed and is
|
||||
replaced by #92091's `pause-for-update`. Do NOT "fix" gateway-dies-with-app by re-parenting the
|
||||
gateway under the backend, and do NOT "fix" update locks by widening the tree-kill. Gateways stamp
|
||||
`code_sha`/`code_version` into `gateway_state.json` (`status.py`) so the updater can verify a fleet.
|
||||
|
||||
## Profiles and secrets in adapters
|
||||
|
||||
- **Token locks.** An adapter that connects with a unique credential (bot token, API key) calls
|
||||
`acquire_scoped_lock()` from `gateway.status` in `connect()`/`start()` and `release_scoped_lock()`
|
||||
in `disconnect()`/`stop()`, so two profiles cannot share one credential. Canonical:
|
||||
`plugins/platforms/irc/adapter.py`.
|
||||
- **Multiplex profile-scoped env reads MUST fail closed — never borrow from `os.environ`**
|
||||
(`agent/secret_scope.py`; #72348, #86905). Under `gateway.multiplex_profiles`, `os.environ` holds
|
||||
the DEFAULT profile's values; a secondary profile's `.env` exists only in its secret scope,
|
||||
installed per turn by `_profile_runtime_scope`. All profile-level env config — credentials
|
||||
(`app_secret`, tokens) AND authorization (`FEISHU_ALLOWED_USERS`, `{PLATFORM}_ALLOW_ALL_USERS`,
|
||||
`GATEWAY_ALLOW_ALL_USERS`, `group_policy`, `allow_bots`) — is read scope-aware: adapters via
|
||||
`_get_scoped_secret()` (canonical fail-closed copy: `plugins/platforms/feishu/adapter.py`),
|
||||
gateway authz via `_auth_env()` / `_platform_gate_env()` (`authz_mixin.py`). Scope installed +
|
||||
multiplex active → a scoped miss returns the **default**, NEVER `os.environ` (a leaked allowlist
|
||||
skips the allow-all check and silently rejects every secondary-profile sender, #86905). The
|
||||
unscoped default-profile path (`UnscopedSecretError`) and single-profile deployments keep the
|
||||
`os.environ` read — there it IS the profile's own value. `_get_scoped_secret` is copy-pasted
|
||||
across ~15 adapters: when touching one, verify fail-closed semantics and never reintroduce the
|
||||
`except _UnscopedSecretError: val = os.getenv(...)` fallback-after-miss shape.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/gateway/`. Adapter tests exercise the real delivery path with a fake transport; never
|
||||
assert on hardcoded platform lists or command counts (root: no change-detectors). Session-key and
|
||||
guard behaviour are invariants worth a test; platform API quirks belong in connector comments +
|
||||
tests, not in prose.
|
||||
@@ -0,0 +1,123 @@
|
||||
# hermes_cli/ + cli.py — CLI, slash commands, config, skins, updater, profiles
|
||||
|
||||
Applies on top of the root `AGENTS.md`. Long-form: `website/docs/developer-guide/cli-internals.md`.
|
||||
|
||||
## CLI architecture
|
||||
|
||||
`cli.py` holds `HermesCLI` (REPL loop, config, slash dispatch); behaviour lives in mixins
|
||||
`hermes_cli/cli_commands_mixin.py`, `cli_stream_mixin.py`, `cli_status_bar_mixin.py`,
|
||||
`cli_billing_mixin.py`, `cli_tui_mixin.py`, ... **Rich** renders banner/panels; **prompt_toolkit**
|
||||
handles input + autocomplete; `KawaiiSpinner` (`agent/display.py`) animates API calls and prints
|
||||
the `┊` activity feed. `load_cli_config()` in `cli.py` merges CLI defaults + user YAML.
|
||||
`process_command()` resolves the canonical name via `resolve_command()` then dispatches through
|
||||
`HermesCLI._SLASH_DISPATCH` (`canonical -> (method name, pass_arg)`), falling back to a
|
||||
`_handle_<name>_command` method by naming convention. **There is no `elif` ladder — do not add one.**
|
||||
Skill slash commands (`agent/skill_commands.py`) scan `~/.hermes/skills/` and inject as a **user
|
||||
message**, never into the system prompt (prompt caching).
|
||||
|
||||
Rules: all interactive menu-pickers use curses (`hermes_cli/curses_ui.py`; example
|
||||
`hermes_cli/tools_config.py`). Never emit `\033[K` (ANSI erase-to-EOL) in spinner/display code —
|
||||
it leaks as literal `?[K` under prompt_toolkit's `patch_stdout`; space-pad instead:
|
||||
`f"\r{line}{' ' * pad}"`. Wrapper CLIs extend via the protected hooks in `cli_tui_mixin.py`
|
||||
(`website/docs/developer-guide/extending-the-cli.md`), not by overriding `run()`.
|
||||
|
||||
## Slash command registry (`hermes_cli/commands.py`)
|
||||
|
||||
`COMMAND_REGISTRY` (list of `CommandDef`) is the single source; everything derives from it: CLI
|
||||
dispatch (`resolve_command()`), gateway `GATEWAY_KNOWN_COMMANDS` + dispatch, `gateway_help_lines()`,
|
||||
`telegram_bot_commands()` (BotCommand menu), `slack_subcommand_map()`, `COMMANDS` (autocomplete),
|
||||
`COMMANDS_BY_CATEGORY` (`show_help()`). Fields: `name` (no slash), `description`, `category`
|
||||
(`Session | Configuration | Tools & Skills | Info | Exit`), `aliases` tuple, `args_hint`,
|
||||
`cli_only`, `gateway_only`, `gateway_config_gate` (config dotpath; a `cli_only` command becomes
|
||||
gateway-available when truthy — `GATEWAY_KNOWN_COMMANDS` always includes gated commands so the
|
||||
gateway can dispatch them; help/menus show them only when the gate is open).
|
||||
|
||||
**Adding a command:** (1) `CommandDef("mycommand", "What it does", "Session", aliases=("mc",),
|
||||
args_hint="[arg]")` in `COMMAND_REGISTRY`; (2) `_handle_mycommand_command(self, cmd_original)` on the
|
||||
relevant `cli_*_mixin.py` (picked up by convention) or an explicit `_SLASH_DISPATCH` entry
|
||||
`"mycommand": ("_handle_mycommand", True)` when the method name/arg-passing differs; (3) for the
|
||||
gateway, `_handle_mycommand_command(self, event)` on the matching `gateway/slash_commands_*.py`
|
||||
mixin and list it in `_IDLE_COMMANDS` (or `_PLAIN_COMMANDS` if it must work mid-run) in
|
||||
`gateway/run_busy.py` — handlers resolve by name via `_command_handler_table`; (4) persistent
|
||||
settings via `save_config_value()` in `cli.py`. **Adding an alias** = add to `aliases`; every
|
||||
surface updates automatically. Commands that mutate system-prompt state default to deferred
|
||||
invalidation with `--now` opt-in (root invariant).
|
||||
|
||||
## Config system (`hermes_cli/config.py`)
|
||||
|
||||
- **config.yaml option:** add to `DEFAULT_CONFIG`. Bump `_config_version` ONLY to actively
|
||||
migrate/transform existing config (rename keys, restructure); new keys deep-merge automatically.
|
||||
Top-level sections (non-exhaustive): `model, agent, terminal, compression, display, stt, tts,
|
||||
memory, security, delegation, smart_model_routing, checkpoints, auxiliary, curator, skills,
|
||||
gateway, logging, cron, profiles, plugins, honcho`. `auxiliary` = per-task side-LLM overrides
|
||||
(`agent/AGENTS.md`); `curator` = `enabled, interval_hours, min_idle_hours, stale_after_days,
|
||||
archive_after_days, backup.*`.
|
||||
- **.env = SECRETS ONLY** (keys, tokens, passwords): add to `OPTIONAL_ENV_VARS` with
|
||||
`{"description", "prompt", "url", "password": True, "category": provider|tool|messaging|setting}`.
|
||||
Non-secret settings go in config.yaml; if internal code needs an env mirror, bridge it in code
|
||||
(`gateway_timeout`; `terminal.cwd` → `TERMINAL_CWD`). `MESSAGING_CWD` is removed and `TERMINAL_CWD`
|
||||
in `.env` is deprecated — the loader warns; canonical is `terminal.cwd`.
|
||||
- **Three loaders — know which you're in:** `load_cli_config()` (CLI, `cli.py`); `load_config()`
|
||||
(`hermes tools/setup`, most subcommands, `hermes_cli/config.py`, merges `DEFAULT_CONFIG`); raw
|
||||
YAML (gateway runtime, `gateway/run.py` + `gateway/config.py`). If the CLI sees a key and the
|
||||
gateway doesn't (or vice versa), you're on the wrong loader — check `DEFAULT_CONFIG` coverage.
|
||||
- **Working directory:** CLI uses `os.getcwd()`; messaging uses `terminal.cwd`, bridged to
|
||||
`TERMINAL_CWD` for child tools.
|
||||
|
||||
## Skin engine (`hermes_cli/skin_engine.py`)
|
||||
|
||||
Skins are **pure data** (`SkinConfig`); no code change to add one. `init_skin_from_config()` reads
|
||||
`display.skin` at startup; `get_active_skin()` (cached), `set_active_skin(name)` (`/skin`),
|
||||
`load_skin(name)` (user `~/.hermes/skins/*.yaml` → built-ins → default; missing values inherit
|
||||
from `default`). Built-ins in `_BUILTIN_SKINS`: `default`, `ares`, `mono`, `slate`. Keys: `colors.*`
|
||||
(banner border/title/accent/dim/text, response_border), `spinner.*` (waiting/thinking faces,
|
||||
thinking_verbs, wings), `tool_prefix`, `tool_emojis`, `branding.*` (agent_name, welcome,
|
||||
response_label, prompt_symbol). Consumers: `banner.py`, `display.py`, `cli.py`. Key-by-key table
|
||||
and YAML template: `website/docs/user-guide/features/skins.md`.
|
||||
|
||||
## Update pipeline (`hermes update`) — transactional; every stage guards a real field failure
|
||||
|
||||
Fleet-update campaign #91277 (Aug 2026). A PR that weakens a stage must answer for the failure class
|
||||
it guards. `plan → snapshot → apply → restart-per-kind → verify → report`
|
||||
|
||||
- **Plan** (`update_inventory.py`, `hermes update --plan`): read-only inventory — install kind, all
|
||||
profiles, every live gateway with supervisor + running code version. Deployment kinds are
|
||||
first-class: `git` updates in place; `docker`/`nix`/`apt` are NOT in-place-updatable and the
|
||||
updater reports the correct external command instead of fighting the deployment model.
|
||||
- **Snapshot** (`backup.py`): pre-update quick snapshot for EVERY profile (the code swap + fleet
|
||||
restart touch all of them), each into its own `state-snapshots/`, identical file set, 1 GiB
|
||||
per-file cap, keep=1. **Never add a partial/tiered snapshot set** — mixed coverage creates
|
||||
torn-restore states across schema generations. Quick snapshots are FILE-LOSS RECOVERY (the
|
||||
per-profile cron-jobs safety net restores from them), NOT code-rollback insurance; `--backup`
|
||||
full mode owns rollback.
|
||||
- **Apply**: git pull, or the Windows ZIP fallback — which fires ONLY when git itself failed
|
||||
(`_should_zip_fallback_on_update_error`, argv-classified; a dependency-install failure must never
|
||||
trigger a tree-clobbering re-download), REFUSES a dirty working tree (`-uall` + a pre-swap TOCTOU
|
||||
re-check), and grafts the live `apps/desktop/release/` into the staged swap (the GitHub source
|
||||
ZIP has no built desktop app; without the graft the swap deletes it).
|
||||
- **Restart-per-kind**: systemd and launchd restarts are FLEET-WIDE (every `hermes-gateway*` unit /
|
||||
`ai.hermes.gateway*` LaunchAgent), drain-first (SIGUSR1), with per-unit/per-label failure
|
||||
isolation. Restarting only the invoking profile's service leaves siblings on stale `sys.modules`
|
||||
until they crash — the largest dupe-PR cluster in the repo's history came from that bug.
|
||||
- **Verify**: gateways stamp `code_sha`/`code_version` into `gateway_state.json` on every
|
||||
runtime-status write (`gateway/status.py`); the updater compares each live gateway against the
|
||||
fresh checkout and prints a fleet version matrix. A provably-stale gateway fails the update
|
||||
(exit 1) — automation must never treat a mixed-version fleet as healthy.
|
||||
- **Report**: every run writes a machine-readable receipt to `~/.hermes/logs/update_receipts/`
|
||||
(`latest.json` pointer; steps, skips WITH reasons, restart outcome, plan, fleet snapshot).
|
||||
Finalization is owned by the `cmd_update` command boundary — early `sys.exit` paths (preflight
|
||||
refusals, fetch failures) still persist a receipt with the real exit code. A begun-but-unwritten
|
||||
receipt is a bug: refused/failed runs are the ones receipts exist for.
|
||||
|
||||
Process-scan coordination between updater, serve/dashboard, and gateway is being replaced by a
|
||||
gateway-owned control socket (#92091); scans are the fallback layer for old/crashed processes — read
|
||||
#92091 before adding any heuristic. Process identity rules (never argv substrings; canonical
|
||||
matchers; parser-derived flag sets; never blanket-exclude gateway ancestors, #87594): root
|
||||
`AGENTS.md` and `website/docs/developer-guide/cli-internals.md`.
|
||||
|
||||
## Profiles (multi-instance)
|
||||
|
||||
`_apply_profile_override()` in `hermes_cli/main.py` sets `HERMES_HOME` before any module import, so
|
||||
every `get_hermes_home()` scopes to the active profile (rules in root). Profiles are independent
|
||||
islands by design — no live config inheritance; `--clone` copies at creation. Multiplex
|
||||
(`gateway.multiplex_profiles`) secret-scope rules: `gateway/AGENTS.md`.
|
||||
@@ -0,0 +1,86 @@
|
||||
# plugins/ — plugin kinds, compat contract, in-tree policy
|
||||
|
||||
Applies on top of the root `AGENTS.md`. Authoring guide + canonical compat contract:
|
||||
`website/docs/developer-guide/plugins/index.md`. Per-kind guides: `memory-provider-plugin.md`,
|
||||
`model-provider-plugin.md`, `context-engine-plugin.md`, `image-gen-provider-plugin.md`, ...
|
||||
|
||||
## Plugins never touch core (Teknium, May 2026)
|
||||
|
||||
Plugins live in their own directory and work within the ABCs / hooks / `ctx` surface we provide.
|
||||
A plugin MUST NOT modify `run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.
|
||||
If it needs a capability the framework lacks, widen the **generic** plugin surface (new hook, new
|
||||
ctx method) and have the plugin use it — never hardcode plugin-specific logic into core (PR #5295
|
||||
removed 95 lines of hardcoded honcho argparse from `main.py`). Plugin setup goes through
|
||||
`hermes memory setup` → `provider.post_setup(hermes_home, config)`, never a parallel top-level
|
||||
command. A hook with no concrete consumer is speculative infrastructure and is rejected (root).
|
||||
|
||||
## What may live in this tree (policy)
|
||||
|
||||
- **No new in-tree memory providers (May 2026).** `plugins/memory/` is closed (honcho, mem0,
|
||||
supermemory, byterover, hindsight, holographic, openviking, retaindb stay; bug fixes welcome). New
|
||||
backends ship as standalone repos implementing the same `MemoryProvider` ABC, discovered through
|
||||
the same path, integrated via `hermes memory setup` / `post_setup()`.
|
||||
- **No new third-party-product plugins (June 2026).** Observability/metrics backends, vendor SaaS
|
||||
connectors, analytics dashboards, paid-service tie-ins ship as standalone plugin repos
|
||||
(`~/.hermes/plugins/` or pip entry point) promoted in Discord `#plugins-skills-and-skins`. Reason:
|
||||
every absorbed product is our maintenance burden against a fast-moving core for a backend we don't
|
||||
own. `observability/`, `kanban/`, `disk-cleanup/` are precedent, not an invitation. Closing such a
|
||||
PR is a coupling decision, not a quality judgment.
|
||||
- Reference/docs-companion plugins (`example-dashboard`, `strike-freedom-cockpit`,
|
||||
`plugin-llm-example`, `plugin-llm-async-example`) live in
|
||||
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins), not here.
|
||||
|
||||
## Plugin kinds and their discovery systems
|
||||
|
||||
| Kind | Where | Discovery | Notes |
|
||||
|---|---|---|---|
|
||||
| General | `plugins/<name>/`, `~/.hermes/plugins/`, `./.hermes/plugins/`, pip entry points | `PluginManager` (`hermes_cli/plugins.py`), later-wins | `register(ctx)` registers hooks (`pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`, `on_session_start`, `on_session_end`), tools (`ctx.register_tool`), CLI subcommands (`ctx.register_cli_command` — argparse tree wired into `hermes` at startup, no `main.py` change) |
|
||||
| Memory provider | `plugins/memory/<name>/` | `plugins/memory/__init__.py`: bundled → `$HERMES_HOME/plugins/` → `./.hermes/plugins/` (opt-in `HERMES_ENABLE_PROJECT_PLUGINS`) → `hermes_agent.memory_providers` entry points; **bundled-first** | Activated by name via `memory.provider`, so a dropped-in dir must not shadow a shipped one (reverse of general later-wins). Enumerates without importing. Implements `MemoryProvider` ABC (`agent/memory_provider.py`), orchestrated by `agent/memory_manager.py`: `sync_turn`, `prefetch`, `shutdown`, optional `post_setup`. `cli.py` with `register_cli(subparser)` is wired by `discover_plugin_cli_commands()` — only for the ACTIVE provider, so `hermes --help` stays clean |
|
||||
| Model provider | `plugins/model-providers/<name>/` | `providers/__init__.py._discover_providers()`, **lazy**, on first `get_provider_profile()`/`list_providers()`; bundled → `$HERMES_HOME/plugins/model-providers/` → legacy `providers/<name>.py` | `__init__.py` calls `providers.register_provider(ProviderProfile(...))` at load; **last-writer-wins** so a user plugin overrides a bundled profile. `PluginManager` records `kind: model-provider` manifests but does NOT import them (would double-instantiate); manifests without `kind:` are auto-coerced by source heuristic (`register_provider` + `ProviderProfile`) |
|
||||
| Context engine / image-gen / others | `plugins/context_engine/`, `plugins/image_gen/`, ... | ABC + orchestrator + per-plugin directory | Plug into `agent/context_engine.py`, `agent/image_gen_provider.py` |
|
||||
| Platform adapters | `plugins/platforms/<name>/adapter.py` | gateway | Token-lock and scoped-secret rules in `gateway/AGENTS.md` (`irc`, `feishu` are canonical) |
|
||||
|
||||
**Discovery timing pitfall:** `discover_plugins()` runs only as a side effect of importing
|
||||
`model_tools.py`. Code that reads plugin state without importing `model_tools.py` first must call
|
||||
`discover_plugins()` explicitly (idempotent). Hooks are invoked from `model_tools.py` (pre/post
|
||||
tool) and `run_agent.py` (lifecycle). When a plugin changes a default, add a migration guard keyed
|
||||
on an "existing config" signal (`_explicitly_configured`) so existing users keep the old default.
|
||||
|
||||
## Native plugin compatibility contract (summary — canonical text in the docs page)
|
||||
|
||||
Compatibility is a **behavior contract**, not a monolithic `PLUGIN_API_VERSION`, a manifest-wide
|
||||
native `api:` match, or version literals on unrelated payloads. Documented surfaces stay additive:
|
||||
|
||||
- Hook payload data is added as **keyword fields**; callbacks are signature-inspected so old narrow
|
||||
signatures receive only the fields they declare and `**kwargs` callbacks get the full payload.
|
||||
- Never remove or rename `PluginContext` methods; new parameters are optional with defaults and
|
||||
keyword-only where possible.
|
||||
- Unknown native manifest fields are ignored.
|
||||
- New provider methods get default implementations; optional callback kwargs are
|
||||
signature-inspected, not forwarded unconditionally.
|
||||
- A local schema version exists only for a capability with a wire or persisted contract, and old
|
||||
state/config/session replay is preserved or migrated.
|
||||
- Deprecations: once-per-process warning, documented replacement + migration note, ≥ 2 subsequent
|
||||
minor releases before removal.
|
||||
- Compat tests load **frozen plugins through the real discovery path** and assert outcomes — never
|
||||
exact registry/catalog counts, source-reading tests, or "a global version literal changed".
|
||||
|
||||
## Sep 2026 decomposition compat window (ends 2026-09-14)
|
||||
|
||||
PR #102117 moved internals into `<stem>_<topic>` siblings. Old import paths resolve through
|
||||
`PLUGIN-COMPAT` `__getattr__` blocks (listed in `COMPAT_MANIFEST.md` / `compat_manifest.json`)
|
||||
until `hermes_cli.plugin_compat.COMPAT_REMOVAL_DATE`, when the commit that added them is reverted.
|
||||
`hermes_cli/plugin_compat.py` is the single source: `scan_plugin` (AST scan), `compat_report`
|
||||
(hits across enabled external plugins, cached to `HERMES_HOME/.plugin-compat-report.json`,
|
||||
refreshed by discovery), `removal_in_effect`, `warn_once`. Surfaces: CLI banner notice,
|
||||
`hermes plugins compat` (shows affected user plugins), `hermes doctor`, post-update notices, the
|
||||
TUI/Desktop `plugins.compat_report` RPC. After the date `PluginManager` skips a hitting plugin
|
||||
unless `plugins.allow_deprecated_imports: true`. **In-tree code and tests never use compat paths**
|
||||
(`scripts/check_compat_pointers.py` in CI; `-W error::hermes_cli.plugin_compat.HermesPluginCompatWarning`).
|
||||
External-plugin compat is handled ONCE here — never add per-PR re-export shims.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/plugins/`. Load through real discovery with a temp `HERMES_HOME`; assert behaviour (tool
|
||||
registered, hook fired with expected kwargs), not counts. Opt-in telemetry rule applies to plugins
|
||||
too: no attribution tag ships by default (`tests/plugins/memory/test_hindsight_provider.py`).
|
||||
@@ -0,0 +1,75 @@
|
||||
# skills/ + optional-skills/ — bundled skills, authoring standards, curator
|
||||
|
||||
Applies on top of the root `AGENTS.md`. Long-form: `website/docs/developer-guide/creating-skills.md`;
|
||||
user docs: `website/docs/user-guide/features/skills.md`, `curator.md`.
|
||||
|
||||
## Two surfaces
|
||||
|
||||
- **`skills/`** — built-in, loadable by default, organised by category (`skills/github/`, `skills/mlops/`).
|
||||
- **`optional-skills/`** — heavier/niche skills shipped but NOT active; installed via
|
||||
`hermes skills install official/<category>/<skill>` (adapter `tools/skills_hub_official.py`
|
||||
`OptionalSkillSource`). Categories: `autonomous-ai-agents, blockchain, communication, creative,
|
||||
devops, email, health, mcp, migration, mlops, productivity, research, security, web-development`.
|
||||
|
||||
Reviewing a skill PR: check the target directory — heavy-dep or niche skills go to `optional-skills/`.
|
||||
|
||||
## SKILL.md frontmatter
|
||||
|
||||
`name`, `description`, `version`, `author`, `license`, `platforms` (OS gate: `[macos]`,
|
||||
`[linux, macos]`, ...), `metadata.hermes.tags`, `metadata.hermes.category`,
|
||||
`metadata.hermes.related_skills`, `metadata.hermes.config` (config.yaml settings the skill needs —
|
||||
stored under `skills.config.<key>`, prompted during setup, injected at load). Top-level `tags:` /
|
||||
`category:` are accepted and mirrored from `metadata.hermes.*` by the loader.
|
||||
|
||||
## Authoring standards (HARDLINE — enforced by `tests/skills/test_authoring_standards.py`)
|
||||
|
||||
Every new or modernised skill — bundled, optional, or contributed — meets all of these before merge:
|
||||
|
||||
1. **`description` ≤ 60 chars, one sentence, ends with a period.** Long descriptions bloat listings
|
||||
and dilute attention when many skills load. State the capability, not the implementation; no
|
||||
marketing words ("powerful", "comprehensive", "seamless", "advanced"); don't repeat the name.
|
||||
Check: `len(re.search(r'^description: (.*)$', text, re.M).group(1)) <= 60`.
|
||||
2. **Prose references native Hermes tools or the MCP servers the skill expects, in backticks**
|
||||
(`terminal`, `web_extract`, `read_file`, `patch`, `search_files`, `vision_analyze`,
|
||||
`browser_navigate`, `delegate_task`). Never name shell utilities the agent has wrapped: `grep` →
|
||||
`search_files`, `cat`/`head`/`tail` → `read_file`, `sed`/`awk` → `patch`, `find`/`ls` →
|
||||
`search_files target='files'`. MCP dependencies are named with setup in `## Prerequisites`.
|
||||
Third-party CLIs and pipelines are fine inside script files, not as the headline surface.
|
||||
3. **`platforms:` gating is audited against actual script imports.** POSIX-only primitives
|
||||
(`fcntl`, `termios`, `os.setsid`, `os.kill(pid, 0)`, `/proc`, hardcoded `/tmp`, `signal.SIGKILL`,
|
||||
bash heredocs, `osascript`, `apt`, `systemctl`) require a platform declaration. Fix cross-platform
|
||||
first (`tempfile.gettempdir`, `pathlib.Path`, `psutil.pid_exists`, Python filtering instead of
|
||||
`grep`); gate narrower only when the dependency is genuinely platform-bound.
|
||||
4. **`author` credits the human first.** External contributor's real name + GitHub handle first,
|
||||
"Hermes Agent" second. A commit authored as "Hermes Agent" (they drafted with Hermes) is replaced
|
||||
with the human's name — credit the human, not the tool.
|
||||
5. **Modern section order:** `# <Skill> Skill`, 2–3 sentence intro (what it does and doesn't),
|
||||
`## When to Use`, `## Prerequisites`, `## How to Run`, `## Quick Reference`, `## Procedure`,
|
||||
`## Pitfalls`, `## Verification`. ~200 lines for a complex skill, ~100 simple. Cut intro fluff,
|
||||
marketing prose, and env-var re-explanations already in Prerequisites.
|
||||
6. **`scripts/`, `references/`, `templates/`.** Don't make the model inline-write parsers or
|
||||
non-trivial logic every call — ship a helper script and reference it by skill-relative path.
|
||||
7. **Tests at `tests/skills/test_<skill>_skill.py`**, stdlib + pytest + `unittest.mock` only, no
|
||||
live network. Run `scripts/run_tests.sh tests/skills/test_<skill>_skill.py -q`.
|
||||
8. **`.env.example` additions sit in a clearly delimited block.** Contributor copies of the file are
|
||||
usually stale; edits outside the skill's own block are dropped during salvage.
|
||||
|
||||
No `offset`/`limit` pagination on skill-loading tools — the agent must read a skill fully (root).
|
||||
The salvage/modernisation checklist for external skill PRs is `references/new-skill-pr-salvage.md`
|
||||
in the `hermes-agent-dev` skill.
|
||||
|
||||
## Curator (skill lifecycle)
|
||||
|
||||
Background maintenance that tracks usage on agent-created skills and auto-archives stale ones;
|
||||
archives go to `~/.hermes/skills/.archive/` and are restorable. Core `agent/curator.py` (review
|
||||
loop, auto-transitions, LLM review prompt) + `agent/curator_backup.py` (pre-run tar.gz snapshots);
|
||||
CLI `hermes_cli/curator.py` → `hermes curator status|run|pause|resume|pin|unpin|archive|restore|
|
||||
prune|backup|rollback`; telemetry `tools/skill_usage.py` owns `~/.hermes/skills/.usage.json`
|
||||
(`use_count`, `view_count`, `patch_count`, `last_activity_at`, `state` active/stale/archived,
|
||||
`pinned`). Config `curator:` — `enabled, interval_hours, min_idle_hours, stale_after_days,
|
||||
archive_after_days, backup.*`; its LLM calls route through `auxiliary` (`agent/AGENTS.md`).
|
||||
|
||||
Invariants: touches only `created_by: "agent"` skills (bundled + hub-installed are off-limits);
|
||||
never deletes — archive is the maximum; pinned skills are exempt from every auto-transition and
|
||||
the LLM review; `skill_manage(action="delete")` refuses pinned skills while patch/edit/write_file/
|
||||
remove_file still work so the agent can keep improving them.
|
||||
@@ -0,0 +1,96 @@
|
||||
# tools/ + toolsets.py + model_tools.py — model tools
|
||||
|
||||
Applies on top of the root `AGENTS.md`: settle the **Footprint Ladder** before adding anything here.
|
||||
Most capabilities should NOT be core tools. Long-form: `website/docs/developer-guide/adding-tools.md`,
|
||||
`tools-runtime.md`.
|
||||
|
||||
## Registry and discovery
|
||||
|
||||
`tools/registry.py` has no deps and is imported by every tool file; each `tools/*.py` calls
|
||||
`registry.register()` at import time; `model_tools.py` imports the registry and triggers discovery
|
||||
(`discover_builtin_tools()`), then `run_agent.py`, `cli.py`, `batch_runner.py`, `environments/`
|
||||
consume it. Any `tools/*.py` with a top-level `registry.register()` is imported automatically — no
|
||||
manual import list. The registry handles schema collection, dispatch (`handle_function_call()`),
|
||||
availability (`check_fn`, TTL-cached process-wide), and error wrapping. **All handlers return a JSON
|
||||
string.**
|
||||
|
||||
## Adding a core tool (2 files) — only when the user is explicitly contributing a core tool
|
||||
|
||||
For custom/local-only tools do NOT edit core: create `~/.hermes/plugins/<name>/plugin.yaml` +
|
||||
`__init__.py` and call `ctx.register_tool(...)`; plugin toolsets are discovered automatically and
|
||||
toggled without touching `tools/` or `toolsets.py` (`plugins/AGENTS.md`).
|
||||
|
||||
1. `tools/your_tool.py`:
|
||||
```python
|
||||
from tools.registry import registry
|
||||
def check_requirements() -> bool: return bool(os.getenv("EXAMPLE_API_KEY"))
|
||||
def example_tool(param: str, task_id: str = None) -> str: return json.dumps({"success": True, ...})
|
||||
registry.register(name="example_tool", toolset="example",
|
||||
schema={"name": "example_tool", "description": "...", "parameters": {...}},
|
||||
handler=lambda args, **kw: example_tool(param=args.get("param", ""), task_id=kw.get("task_id")),
|
||||
check_fn=check_requirements, requires_env=["EXAMPLE_API_KEY"])
|
||||
```
|
||||
2. `toolsets.py`: add the name to `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **Required**
|
||||
— discovery registers the schema, but a tool is only exposed if a toolset names it.
|
||||
`_HERMES_CORE_TOOLS` is the default bundle every platform's base toolset inherits, not dead code.
|
||||
|
||||
Rules for tool code:
|
||||
- **Schema descriptions must not name tools from other toolsets** (`browser_navigate` saying "prefer
|
||||
web_search"). Those tools may be unavailable (missing key, disabled toolset) and the model
|
||||
hallucinates calls to them. Cross-references are added dynamically in `get_tool_definitions()` in
|
||||
`model_tools.py` — see the `browser_navigate` / `execute_code` post-processing blocks.
|
||||
- **Paths in schema descriptions use `display_hermes_home()`** (schema is built at import, after
|
||||
`_apply_profile_override()` set `HERMES_HOME`). **State files use `get_hermes_home()`**, never
|
||||
`Path.home()/.hermes`, so each profile gets its own state.
|
||||
- **No `offset`/`limit` on instructional tools** (skills, prompts, playbooks) — models read page 1
|
||||
and skip the rest (root rubric).
|
||||
- **`check_fn` answers reachability/opt-in, never surface.** It is TTL-cached process-wide, and one
|
||||
process serves many sessions; GUI-only tools go in a named toolset (`desktop_ui`, `project`)
|
||||
folded in by `_load_enabled_toolsets(platform)` (root: capability is a property of the SESSION).
|
||||
- **Agent-level tools** (`todo`, `memory`) are intercepted before `handle_function_call()` via the
|
||||
`INLINE_TOOL_EXECUTORS` table (`agent/inline_tool_executors.py`; `agent/AGENTS.md`).
|
||||
- **`_last_resolved_tool_names`** is a process-global in `model_tools.py`; `_run_single_child()` in
|
||||
`delegate_tool.py` saves/restores it around child runs — readers may see it stale mid-delegation.
|
||||
- New tools integrate with existing setup UX (`hermes tools`, `hermes setup`, auto-install) rather
|
||||
than a raw env var; secrets go in `OPTIONAL_ENV_VARS` (`hermes_cli/AGENTS.md`).
|
||||
|
||||
## Toolsets (`toolsets.py`)
|
||||
|
||||
Single `TOOLSETS` dict. Keys today: `browser, clarify, code_execution, cronjob, debugging,
|
||||
delegation, discord, discord_admin, feishu_doc, feishu_drive, file, homeassistant, image_gen,
|
||||
kanban, memory, messaging, moa, rl, safe, search, session_search, skills, spotify, terminal, todo,
|
||||
tts, video, vision, web, yuanbao` (don't assert the list in tests). Per-platform enable/disable via
|
||||
`hermes tools` (curses) or `tools.<platform>.enabled/disabled` in config.yaml. `browser_exec`
|
||||
replaces the other browser tools when `browser.backend` is `browser-use`.
|
||||
|
||||
## Backends and providers inside tools/
|
||||
|
||||
Several tools front pluggable backends: terminal environments in `tools/environments/` (local,
|
||||
docker, ssh, modal, daytona, singularity; `terminal_tool_backends.py`, `tool_backend_helpers.py`),
|
||||
browser (`browser_tool_*.py`: cdp, cloud, install, lifecycle, session, real_profile, vision), MCP
|
||||
client (`mcp_tool_*.py`: config, discovery, transport, registration, content, errors), TTS
|
||||
(`tts_tool_providers.py`, `tts_command_provider.py`), skills hub sources (`skills_hub_official.py`
|
||||
`OptionalSkillSource`). Adding a backend = a new sibling or provider entry in the existing table,
|
||||
never an `elif` on a backend name (root shape rules). Remote-backend file visibility problems are
|
||||
fixed at the mount, not by adding a tool.
|
||||
|
||||
## Delegation (`tools/delegate_tool.py`)
|
||||
|
||||
Spawns a subagent with isolated context + terminal session; the parent waits for the summary unless
|
||||
`background=true`, which returns a delegation id and re-enters the result via the async-delegation
|
||||
completion queue. Shapes: single (`goal` + optional `context`, `toolsets`) or batch (`tasks: [...]`,
|
||||
concurrency capped by `delegation.max_concurrent_children`, default 3). Roles: `leaf` (default;
|
||||
no `delegate_task`, `clarify`, `memory`, `send_message`, `cronjob`; keeps `execute_code`) and
|
||||
`orchestrator` (keeps `delegate_task`; gated by `delegation.orchestrator_enabled`, bounded by
|
||||
`delegation.max_spawn_depth`, default 2). Config knobs under `delegation:`:
|
||||
`max_concurrent_children, max_spawn_depth, child_timeout_seconds, orchestrator_enabled,
|
||||
subagent_auto_approve, inherit_mcp_toolsets, max_iterations`. **Durability:** background
|
||||
delegation is process-local; work that must survive restart uses `cronjob` or
|
||||
`terminal(background=True, notify_on_complete=True)`. API: `website/docs/developer-guide/subagent-lifecycle-api.md`.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/tools/`. Test the handler through the registry (real dispatch), not the bare function only;
|
||||
assert contracts ("every registered tool has a toolset", "no schema description names a tool from
|
||||
another toolset") rather than tool counts. Approval/security-boundary tools are E2E'd with real
|
||||
imports against a temp `HERMES_HOME` (see `tests/tools/test_approval_config_readonly.py`).
|
||||
@@ -0,0 +1,74 @@
|
||||
# tui_gateway/ + ui-tui/ — the TUI and its JSON-RPC backend
|
||||
|
||||
Applies on top of the root `AGENTS.md`. The TUI fully replaces the classic prompt_toolkit CLI;
|
||||
activate with `hermes --tui` or `HERMES_TUI=1`. `tui_gateway` is ALSO the backend the Desktop app
|
||||
and the dashboard `/chat` talk to — changes here have three consumers.
|
||||
|
||||
## Process model
|
||||
|
||||
```
|
||||
hermes --tui
|
||||
└─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway)
|
||||
│ └─ AIAgent + tools + sessions
|
||||
└─ renders transcript, composer, prompts, activity
|
||||
```
|
||||
|
||||
TypeScript owns the screen. Python owns sessions, tools, model calls, and slash-command logic.
|
||||
Never move agent behaviour into the renderer.
|
||||
|
||||
## Transport
|
||||
|
||||
Newline-delimited JSON-RPC over stdio: requests from Ink, events from Python. `tui_gateway/server.py`
|
||||
is the facade with the method/event catalog; methods live in `methods_*.py` siblings (`methods_config`,
|
||||
`methods_complete`, `methods_browser`, `methods_bot_relay`, ...), event publishing in
|
||||
`event_publisher.py` / `event_replay.py`. Desktop reaches the same server over WebSocket via
|
||||
`apps/shared` (`JsonRpcGatewayClient`). New RPC = a new `methods_<topic>.py` or an entry in an
|
||||
existing topical sibling, registered in the table — no `if method == ...` chain (root shape rules).
|
||||
|
||||
## Key surfaces
|
||||
|
||||
| Surface | Ink component | Gateway method / event |
|
||||
|---|---|---|
|
||||
| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta` / `message.complete` |
|
||||
| Tool activity | `thinking.tsx` | `tool.start` / `tool.progress` / `tool.complete` |
|
||||
| Approvals | `prompts.tsx` | `approval.request` → `approval.respond` |
|
||||
| Clarify / sudo / secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify.respond`, `sudo.respond`, `secret.respond` |
|
||||
| Session picker | `sessionPicker.tsx` | `session.list` / `session.resume` |
|
||||
| Slash commands | local handler + fallthrough | `slash.exec` → `_SlashWorker`; `command.dispatch` |
|
||||
| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
|
||||
| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` carries skin data |
|
||||
| Plugin compat notice | — | `plugins.compat_report` (see `plugins/AGENTS.md`) |
|
||||
|
||||
## Slash command flow
|
||||
|
||||
1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, ...) are
|
||||
handled locally in `app.tsx`.
|
||||
2. Everything else → `slash.exec`, which runs in the persistent `_SlashWorker` subprocess →
|
||||
`command.dispatch` fallback, which the gateway resolves into a skill / alias / exec directive
|
||||
(a skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt).
|
||||
|
||||
`commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) already include
|
||||
built-ins, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` /
|
||||
`get_skill_commands()`) — clients do not need a new RPC to see skills. The command definitions
|
||||
themselves come from `hermes_cli/commands.py` (`hermes_cli/AGENTS.md`).
|
||||
|
||||
## Dev commands
|
||||
|
||||
```bash
|
||||
cd ui-tui
|
||||
npm install # first time
|
||||
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
|
||||
npm start # production
|
||||
npm run build # full build (hermes-ink + tsc)
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # eslint
|
||||
npm run fmt # prettier
|
||||
npm test # vitest
|
||||
```
|
||||
|
||||
Python tests: `tests/tui_gateway/` via `scripts/run_tests.sh`. TS tests: vitest in `ui-tui`. A
|
||||
Python test that asserts about `package.json` / `.ts` sources will not run on a JS-only PR — keep
|
||||
JS-side assertions in vitest (root testing rules). Root TypeScript style rules apply.
|
||||
|
||||
Related: `web/AGENTS.md` (dashboard embeds this TUI over a PTY), `apps/desktop/AGENTS.md` (own
|
||||
renderer on the same backend).
|
||||
@@ -0,0 +1,45 @@
|
||||
# web/ + hermes_cli/web_routers/ — the dashboard (`hermes dashboard` → `/chat`)
|
||||
|
||||
Applies on top of the root `AGENTS.md`. Backend routers: `hermes_cli/web_routers/*.py`, one file per
|
||||
dashboard surface, mounted by `hermes_cli/web_server.py` (+ `web_server_*.py` siblings). Frontend:
|
||||
`web/src/`. Shared JSON-RPC/WS client: `apps/shared` (`@hermes/shared`), also used by the desktop.
|
||||
|
||||
## The dashboard embeds the REAL `hermes --tui` — not a rewrite
|
||||
|
||||
`hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `web_server.py`:
|
||||
|
||||
- `web/src/pages/ChatPage.tsx` mounts xterm.js `Terminal` with the WebGL renderer, `@xterm/addon-fit`
|
||||
(container-driven resize) and `@xterm/addon-unicode11` (wide-character widths).
|
||||
- `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST,
|
||||
passed as a query param because browsers cannot set `Authorization` on a WS upgrade.
|
||||
- The server spawns exactly what `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL
|
||||
works, native Windows does not).
|
||||
- Frames are raw PTY bytes each way; resize travels as `\x1b[RESIZE:<cols>;<rows>]`, intercepted
|
||||
on the server and applied with `TIOCSWINSZ`.
|
||||
|
||||
**Do not re-implement the primary chat experience in React.** Transcript, composer/input flow
|
||||
(including slash-command behaviour), and the PTY-backed terminal belong to the embedded TUI; anything
|
||||
added to Ink shows up here automatically. If you are rebuilding the transcript or composer for the
|
||||
dashboard, stop and extend Ink (`tui_gateway/AGENTS.md`).
|
||||
|
||||
**Structured React UI around the TUI is fine when it is not a second chat surface.** Sidebar
|
||||
widgets, inspectors, summaries, status panels (`ChatSidebar`, `ModelPickerDialog`, `ToolCall`)
|
||||
complement the embedded TUI. Keep their state independent of the PTY child's session and surface
|
||||
their failures non-destructively so the terminal pane keeps working.
|
||||
|
||||
## `dashboard` vs `serve`
|
||||
|
||||
`dashboard` and `serve` share `cmd_dashboard` / `start_server` but are independent surfaces — neither
|
||||
launches the other. `serve` is the headless backend the desktop app spawns (`headless_backend=True`:
|
||||
`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 JSON-RPC/WS/API is reachable). The
|
||||
desktop has no build/runtime dependency on this frontend. Details: `apps/desktop/src/AGENTS.md`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Auth: every new REST route and WS endpoint uses the same session token; never a second scheme.
|
||||
- Routers are one-file-per-surface; a new surface is a new `web_routers/<surface>.py`, not a growing
|
||||
`web_server.py`.
|
||||
- Tests: Python in `tests/hermes_cli/` (routers, pty bridge); JS in the `web/` vitest suite. Python
|
||||
tests must not assert about `package.json` / `.tsx` sources (root testing rules). Root TypeScript
|
||||
style rules apply.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
sidebar_position: 15
|
||||
title: "CLI Internals"
|
||||
description: "How hermes_cli is shaped: slash dispatch, config loaders, the skin engine, the transactional update pipeline, and process-identity rules"
|
||||
---
|
||||
|
||||
# CLI Internals
|
||||
|
||||
Companion to `hermes_cli/AGENTS.md` (the rules) — this page holds the longer explanations.
|
||||
|
||||
## Update pipeline
|
||||
|
||||
The stage-by-stage contract (`plan → snapshot → apply → restart-per-kind → verify → report`) and the
|
||||
field failure each stage guards are documented in `hermes_cli/AGENTS.md`; user-facing behaviour
|
||||
(receipts, `--plan`, snapshot modes) is in [Updating](../getting-started/updating.md).
|
||||
|
||||
## Process identity: never infer it from argv substrings
|
||||
|
||||
The bug class behind ~10 fleet-update issues (#90778, #87594, #78089, #76129, #91964, ...):
|
||||
classifying a process by `"serve" in cmdline` or similar. `kanban --preserve-cache` contains
|
||||
"serve"; a flag VALUE can equal a subcommand (`-m dashboard serve`); truncated cmdlines hide the real
|
||||
subcommand. Rules:
|
||||
|
||||
- Use the canonical matchers: `gateway.status.looks_like_gateway_command_line` (gateway run),
|
||||
`hermes_cli.update_cmd._hermes_holder_subcommand` (top-level subcommand of any Hermes argv). Never
|
||||
hand-roll token scans.
|
||||
- Flag sets must be DERIVED from the parser (`_holder_value_flags()` introspects
|
||||
`build_top_level_parser()`), never hand-written lists — they drift.
|
||||
- Never blanket-exclude ancestors from process scans: when `/update` runs as the gateway's child, a
|
||||
gateway ancestor must stay visible to the pause machinery (#87594). Exclude interactive ancestry,
|
||||
carve out gateway-shaped ancestors.
|
||||
- Match on FULL cmdlines; truncate only at display time (#78089).
|
||||
- Before adding any new scan heuristic, read #92091 — the gateway control socket replaces scans as
|
||||
the primary coordination mechanism; scans are the fallback layer for old/crashed processes.
|
||||
|
||||
## Skin engine — what skins customize
|
||||
|
||||
| Element | Skin key | Used by |
|
||||
|---|---|---|
|
||||
| Banner panel border / title / section headers / dim / body | `colors.banner_border`, `banner_title`, `banner_accent`, `banner_dim`, `banner_text` | `banner.py` |
|
||||
| Response box border | `colors.response_border` | `cli.py` |
|
||||
| Spinner faces (waiting / thinking) | `spinner.waiting_faces`, `spinner.thinking_faces` | `display.py` |
|
||||
| Spinner verbs / wings (optional) | `spinner.thinking_verbs`, `spinner.wings` | `display.py` |
|
||||
| Tool output prefix / per-tool emojis | `tool_prefix`, `tool_emojis` | `display.py` → `get_tool_emoji()` |
|
||||
| Agent name / welcome / response label / prompt symbol | `branding.agent_name`, `welcome`, `response_label`, `prompt_symbol` | `banner.py`, `cli.py` |
|
||||
|
||||
Built-in skins (`_BUILTIN_SKINS` in `hermes_cli/skin_engine.py`): `default` (classic gold/kawaii),
|
||||
`ares` (crimson/bronze with custom spinner wings), `mono` (grayscale), `slate` (cool blue). Add a
|
||||
built-in as a dict entry `{"name", "description", "colors", "spinner", "branding", "tool_prefix"}`.
|
||||
User skins are `~/.hermes/skins/<name>.yaml` with the same keys, activated with `/skin <name>` or
|
||||
`display.skin: <name>`; the full YAML template is in the
|
||||
[Skins & Themes](../user-guide/features/skins.md) user guide.
|
||||
|
||||
## Profiles: multi-instance support
|
||||
|
||||
Hermes supports profiles — fully isolated instances, each with its own `HERMES_HOME` (config, API
|
||||
keys, memory, sessions, skills, gateway). `_apply_profile_override()` in `hermes_cli/main.py` sets
|
||||
`HERMES_HOME` before any module imports, so every `get_hermes_home()` reference scopes to the active
|
||||
profile. Profile operations are HOME-anchored (`_get_profiles_root()` returns
|
||||
`Path.home() / ".hermes" / "profiles"`, not `get_hermes_home() / "profiles"`) so
|
||||
`hermes -p coder profile list` sees all profiles regardless of which one is active — intentional.
|
||||
Profile-safe coding rules are in the root `AGENTS.md`; multiplex secret-scope rules in
|
||||
`gateway/AGENTS.md`.
|
||||
Reference in New Issue
Block a user