Skip to content

fix(skill_manager): use patchable SKILLS_DIR in _find_skill - #5268

Closed
iRonin wants to merge 44 commits into
NousResearch:mainfrom
iRonin:fix/skill-manager-find-skill
Closed

fix(skill_manager): use patchable SKILLS_DIR in _find_skill#5268
iRonin wants to merge 44 commits into
NousResearch:mainfrom
iRonin:fix/skill-manager-find-skill

Conversation

@iRonin

@iRonin iRonin commented Apr 5, 2026

Copy link
Copy Markdown
Contributor

Problem

_find_skill() called get_all_skills_dirs() which always returns the real ~/.hermes/skills/ as its first entry — ignoring any unittest.mock.patch on the module-level SKILLS_DIR constant.

Tests patch SKILLS_DIR to a tmp_path, so _create_skill wrote skills there but _find_skill never found them. Every operation that needed to locate an existing skill (edit, patch, delete, write_file, remove_file) silently failed with "skill not found", and the duplicate-check test passed when it shouldn't.

Fix

Replace get_all_skills_dirs()[0] with the module-level SKILLS_DIR (which is patchable), keeping external skill dirs from config at indices 1+:

all_dirs = get_all_skills_dirs()
search_dirs = [SKILLS_DIR] + list(all_dirs[1:])

In production SKILLS_DIR == get_all_skills_dirs()[0] so behaviour is identical. In tests the patch is respected.

Tests

All 51 test_skill_manager_tool tests now pass locally.

iRonin added 30 commits April 4, 2026 18:00
Adds two features to the Hermes CLI:

Ctrl+G — External Editor:
- Opens current input in $VISUAL / $EDITOR / VS Code / Cursor / vi
- Smart paste detection: if input contains a collapsed paste reference
  [Pasted text #N → path], opens that file directly for editing
- Uses run_in_terminal() for clean TUI suspend/resume
- Updates input buffer and paste line count on editor close

/keys (/shortcuts) — Keyboard Shortcuts Display:
- Categorized list of all keybindings (Input, Session, Drafting, Voice)
- Reads voice key from config for accurate display
- Registered in CommandDef with tab completion
…icators

Adds a Claude Code-style input stash to the Hermes CLI:

- Ctrl+S stashes current input (text + attached images) and clears the field
- Ctrl+S on empty input pops the stash back
- Stashed input auto-restores after the agent finishes responding
- Placeholder shows stash preview when idle, hint when agent is running
- Status bar shows a pinned indicator when a stash is active
- Uses (text, [images]) tuple so dragged/pasted images are preserved

Alternative to NousResearch#4259 with additional features: auto-restore after
response (the key UX from Claude Code), image stashing, placeholder
preview, status bar indicator, and proper buf.reset() cleanup.

Closes NousResearch#4255
Input starting with / is only routed to the command handler when the
first word matches a known command (via resolve_command). Bare paths
like /Users/ironin/file.md:45-46 now pass through as regular input
to the agent instead of triggering 'Unknown command'.

Fixes both the process_loop routing and the handle_enter interrupt
bypass — both had the same startswith('/') assumption.
Alt+Enter now queues the current input as a follow-up to be sent after
the agent finishes responding, instead of inserting a newline.

- Alt+Enter → puts message into _pending_input (non-interrupting)
- Enter (agent running) → still interrupts via _interrupt_queue
- _followup_queue list mirrors pending items for display
- Status bar shows 📬 N when follow-ups are queued
- Placeholder hints update: shows queue depth while agent runs,
  and persists after it finishes until queue drains
- Ctrl+J remains the newline key for multi-line input

Closes: the need for Shift+Enter queue (terminals can't distinguish
Shift+Enter from Enter; Alt+Enter is the reliable alternative)
Alt+Up pops the most recently queued follow-up (LIFO) from
_followup_queue, appends it to the current input with a newline---
separator, and marks it cancelled so process_loop skips it.

Repeated Alt+Up recalls one at a time until queue is empty.
_cancelled_followups set is checked in process_loop and discarded
on match to avoid sending the recalled message twice.
hermes -c "session name" -m anthropic/claude-sonnet-4-6 now works.
Previously -m was only on 'hermes chat', so the shorthand root-level
-c flag couldn't be combined with a model override.

Also stop stomping args.model/provider with None in the root→chat
passthrough — the values from the root parser are now preserved.
Addresses review feedback from britrik (NousResearch#4788):

- Replace text-based cancellation with UUID tags — identical messages
  queued twice no longer cancel each other incorrectly
- Wrap Alt+Enter payloads as {_followup_tag, payload} dicts so
  process_loop can identify followup items by ID, not content
- Fix phantom _followup_queue pops: display sync now only happens
  for tagged (Alt+Enter) items, not regular Enter messages
- _cancelled_followups stores UUIDs (bounded, auto-discarded on match)

Note: the image-payload cancel check was already correct in the original
— both sides extracted text via payload[0] — but UUID tagging makes the
intent unambiguous regardless of payload shape.
…dicator

Sets the terminal title via OSC 0 escape sequence (\x1b]0;...\x07):

  ⚕ Hermes — session name   (named session, idle)
  ⚕ Hermes ⏳                (agent thinking)
  ⚕ Hermes                   (unnamed session)

Symbol comes from the active skin's response_label (⚕ default,
⚔ Ares, etc.) so it adapts to the current theme.

