Hermes CLI Development
Techniques for modifying and extending the Hermes Agent CLI source code
(~/.hermes/hermes-agent/cli.py and related files). Use this skill when
adding new features, integrating with the terminal, or understanding how
the CLI session lifecycle works.
Quick Reference: Key Files
| File | What it does |
|---|---|
cli.py |
HermesCLI class — interactive CLI, session management, slash commands |
hermes_state.py |
SessionDB — SQLite session store, title management, FTS5 search |
run_agent.py |
AIAgent — core conversation loop |
hermes_cli/commands.py |
Central slash command registry |
agent/prompt_builder.py |
System prompt assembly, environment hints |
tui_gateway/server.py |
Gateway RPC — session.active_list, live session state for TUI |
ui-tui/src/app/useMainApp.ts |
TUI terminal title via useTerminalTitle() + composeTabTitle() |
ui-tui/src/domain/paths.ts |
composeTabTitle() — builds terminal titlebar string |
References: references/terminal-title-patch.md — full patch history, re-application notes, and TUI/desktop title path documentation.
Session Title Flow
Hermes stores session titles only in state.db via SessionDB.set_session_title().
There is no auto-generation — the auxiliary.title_generation config key exists
in config.yaml but is not wired up in the codebase. Titles only exist when:
- User runs
/title <name>(stored immediately or deferred via_pending_title) - Branch sessions auto-derive titles from parent
- Resume preserves existing titles
Key methods:
self._session_db.get_session_title(session_id)→str | Noneself._session_db.set_session_title(session_id, title)→boolself._pending_title— deferred title applied when agent initializesself.conversation_history— list of{role, content}dicts, available for fallback extraction
Terminal Title Integration
Hermes does NOT set the terminal window title by default. The terminal emulator (Windows Terminal, iTerm2, GNOME Terminal) shows whatever the shell/process provides, which on WSL+Windows Terminal shows "hermes default" (process name + profile).
OSC Escape Sequence Pattern
def _set_terminal_title(self, title: str = None):
"""Set terminal window/tab title via OSC escape sequences."""
try:
if title is None:
# Fallback chain: DB title → first user message → session ID
if self._session_db:
db_title = self._session_db.get_session_title(self.session_id)
if db_title:
title = db_title
if title is None:
# Extract from first user message in conversation_history
for msg in (self.conversation_history or []):
if msg.get("role") == "user":
raw = msg.get("content", "")
if isinstance(raw, str) and raw.strip():
first_line = raw.strip().split('\n')[0]
if not first_line.startswith('/'):
title = first_line[:60].strip()
if len(raw.strip()) > 60:
title += "…"
break
if title is None:
title = self.session_id[:12] if self.session_id else "hermes"
# OSC 0 = icon name + window title, OSC 2 = window title only
sys.stdout.write(f"\033]0;Hermes: {title}\007")
sys.stdout.write(f"\033]2;Hermes: {title}\007")
sys.stdout.flush()
except Exception:
pass # Never let title setting break the CLI
Both \033]0; and \033]2; are sent for max compatibility:
\033]0;— sets both icon name and window title (wider support)\033]2;— sets just the window title (preferred by modern terminals)- Works on: Windows Terminal, iTerm2, GNOME Terminal, Konsole, Alacritty, Kitty
Where to Hook In (Lifecycle Points)
When adding a feature like terminal titles, wire it up at these key points in HermesCLI:
| Method | Line (approx) | When | What to pass |
|---|---|---|---|
show_banner() |
end | Startup | _set_terminal_title() — no args, uses fallback |
new_session() |
after session ID set | /new, /reset |
_set_terminal_title(title) — new title or None |
_handle_resume_command() |
after session switch | /resume |
_set_terminal_title() — picks up DB title |
| Branch handler | after branch created | /branch |
_set_terminal_title(branch_title) |
/title handler |
after set_session_title |
/title |
_set_terminal_title(new_title) |
_init_agent() pending title |
after title applied | First message | _set_terminal_title(self._pending_title) |
chat() |
before return response |
After each agent turn | _set_terminal_title() — picks up first message |
Patching cli.py Safely
- Indentation matters. cli.py uses 4-space indentation.
HermesCLImethods use 4 spaces (one level). Method bodies use 8 spaces. Nested blocks use 12. - Always syntax-check after patching:
cd ~/.hermes/hermes-agent python3 -c "import py_compile; py_compile.compile('cli.py', doraise=True)" - Pre-existing warnings are OK. The
SyntaxWarning: 'return' in a 'finally' blockat line ~10600 is pre-existing — ignore it. - Methods vs nested functions: Adding
def _method(self, ...)inside__init__creates a nested function whereselfshadows the instance. Always add methods at class level (between existing methods), not inside__init__. - Use
rtk grep -nto find line numbers before patching — they shift with each edit.
Pitfalls
SessionDBmay be None. Agent initialization can fail, leavingself._session_dbas None. Always guard withif self._session_db:before calling DB methods.conversation_historyis empty at startup. The first-user-message fallback won't work until after the firstchat()call. Startup title will show session ID.- Don't add methods inside
__init__. Python treats them as nested functions whereselfbecomes a regular parameter, not the instance reference. - The
finallyblock at line ~10600 inchat()has areturnstatement that triggers a SyntaxWarning. This is pre-existing — don't try to fix it as part of other changes. - Terminal title escape sequences are invisible. They produce no visible output but are interpreted by the terminal. If titles aren't changing, verify the terminal emulator supports OSC sequences (almost all modern ones do).
- Hermes updates WIPE custom patches to cli.py. When
hermes updateruns, it replaces the entire install directory with a fresh git pull — any custom changes tocli.pyare lost. Before updating, Hermes backs up modified files to~/hermes-backups/pre-update-<timestamp>/. The backup includes:cli.py.original— pristine pre-update filecli.py.modified— your version with custom patchescli-py.patch— unified diff you can re-apply To restore after an update:cd ~/.hermes/hermes-agent && git apply ~/hermes-backups/pre-update-*/cli-py.patch— but expect line-number conflicts if upstream changed the file significantly.
- Line numbers SHIFT between Hermes versions. The file grows/shrinks with each
update (e.g., 14,822 → 16,455 lines between v0.17 and v0.18.2). Always use
rtk grep -n "pattern" cli.pyto find current line numbers before patching; never rely on line numbers from a previous session. - CLI
_set_terminal_title()and the TUI title are SEPARATE paths. The CLI's_set_terminal_title()works forhermes(plain CLI) sessions. The Ink/React TUI (hermes --tui) sets the terminal title via its ownuseTerminalTitle()hook inui-tui/src/app/useMainApp.ts, which pulls session titles from thesession.active_listgateway RPC. If both are running in the same terminal, the TUI one wins. The desktop app uses yet another path — pane headers come frompaneFor(paneId)?.titlein zone definitions, not from session titles.