Updated at:
- run() startup
- _preload_resumed_session() when a titled session is resumed
- /title command when a title is set or committed from pending
- process_loop when agent starts (thinking=True) and finishes

Skipped when stdout is not a TTY, TERM=dumb, or NO_COLOR is set.
Three input UX improvements:

1. ESC ESC — clear input buffer (and attached images)
   Pressing ESC twice quickly discards the current draft without
   conflicting with Alt key sequences (escape+enter, escape+up, etc.)

2. Ctrl+P — peek collapsed paste content inline
   When input contains a [Pasted text #N → path] reference, prints
   the first 20 lines in the terminal so the user can verify content
   without opening an editor (Ctrl+G). Falls back to previewing the
   current input text when no paste reference is present.

3. \r\n normalisation in handle_paste
   Windows-style (CRLF) and old Mac-style (CR) line endings are
   normalised to LF before the 5-line collapse threshold is checked.
   Prevents markdown pasted from Windows sources being treated as
   single-line and bypassing the file-reference collapse.
Ctrl+P is now context-aware:
  - paste ref in input → peek first 20 lines of paste file inline
  - text in input     → preview first 20 lines of current input
  - empty input       → full conversation pager (newest first, via less)

New show_history_full() method:
  - Reverses conversation order so most recent message is at the top
  - No truncation — full text of every user and assistant turn
  - Tool call names listed inline on the header line
  - Strips REASONING_SCRATCHPAD blocks
  - Pipes through 'less -R --no-init --quit-if-one-screen'
    (falls back to plain print if less is unavailable)
  - Header shows message count and keyboard hints (q, /)

New /history full (aliases: f, all):
  - Calls show_history_full() from the slash command interface
  - /history (no arg) still calls the existing show_history()
- Drop 'Hermes' and session name from tab title — symbol only
- Use OSC 1 (tab/icon title) + OSC 2 (window title) instead of OSC 0
  so iTerm2 does not append the Python process name to the tab label
- Thinking indicator: ⚕ ⏳ (was ⚕ Hermes ⏳)
- Idle: ⚕ (was ⚕ Hermes / ⚕ Hermes — session)
… proxy

Inside the TUI, sys.stdout is patched by prompt_toolkit's patch_stdout.
OSC escape sequences written to StdoutProxy are buffered or discarded
and never reach the terminal emulator — so /title didn't update the tab
and Python still appeared as the process name.

Fix: use sys.__stdout__ (the pre-patch original) and write via os.write()
directly to the file descriptor, bypassing the proxy entirely.
Users on tmux/screen, or whose iTerm2 profile appends the job name
(Python) to the tab title, can now disable OSC title sequences:

  display:
    terminal_title: false

Default: true (enabled).
…logins

/browser connect profile        — Default profile (cookies/logins intact)
/browser connect profile 'Profile 1'  — specific Chrome profile
/browser connect ws://...       — custom CDP URL (unchanged)

Passes --user-data-dir and --profile-directory to Chrome so existing
sessions (X, LinkedIn, etc.) are available. Warns that Chrome must be
fully quit first (Cmd+Q) since Chrome is single-instance per profile.
Complete rewrite of browser command handling:

  /browser connect        — auto-launches Chrome with ~/.hermes/chrome-profile
                            (or browser.hermes_profile_dir from config)
                            auto-detects if already running, no URL needed
  /browser connect setup  — first-time setup: creates profile dir, opens Chrome
                            so user can log in, then close and run connect
  /browser connect <url>  — explicit CDP URL (unchanged)

Config options:
  browser.hermes_profile_dir: ~/.hermes/chrome-profile
  browser.cdp_port: 9222

Fixes BROWSER_CDP_URL being set to literal words ('profile', 'hermes')
instead of the actual http://localhost:PORT endpoint.

Adds _chrome_candidates(), _ensure_chrome_debug() helpers.
…ering race

os.write() to fd 1 races with prompt_toolkit's own rendering writes,
causing ESC to appear as '?' and the OSC sequence to leak as literal
text (e.g. '?]0;⚕ Hermes' visible in the terminal output).

Fix: when inside the TUI, write via get_app().output.write_raw() which
is synchronised with the render loop. Falls back to direct fd write when
outside the TUI (startup, single-query mode).
…mode)

Two distinct queues with dedicated icons and recall shortcuts:

📬 Follow-up queue (Alt+Enter, always non-interrupting)
  - New: independent follow-up task after current response
  - Recall: Alt+Up (LIFO)
  - Status bar: 📬 N
  - UUID-tagged, cancellable

🎯 Steering queue (Enter during agent run, busy_input_mode=queue)
  - Contextual guidance for current/upcoming work
  - Recall: Alt+Down (LIFO)
  - Status bar: 🎯 N
  - UUID-tagged, cancellable

Placeholder hints adapt to busy_input_mode:
  queue mode:     'Enter to steer (🎯) · Alt+Enter to follow-up (📬)'
  interrupt mode: 'Enter to interrupt · Alt+Enter to queue follow-up (📬)'

Idle placeholder shows both counts with their recall shortcuts.
Agent-running placeholder now shows:
  📬 2 (Alt+↑ to recall) · 🎯 1 (Alt+↓ to recall)

instead of just the counts.
The startup call to _update_terminal_title() fired before app.run(),
so get_app() raised RuntimeError and the os.write() fallback ran — but
iTerm2 then reset the tab title when prompt_toolkit took over the terminal,
leaving the tab showing 'hermes (Python)' with no ⚕ symbol.

Fix: remove the premature pre-app call and instead schedule
_update_terminal_title() via app.call_from_executor() at the start of
process_loop. This runs in the event loop after the TUI is live, so
get_app() returns the running app and write_raw() reaches the terminal
at the right moment.
get_app().output.write_raw() and os.write(sys.__stdout__.fileno())
both fail to reliably reach the terminal because prompt_toolkit's
output buffers and patch_stdout intercept or defer the write.

Fix: open the controlling terminal device via os.ctermid() (returns
'/dev/tty' on macOS/Linux) with O_WRONLY|O_NOCTTY and write directly.
This bypasses Python's I/O, prompt_toolkit's buffers, patch_stdout's
StdoutProxy, and any stdout redirections — bytes go straight to the
TTY the user is looking at, from any thread, at any time.
Debug test showed Test 5 (OSC 0 + ST terminator) is what iTerm2 accepts
for tab title updates. Previous code used OSC 1+2 with BEL (\x07).

Changes:
- OSC 0 instead of separate OSC 1 + OSC 2
- ST terminator (\x1b\\) instead of BEL (\x07)
- Write via sys.__stdout__ first (simpler), ctermid as fallback
Tab title format:
  ⚕ My Session   (when /title is set)
  ⚕ ⏳            (when agent is thinking)
  ⚕               (no title set)
Multiline user messages are always displayed truncated (first line +
'+N lines'). This adds a config option and runtime toggle.

- display.show_full_user_message: false (default) in config.yaml
- Ctrl+O toggles at runtime with '↕ Full user message display: ON/OFF'
  feedback — no restart required
- Status bar shows '↕ full msg' indicator when active
- To enable permanently: hermes config set display.show_full_user_message true
iRonin added 14 commits April 4, 2026 18:00
Lets Open WebUI (or any OpenAI-compatible frontend) select the
underlying LLM via the model field in the chat completions request.
When model_override is set and is not 'hermes-agent', it is used
instead of the value from config.yaml. GET /v1/models now returns
hermes-agent plus the per-provider model list.
browser_tool: read browser.cdp_url from config.yaml as a persistent
fallback for BROWSER_CDP_URL, so /browser connect is not needed every
session when cdp_url is set in config.

cli: when thinking and a session title exists, append ⏳ to the title
rather than replacing it — was: '⚕ ⏳', now: '⚕ My Title ⏳'.
All four response panel sites (streaming box, TTS box, background
task panel, main Rich panel) now append '— {title}' to the label
when a session title exists:

  ╭─ ⚕ Hermes — My Session Title ────────╮

Also: tab title appends ⏳ instead of replacing the session title
when thinking (was: '⚕ ⏳', now: '⚕ My Title ⏳').

delegate_tool: max_concurrent_children now reads from
delegation.max_concurrent_children in config.yaml (default 6).
Auto-restore after agent response now checks buf.text.strip() first.
If the user started typing while the agent was responding, the stash
is left intact with a 'Ctrl+S to pop' reminder instead of clobbering
their input.
/resume with no argument now calls show_sessions_full() which fetches
up to 200 sessions and pipes them through less (same mechanism as
Ctrl+P history pager) — scrollable, searchable with '/'.

Replaces the hardcoded 10-session inline table.
/resume with no arg now opens a mini prompt_toolkit Application:
- type to fuzzy-filter by label/preview/id in real-time
- ↑↓ navigate, Enter to select and auto-resume, Esc/q/Ctrl+C cancel
- sessions with no title show first user message as label instead of '—'
- up to 200 sessions listed
- falls back to less pager on non-interactive terminals
display.steering_dispatch: all_at_once | one_by_one (default)
display.followup_dispatch: one_by_one (default) | all_at_once

all_at_once: items held in the queue list only until the current
agent turn completes, then drained and joined with \n---\n into
a single combined message for the next turn.

one_by_one: existing behaviour — each queued item goes straight
into _pending_input and triggers its own agent turn.
- Rename 'Label' column header → 'Title' everywhere in the picker
  and fallback pager; rename _label() helper → _title()
- list_sessions_rich: ORDER BY last_active DESC instead of
  started_at DESC — most recently used sessions appear first
- Only CLI sessions shown (source=cli, excludes tool/gateway/cron)
Off by default — only CLI sessions shown.
Set to true to include gateway sessions (Telegram, Discord, etc.)
in the /resume picker. Tool sessions always excluded.
Lists sessions from the shared DB. Query params:
  source — filter by source (cli, telegram, discord, etc); omit for all
  limit  — max results (default 50, max 200)
  offset — pagination offset (default 0)

Returns: {object: list, data: [{id, title, preview, last_active,
source, message_count}], count: N}

Tool-spawned sessions always excluded.

Also fixes pre-existing syntax error in _handle_models (garbled auth
check line restored to self._check_auth(request)).
Live overlay showing running/completed subagents with progress.
Ctrl+X toggles panel, ↑↓ navigates rows, K interrupts selected agent.
Status bar shows 🔀 N badge when N subagents are running.

three-file change:
- hermes_cli/subagent_panel.py: SubagentRecord dataclass + render_panel()
- cli.py: panel state, Ctrl+X keybinding, status badge, TUI widget
- tools/delegate_tool.py: panel record lifecycle (spawn/progress/complete)
- Footer dashes: was W-2 (way too wide), now W-19 to match suffix length
- Header dashes: was W-len(title)-14, now W-len(title)-12
- goal_w: was W-12 (too wide), now W-20 (accounts for icon+index+elapsed)
- elapsed field: padded to 9 chars ('0:00 done' max) so row stays fixed-width
- ⚡ (2-wide emoji) replaced with ~ (1-wide) so column math holds
- Tool emoji replaced with ASCII symbols for same reason
- Status bar: '🔀 N Ctrl+X' when closed, '🔀 N ▲' when open
_find_skill() was calling get_all_skills_dirs() which always
returns the real ~/.hermes/skills/ as the first entry, ignoring
any unittest.mock.patch on the module-level SKILLS_DIR constant.

Tests patch SKILLS_DIR to a tmp_path, so _create_skill wrote
skills there but _find_skill never found them — causing every
subsequent edit/patch/delete/write_file/remove_file test to fail.

Fix: build search_dirs by replacing the first entry with the
module-level SKILLS_DIR (patchable) and keeping external dirs
from config (indices 1+) unchanged. In production the two are
identical, so no behaviour change.
@trevorgordon981

Copy link
Copy Markdown
Contributor

The core fix (5 lines in tools/skill_manager_tool.py) is correct. get_all_skills_dirs()[0] bypasses mock.patch on the module-level SKILLS_DIR constant, and [SKILLS_DIR] + list(all_dirs[1:]) respects both the patch and external skill dirs. However, the diff is 3969 lines across 10 files, including 2397 lines of package-lock.json and +1152 in cli.py that are unrelated to the stated fix. This is mis-scoped. Recommend rebasing onto a clean branch or splitting the unrelated changes out before merge. Also overlaps with #5284 (malaiwah, same-area fix, different approach), so one should be closed in favor of the other. Blocker on scope, not on the core fix.

@iRonin

iRonin commented Apr 5, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #5317 — rebuilt from clean worktree (origin/main only, single file).

@iRonin iRonin closed this Apr 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants