mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
* feat(codex-runtime): scaffold optional codex app-server runtime
Foundational commit for an opt-in alternate runtime that hands OpenAI/Codex
turns to a 'codex app-server' subprocess instead of Hermes' tool dispatch.
Default behavior is unchanged.
Lands in three pieces:
1. agent/transports/codex_app_server.py — JSON-RPC 2.0 over stdio speaker
for codex's app-server protocol (codex-rs/app-server). Spawn, init
handshake, request/response, notification queue, server-initiated
request queue (for approval round-trips), interrupt-friendly blocking
reads. Tested against real codex 0.130.0 binary end-to-end during
development.
2. hermes_cli/runtime_provider.py:
- Adds 'codex_app_server' to _VALID_API_MODES.
- Adds _maybe_apply_codex_app_server_runtime() helper, called at the
end of _resolve_runtime_from_pool_entry(). Inert unless
'model.openai_runtime: codex_app_server' is set in config.yaml AND
provider in {openai, openai-codex}. Other providers cannot be
rerouted (anthropic, openrouter, etc. preserved).
3. tests/agent/transports/test_codex_app_server_runtime.py — 24 tests
covering api_mode registration, the rewriter helper (default-off,
case-insensitive, opt-in, non-eligible providers preserved), version
parser, missing-binary handling, error class. Does NOT require codex
CLI installed.
This commit is wire-only: the api_mode is recognized but AIAgent does
not yet branch on it. Followup commits add the session adapter, event
projector, approval bridge, transcript projection (so memory/skill
review still works), plugin migration, and slash command.
Existing tests remain green:
- tests/cli/test_cli_provider_resolution.py (29 passed)
- tests/agent/test_credential_pool_routing.py (included above)
* feat(codex-runtime): add codex item projector for memory/skill review
The translator that lets Hermes' self-improvement loop keep working under the
Codex runtime: converts codex 'item/*' notifications into Hermes' standard
{role, content, tool_calls, tool_call_id} message shape that
agent/curator.py already knows how to read.
Item taxonomy (matches codex-rs/app-server-protocol/src/protocol/v2/item.rs):
- userMessage → {role: user, content}
- agentMessage → {role: assistant, content: text}
- reasoning → stashed in next assistant's 'reasoning' field
- commandExecution → assistant tool_call(name='exec_command') + tool result
- fileChange → assistant tool_call(name='apply_patch') + tool result
- mcpToolCall → assistant tool_call(name='mcp.<server>.<tool>') + tool result
- dynamicToolCall → assistant tool_call(name=<tool>) + tool result
- plan/hookPrompt/etc → opaque assistant note, no fabricated tool_calls
Invariants preserved:
- Message role alternation never violated: each tool item produces at most
one assistant + one tool message in that order, correlated by call_id.
- Streaming deltas (item/<type>/outputDelta, item/agentMessage/delta)
don't materialize messages — only item/completed does. Mirrors how
Hermes already only writes the assistant message after streaming ends.
- Tool call ids are deterministic (codex item id-based) so replays produce
identical messages and prefix caches stay valid (AGENTS.md pitfall #16).
- JSON args use sorted_keys for the same reason.
Real wire formats verified against codex 0.130.0 by capturing live
notifications from thread/shellCommand and including one as a fixture
(COMMAND_EXEC_COMPLETED).
23 new tests, all green:
- Streaming deltas don't materialize (3 paths)
- Turn/thread frame events are silent
- commandExecution: 5 tests including non-zero exit annotation +
deterministic id stability across replays
- agentMessage + reasoning attachment + reasoning consumption
- fileChange: summary without inlined content
- mcpToolCall: namespaced naming + error surfacing
- userMessage: text fragments only (drops images/etc)
- opaque items: no fabricated tool_calls
- Helpers: deterministic id stability + sorted JSON args
- Role alternation invariant across all four tool-shaped item types
This commit is a pure addition. AIAgent integration (the wire that uses the
projector) is the next commit.
* feat(codex-runtime): add session adapter + approval bridge
The third self-contained module: CodexAppServerSession owns one Codex
thread per Hermes session, drives turn/start, consumes streaming
notifications via CodexEventProjector, handles server-initiated approval
requests, and translates cancellation into turn/interrupt.
The adapter has a single public per-turn method:
result = session.run_turn(user_input='...', turn_timeout=600)
# result.final_text → assistant text for the caller
# result.projected_messages → list ready to splice into AIAgent.messages
# result.tool_iterations → tick count for _iters_since_skill nudge
# result.interrupted → True on Ctrl+C / deadline / interrupt
# result.error → error string when the turn cannot complete
# result.turn_id, thread_id → for sessions DB / resume
Behavior:
- ensure_started() spawns codex, does the initialize handshake, and
issues thread/start with cwd + permissions profile. Idempotent.
- run_turn() blocks until turn/completed, drains server-initiated
requests (approvals) before reading notifications so codex never
deadlocks waiting for us, projects every item/completed via the
projector, and increments tool_iterations for the skill nudge gate.
- request_interrupt() is thread-safe (threading.Event); the next loop
iteration issues turn/interrupt and unwinds.
- turn_timeout deadlock guard issues turn/interrupt and records an
error if the turn never completes.
- close() escalates terminate → kill via the underlying client.
Approval bridge:
Codex emits server-initiated requests for execCommandApproval and
applyPatchApproval. The adapter translates Hermes' approval choice
vocabulary onto codex's decision vocabulary:
Hermes 'once' → codex 'approved'
Hermes 'session' or 'always' → codex 'approvedForSession'
Hermes 'deny' / anything else → codex 'denied'
Routing precedence:
1. _ServerRequestRouting.auto_approve_* flags (cron / non-interactive)
2. approval_callback wired by the CLI (defers to
tools.approval.prompt_dangerous_approval())
3. Fail-closed denial when neither is wired
Unknown server-request methods are answered with JSON-RPC error -32601
so codex doesn't hang waiting for us.
Permission profile mapping mirrors AGENTS.md:
Hermes 'auto' → codex 'workspace-write'
Hermes 'approval-required' → codex 'read-only-with-approval'
Hermes 'unrestricted/yolo' → codex 'full-access'
20 new tests, all green. Combined with prior commits this PR now has
67 tests across three modules:
- test_codex_app_server_runtime.py: 24 (api_mode + transport surface)
- test_codex_event_projector.py: 23 (item taxonomy projections)
- test_codex_app_server_session.py: 20 (turn loop + approvals + interrupts)
Full tests/agent/transports/ directory: 249/249 pass — no regressions
to existing transport tests.
Still no wire into AIAgent.run_conversation(); that integration commit
is small and goes next.
* feat(codex-runtime): wire codex_app_server runtime into AIAgent
The integration commit. AIAgent.run_conversation() now early-returns to a
new helper _run_codex_app_server_turn() when self.api_mode ==
'codex_app_server', bypassing the chat_completions tool loop entirely.
Three small surgical edits to run_agent.py (~105 LOC total):
1. Line ~1204 (constructor api_mode validation set):
Add 'codex_app_server' so an explicit api_mode='codex_app_server'
passed to AIAgent() isn't silently rewritten to 'chat_completions'.
2. Line ~12048 (run_conversation, just before the while loop):
Early-return to _run_codex_app_server_turn() when self.api_mode is
'codex_app_server'. Placed AFTER all standard pre-loop setup —
logging context, session DB, surrogate sanitization, _user_turn_count
and _turns_since_memory increments, _ext_prefetch_cache, memory
manager on_turn_start — so behavior outside the model-call loop is
identical between paths. Default Hermes flow is unchanged when the
flag is off.
3. End-of-class (line ~15497):
New method _run_codex_app_server_turn(). Lazy-instantiates one
CodexAppServerSession per AIAgent (reused across turns), runs the
turn, splices projected_messages into messages, increments
_iters_since_skill by tool_iterations (since the chat_completions
loop normally does that per iteration), fires
_spawn_background_review on the same cadence as the default path.
Counter accounting:
_turns_since_memory ← already incremented at run_conversation:11817
(gated on memory store configured) — codex
helper does NOT touch it (would double-count).
_user_turn_count ← already incremented at run_conversation:11793
— codex helper does NOT touch it.
_iters_since_skill ← incremented in the chat_completions loop per
tool iteration. Codex helper increments by
turn.tool_iterations since the loop is bypassed.
User message:
ALREADY appended to messages by run_conversation pre-loop (line 11823)
before the early-return reaches us. Helper does NOT append again.
Regression test test_user_message_not_duplicated guards this.
Approval callback wiring:
Lazy-fetches tools.terminal_tool._get_approval_callback at session
spawn time, passes to CodexAppServerSession. CLI threads with
prompt_toolkit get interactive approvals; gateway/cron contexts get
the codex-side fail-closed deny.
Error path:
Codex session exceptions become a 'partial' result with completed=False
and a final_response that explicitly tells the user how to switch back:
'Codex app-server turn failed: ... Fall back to default runtime with
/codex-runtime auto.' Same return-dict shape as the chat_completions
path so all callers (gateway, CLI, batch_runner, ACP) work unchanged.
9 new integration tests in tests/run_agent/test_codex_app_server_integration.py:
- api_mode='codex_app_server' is accepted on AIAgent construction
- run_conversation returns the expected codex shape
(final_response, codex_thread_id, codex_turn_id, completed, partial)
- Projected messages are spliced into messages list
- _iters_since_skill ticks per tool iteration
- _user_turn_count delegated to standard flow (not double-counted)
- User message appears exactly once (regression guard)
- _spawn_background_review IS invoked (memory/skill review keeps working)
- chat.completions.create is NEVER called (loop fully bypassed)
- Session exception → partial result with /codex-runtime auto hint
- Interrupted turn → partial result with error preserved
Adjacent test runs confirm no regressions:
- tests/run_agent/test_memory_nudge_counter_hydration.py: green
- tests/run_agent/test_background_review.py: green
- tests/run_agent/test_fallback_model.py: green
- tests/agent/transports/: 249/249 green
Still missing for full feature: /codex-runtime slash command, plugin
migration helper, docs page, live e2e test gated on codex binary. Those
are the remaining followup commits.
* feat(codex-runtime): add /codex-runtime slash command (CLI + gateway)
User-facing toggle for the optional codex app-server runtime. Follows the
'Adding a Slash Command (All Platforms)' pattern from AGENTS.md exactly:
single CommandDef in the central registry → CLI handler → gateway handler
→ running-agent guard → all surfaces (autocomplete, /help, Telegram menu,
Slack subcommands) update automatically.
Surface:
/codex-runtime — show current state + codex CLI status
/codex-runtime auto — Hermes default runtime
/codex-runtime codex_app_server — codex subprocess runtime
/codex-runtime on / off — synonyms
Files changed:
hermes_cli/codex_runtime_switch.py (new):
Pure-Python state machine shared by CLI and gateway. Parse args,
read/write model.openai_runtime in the config dict, gate enabling
behind a codex --version check (don't let users opt in to a runtime
they have no binary for; print npm install hint instead).
Returns a CodexRuntimeStatus dataclass that callers render however
suits their surface.
hermes_cli/commands.py:
Single CommandDef entry, no aliases (codex-runtime is its own thing).
cli.py:
Dispatch in process_command() + _handle_codex_runtime() handler that
delegates to the shared module and renders results via _cprint.
gateway/run.py:
Dispatch in _handle_message() + _handle_codex_runtime_command() that
returns a string (gateway sends as message). On a successful change
that requires a new session, _evict_cached_agent() forces the next
inbound message to construct a fresh AIAgent with the new api_mode —
avoids prompt-cache invalidation mid-session.
gateway/run.py running-agent guard:
/codex-runtime joins /model in the early-intercept block so a runtime
flip mid-turn can't split a turn across two transports.
Tests:
tests/hermes_cli/test_codex_runtime_switch.py — 25 tests covering the
state machine: arg parsing (10 cases incl. case-insensitive and
synonyms), reading current runtime (5 cases incl. malformed configs),
writing runtime (3 cases), apply() entry point covering read-only,
no-op, codex-missing-blocked, codex-present-success, disable-no-binary-check,
and persist-failure paths (8 cases). All green.
Adjacent test suites confirm no regressions:
- tests/hermes_cli/test_commands.py + test_codex_runtime_switch.py:
167/167 green
- tests/agent/transports/: 283/283 green when combined with prior commits
Still missing: plugin migration helper, docs page, live e2e test gated on
codex binary. Followup commits.
* feat(codex-runtime): auto-migrate Hermes MCP servers to ~/.codex/config.toml
Translates the user's mcp_servers config from ~/.hermes/config.yaml into
the TOML format codex's MCP client expects. Wired into the
/codex-runtime codex_app_server enable path so users get their MCP tool
surface in the spawned subprocess automatically.
The migration runs on every enable. Failures are non-fatal — the runtime
change still proceeds and the user gets a warning so they can fix the
codex config manually.
What translates (mapping verified against codex-rs/core/src/config/edit.rs):
Hermes mcp_servers.<n>.command/args/env → codex stdio transport
Hermes mcp_servers.<n>.url/headers → codex streamable_http transport
Hermes mcp_servers.<n>.timeout → codex tool_timeout_sec
Hermes mcp_servers.<n>.connect_timeout → codex startup_timeout_sec
Hermes mcp_servers.<n>.cwd → codex stdio cwd
Hermes mcp_servers.<n>.enabled: false → codex enabled = false
What does NOT translate (warned + skipped per server):
Hermes-specific keys (sampling, etc.) — codex's MCP client has no
equivalent. Listed in the per-server skipped[] field of the report.
What's NOT migrated (intentional):
AGENTS.md — codex respects this file natively in its cwd. Hermes' own
AGENTS.md (project-level) is already in the worktree, so codex picks
it up without translation. No code needed.
Idempotency design:
All managed content lives between a 'managed by hermes-agent' marker
and the next non-mcp_servers section header. _strip_existing_managed_block
removes the prior managed region cleanly, preserving any user-added
codex config (model, providers.openai, sandbox profiles, etc.) above
or below.
Files added:
hermes_cli/codex_runtime_plugin_migration.py — pure-Python migration
helper. Public API: migrate(hermes_config, codex_home=None,
dry_run=False) returns MigrationReport with .migrated/.errors/
.skipped_keys_per_server. No external TOML dependency — minimal
formatter handles strings/numbers/booleans/lists/inline-tables.
tests/hermes_cli/test_codex_runtime_plugin_migration.py — 39 tests
covering:
- per-server translation (12): stdio/http/sse, cwd, timeouts,
enabled flag, command+url precedence, sampling drop, unknown keys
- TOML formatter (8): types, escaping, inline tables, error case
- existing-block stripping (4): no marker, alone, with user content
above, with user content below
- end-to-end migrate() (8): empty, dry-run, round-trip, idempotent
re-run, preserves user config, error reporting, invalid input,
summary formatting
Files changed:
hermes_cli/codex_runtime_switch.py — apply() now calls migrate() in
the codex_app_server enable branch. Migration failure logs a warning
in the result message but does NOT fail the runtime change. Disable
path (auto) explicitly skips migration.
tests/hermes_cli/test_codex_runtime_switch.py — 3 new tests:
test_enable_triggers_mcp_migration, test_disable_does_not_trigger_migration,
test_migration_failure_does_not_block_enable.
All 325 feature tests green:
- tests/agent/transports/: 249 (incl. 67 new)
- tests/run_agent/test_codex_app_server_integration.py: 9
- tests/hermes_cli/test_codex_runtime_switch.py: 28 (3 new)
- tests/hermes_cli/test_codex_runtime_plugin_migration.py: 39 (new)
* perf(codex-runtime): cache codex --version check within apply()
Single /codex-runtime invocation could spawn 'codex --version' up to 3
times (state report, enable gate, success message). Each spawn is ~50ms,
so the cumulative cost wasn't a crisis, but it was wasteful and turned a
trivial slash command into something noticeably laggy on slower systems.
Refactored to lazy-once via a closure over a nonlocal cache. First call
spawns; subsequent calls in the same apply() reuse the result.
Behavior unchanged — same return shape, same error handling, same install
hint when codex is missing. Just one subprocess per call instead of three.
Two regression-guard tests added:
- test_binary_check_cached_within_apply: enable path → call_count == 1
- test_binary_check_cached_on_read_only_call: state-report path → call_count == 1
Total tests for /codex-runtime now 30 (was 28); all 143 codex-runtime
tests still green.
* fix(codex-runtime): correct protocol field names found via live e2e test
Three real bugs caught only by running a turn end-to-end against codex
0.130.0 with a real ChatGPT subscription. Unit tests passed because they
asserted on our own (incorrect) wire shapes; the wire format from
codex-rs/app-server-protocol/src/protocol/v2/* is the source of truth and
my initial reading of the README was incomplete.
Bug 1: thread/start.permissions wire format
Was sending {"profileId": "workspace-write"}.
Real format per PermissionProfileSelectionParams enum (tagged union):
{"type": "profile", "id": "workspace-write"}
AND requires the experimentalApi capability declared during initialize.
AND requires a matching [permissions] table in ~/.codex/config.toml or
codex fails the request with 'default_permissions requires a [permissions]
table'.
Fix: stop overriding permissions on thread/start. Codex picks its default
profile (read-only unless user configures otherwise), which matches what
codex CLI users expect — they configure their default permission profile
in ~/.codex/config.toml the standard way. Trying to be clever about
profile selection broke every turn we tested.
Live error before fix: 'Invalid request: missing field type' on every
turn/start, even though our turn/start payload was correct — the field
codex was complaining about was inside the permissions sub-object we
shouldn't have been sending.
Bug 2: server-request method names
Was matching 'execCommandApproval' and 'applyPatchApproval'.
Real names per common.rs ServerRequest enum:
item/commandExecution/requestApproval
item/fileChange/requestApproval
item/permissions/requestApproval (new third method)
Fix: match the documented names. Added handler for
item/permissions/requestApproval that always declines — codex sometimes
asks to escalate permissions mid-turn and silent acceptance would surprise
users.
Live symptom before fix: agent.log showed
'Unknown codex server request: item/commandExecution/requestApproval'
and codex stalled because we replied with -32601 (unsupported method)
instead of an approval decision. The agent reported back 'The write
command was rejected' even though Hermes never showed the user an
approval prompt.
Bug 3: approval decision values
Was sending decision strings 'approved'/'approvedForSession'/'denied'.
Real values per CommandExecutionApprovalDecision enum (camelCase):
accept, acceptForSession, decline, cancel
(also AcceptWithExecpolicyAmendment and ApplyNetworkPolicyAmendment
variants we don't currently use).
Fix: rename _approval_choice_to_codex_decision return values; update
auto_approve_* fallbacks; update fail-closed default from 'denied' to
'decline'. Test mapping table updated to match.
Live test verified after fixes:
$ hermes (with model.openai_runtime: codex_app_server)
> Run the shell command: echo hermes-codex-livetest > .../proof.txt
then read it back
Approval prompt fired with 'Codex requests exec in <cwd>'.
User chose 'Allow once'. Codex executed the command, wrote the file,
read it back. Final response: 'Read back from proof.txt:
hermes-codex-livetest'. File contents on disk match.
agent.log confirms:
codex app-server thread started: id=019e200e profile=workspace-write
cwd=/tmp/hermes-codex-livetest/workspace
All 20 session tests still green after wire-format updates.
* fix(codex-runtime): correct apply_patch approval params + ship docs
Live e2e revealed FileChangeRequestApprovalParams doesn't carry the
changeset (just itemId, threadId, turnId, reason, grantRoot) — Codex's
'reason' field describes what the patch wants to do. Test config and
display logic updated to use it. The first 'apply_patch (0 change(s))'
display from the live test is now 'apply_patch: <reason>'.
Adds website/docs/user-guide/features/codex-app-server-runtime.md
covering enable/disable, prerequisites, approval UX, MCP migration
behavior, permission profile delegation to ~/.codex/config.toml, known
limitations, and the architecture diagram. Wired into the Automation
category in sidebars.ts.
Live e2e validation across the path matrix:
✓ thread/start handshake
✓ turn/start with text input
✓ commandExecution items + projection
✓ item/commandExecution/requestApproval → Hermes UI → response
✓ Approve once → command runs
✓ Deny → command rejected, codex falls back to read-only message
✓ Multi-turn (codex remembers prior turn's results)
✓ apply_patch via Codex's fileChange path
✓ item/fileChange/requestApproval → Hermes UI
✓ MCP server migration loads inside spawned codex (verified via
'use the filesystem MCP tool' prompt)
✓ /codex-runtime auto → codex_app_server toggle cycle
✓ Disable doesn't trigger migration
✓ Enable with codex CLI present succeeds + migrates
✓ Hermes-side interrupt path (turn/interrupt request issued cleanly
even if codex finishes before the interrupt lands)
Known live-validated limitations now documented in the docs page:
- delegate_task subagents unavailable on this runtime
- permission profile selection delegated to ~/.codex/config.toml
- apply_patch approval prompt has no inline changeset (codex protocol
doesn't expose it)
145/145 codex-runtime tests still green.
* feat(codex-runtime): native plugin migration + UX polish (quirks 2/4/5/10/11)
Major: migrate native Codex plugins (#7 in OpenClaw's PR list)
Discovers installed curated plugins via codex's plugin/list RPC and
writes [plugins."<name>@<marketplace>"] entries to ~/.codex/config.toml
so they're enabled in the spawned Codex sessions. This is the
'YouTube-video-worthy' bit Pash highlighted: when a user has
google-calendar, github, etc. installed in their Codex CLI, those
plugins activate automatically when they enable Hermes' codex runtime.
Implementation:
- hermes_cli/codex_runtime_plugin_migration.py: new _query_codex_plugins()
helper spawns 'codex app-server' briefly and walks plugin/list. Returns
(plugins, error) — failures are non-fatal so MCP migration still works.
- render_codex_toml_section() now takes plugins + permissions args.
- migrate() defaults: discover_plugins=True, default_permission_profile=
'workspace-write'. Explicit None on either disables that side.
- _strip_existing_managed_block() now also strips [plugins.*] and
[permissions]/[permissions.*] sections inside the managed block, so
re-runs replace plugins cleanly without touching codex's own config.
Quirk fixes:
#2 Default permissions profile written on enable.
Without this, Codex's read-only default kicks in and EVERY write
triggers an approval prompt. Now writes [permissions] default =
'workspace-write' so the runtime feels normal out of the box. Set
default_permission_profile=None to opt out.
#4 apply_patch approval prompt now shows what's changing.
Codex's FileChangeRequestApprovalParams doesn't carry the changeset.
Session adapter now caches the fileChange item from item/started
notifications and looks it up by itemId when codex requests approval.
Prompt shows '1 add, 1 update: /tmp/new.py, /tmp/old.py' instead of
'apply_patch (0 change(s))'.
Side benefit: also drains pending notifications BEFORE handling a
server request, so the projector and per-turn caches are up to date
when the approval decision fires. Bounded to 8 notifications per
loop iter to avoid starving codex's response.
#5/#10 Exec approval prompt never shows empty cwd.
When codex omits cwd in CommandExecutionRequestApprovalParams, fall
back to the session's cwd. If somehow neither is available, show
'<unknown>' explicitly instead of an empty string.
Also surfaces 'reason' from the approval params when codex provides
it — gives users more context on why codex wants to run something.
#11 Banner indicates the codex_app_server runtime when active.
New 'Runtime: codex app-server (terminal/file ops/MCP run inside
codex)' line appears in the welcome banner only when the runtime is
on. Default banner is unchanged.
Tests:
- 7 new tests in test_codex_runtime_plugin_migration.py covering
plugin discovery (mocked), failure handling, dry-run skip, opt-out
flag, idempotent re-runs, and permissions writing.
- 3 new tests in test_codex_app_server_session.py covering the
enriched approval prompts: cwd fallback, change summary on
apply_patch, fallback when no item/started cache exists.
- All 26 session tests + 46 migration tests green; 153 total in PR.
* feat(codex-runtime): hermes-tools MCP callback + native plugin migration
The big architectural addition: when codex_app_server runtime is on,
Hermes registers its own tool surface as an MCP server in
~/.codex/config.toml so the codex subprocess can call back into Hermes
for tools codex doesn't ship with — web_search, browser_*, vision,
image_generate, skills, TTS.
Also: 'migrate native codex plugins' (Pash's YouTube-video-worthy bit) —
when the user has plugins like Linear, GitHub, Gmail, Calendar, Canva
installed via 'codex plugin', Hermes discovers them via plugin/list and
writes [plugins.<name>@openai-curated] entries so they activate
automatically.
New module: agent/transports/hermes_tools_mcp_server.py
FastMCP stdio server exposing 17 Hermes tools. Each call dispatches
through model_tools.handle_function_call() — same code path as the
Hermes default runtime. Run with:
python -m agent.transports.hermes_tools_mcp_server [--verbose]
Exposed: web_search, web_extract, browser_navigate / _click / _type /
_press / _snapshot / _scroll / _back / _get_images / _console /
_vision, vision_analyze, image_generate, skill_view, skills_list,
text_to_speech.
NOT exposed (deliberately):
- terminal/shell/read_file/write_file/patch — codex has built-ins
- delegate_task/memory/session_search/todo — _AGENT_LOOP_TOOLS in
model_tools.py:493, require running AIAgent context. Documented
as a limitation and surfaced in the slash command output.
Migration changes (hermes_cli/codex_runtime_plugin_migration.py):
- _query_codex_plugins() spawns 'codex app-server' briefly to walk
plugin/list and pull installed openai-curated plugins. Failures are
non-fatal — MCP migration still completes.
- render_codex_toml_section() now takes plugins + permissions args
AND wraps the managed block with a MIGRATION_END_MARKER comment so
the stripper can reliably find both ends, even when the block
contains top-level keys (default_permissions = ...).
- migrate() defaults: discover_plugins=True, expose_hermes_tools=True,
default_permission_profile=':workspace' (built-in codex profile name
— must be prefixed with ':'). All three opt-out via explicit args.
- _build_hermes_tools_mcp_entry() builds the codex stdio entry with
HERMES_HOME and PYTHONPATH passthrough so a worktree-launched
Hermes points the MCP subprocess at the same module layout.
Live-caught wire bugs fixed during this turn:
1. Permission profile config key is top-level , NOT a [permissions] table. The [permissions] table is
for *user-defined* profiles with structured fields. Built-in
profile names start with ':' (':workspace', ':read-only',
':danger-no-sandbox'). Was emitting
which codex rejected with 'invalid type: string "X", expected
struct PermissionProfileToml'.
2. Built-in profile is , NOT . Codex
rejected with 'unknown built-in profile'.
3. Codex's MCP layer sends for
tool-call confirmation. We weren't handling it, so codex stalled
and returned 'MCP tool call was rejected'. Now: auto-accept for
our own hermes-tools server (user already opted in by enabling
the runtime), decline for third-party servers.
Quirk fixes shipped (from the limitations list):
#2 default permissions: workspace profile written on enable. No more
approval prompt on every write.
#4 apply_patch approval shows what's changing: cache fileChange
items from item/started, look up by itemId when codex sends
item/fileChange/requestApproval. Prompt: '1 add, 1 update:
/tmp/new.py, /tmp/old.py' instead of '0 change(s)'.
#5/#10 exec approval cwd never empty: fall back to session cwd, then
'<unknown>'. Also surfaces 'reason' from codex when present.
#11 banner shows 'Runtime: codex app-server' line when active so
users understand why tool counts may not match what's reachable.
Tests:
- 5 new tests in test_codex_runtime_plugin_migration.py covering
plugin discovery, expose_hermes_tools entry generation, idempotent
re-runs, opt-out flag, permissions profile.
- 3 new tests in test_codex_app_server_session.py covering enriched
approval prompts (cwd fallback, fileChange summary).
- 2 new tests for mcpServer/elicitation/request handling (accept
hermes-tools, decline others).
- New test file test_hermes_tools_mcp_server.py covering module
surface, EXPOSED_TOOLS safety invariants (no shell/file_ops,
no agent-loop tools), and main() error paths.
- 166 codex-runtime tests total, all green.
Live e2e validated against codex 0.130.0 + ChatGPT subscription:
✓ /codex-runtime codex_app_server enables, migrates filesystem MCP,
registers hermes-tools, writes default_permissions = ':workspace'
✓ Banner shows 'Runtime: codex app-server' line in subsequent sessions
✓ Shell command runs without approval prompt (workspace profile works)
✓ Multi-turn — codex remembers prior turn's results
✓ apply_patch path via fileChange request approval
✓ web_search via hermes-tools MCP callback returns real Firecrawl
results: 'OpenAI Codex CLI – Getting Started' end-to-end in 13s
✓ Disable cycle clean
Docs updated: website/docs/user-guide/features/codex-app-server-runtime.md
Full re-write covering native plugin migration, the hermes-tools
callback architecture, the prerequisites change ('codex login is
separate from hermes auth login codex'), the trade-off table now
reflecting which Hermes tools work via callback, and the limitations
list updated with what's actually unavailable on this runtime.
* feat(codex-runtime): pin user-config preservation invariant for quirk #6
Quirk #6 from the limitations list — user MCP servers / overrides /
codex-only sections in ~/.codex/config.toml that live OUTSIDE the
hermes-managed block must survive re-migration verbatim.
This already worked thanks to the MIGRATION_MARKER + MIGRATION_END_MARKER
pair I added when fixing the default_permissions wire format (so the
strip can find both ends of the managed region even with top-level
keys like default_permissions). But it was an emergent property
without a test pinning it.
Now explicitly tested:
- User MCP server above the managed block survives migration
- User MCP server below the managed block survives migration
- Both above + below survive a second re-migration
- User content (model, providers, sandbox, otel, etc.) outside our
region is left untouched
Docs added a section "Editing ~/.codex/config.toml safely" explaining
the marker contract — so users know they can add their own MCP
servers, override permissions, configure codex-only options, etc.
without fear of Hermes overwriting their work.
167 codex-runtime tests, all green.
* docs(codex-runtime): clarify the actual tool surface — shell covers terminal/read/write/find
Previous docs and PR description undersold what codex's built-in
toolset actually provides. apply_patch alone made it sound like the
runtime could only edit files in patch format — implying you'd lose
terminal use, read_file, write_file, search/find. That was wrong.
Codex's 'shell' tool runs arbitrary shell commands inside the sandbox,
which covers everything you'd do in bash: cat/head/tail (read), echo>
or heredocs (write), find/rg/grep (search), ls/cd (navigate), build/
test/git/etc. apply_patch is for structured multi-file edits on top
of that. update_plan is its in-runtime todo. view_image loads images.
And codex has its own web_search built in (in addition to the
Firecrawl-backed one Hermes exposes via MCP callback).
Docs now have a 'What tools the model actually has' section right
after Why, breaking the surface into three clearly-labeled buckets:
1. Codex's built-in toolset (always on) — shell, apply_patch,
update_plan, view_image, web_search; covers everything terminal-
adjacent.
2. Native Codex plugins (auto-migrated from your codex plugin
install) — Linear, GitHub, Gmail, Calendar, Outlook, Canva, etc.
3. Hermes tool callback (MCP server in ~/.codex/config.toml) —
web_search/web_extract via Firecrawl, browser_*, vision_analyze,
image_generate, skill_view/skills_list, text_to_speech.
Plus a 'What's NOT available' callout listing the four agent-loop tools
(delegate_task, memory, session_search, todo) that need running
AIAgent context and can't reach the codex runtime.
Trade-offs table broken out: shell, apply_patch, update_plan,
view_image, sandbox each get their own row with a one-line description
so users can see at a glance what's available natively.
Architecture diagram updated to list the codex built-ins by name
instead of 'apply_patch + shell + sandbox'.
No code changes — purely docs clarification. 167 codex-runtime tests
still green.
* fix(codex-runtime): _spawn_background_review signature + review fork api_mode downgrade
Two real bugs in the self-improvement loop integration that the previous
test mocked away.
Bug 1: wrong call signature
The codex helper was calling self._spawn_background_review() with no
args after every turn. That function actually requires:
messages_snapshot=list (positional or keyword)
review_memory=bool (at least one trigger must be True)
review_skills=bool
So the call would have raised TypeError at runtime — except the only
test that exercised this path mocked _spawn_background_review entirely
and just asserted spawn.called, so the wrong-arg shape never surfaced.
Bug 2: review fork inherits codex_app_server api_mode
The review fork is constructed with:
api_mode = _parent_runtime.get('api_mode')
So when the parent is codex_app_server, the review fork ALSO runs as
codex_app_server. But the review fork's whole job is to call agent-loop
tools (memory, skill_manage) which require Hermes' own dispatch — they
short-circuit with 'must be handled by the agent loop' on the codex
runtime. So the review fork would have run, decided to save something,
called memory or skill_manage, and silently no-op'd.
Fixed in run_agent.py:_spawn_background_review() — when the parent
api_mode is 'codex_app_server', the review fork is downgraded to
'codex_responses' (same OAuth credentials, same openai-codex provider,
but talks to OpenAI's Responses API directly so Hermes owns the loop).
Also rewrote the codex helper's review wiring to match the
chat_completions path:
- Computes _should_review_memory in the pre-loop block (was already
being computed; now passed through to the helper as an arg).
- Computes _should_review_skills AFTER the codex turn returns +
counters tick (line ~15432 pattern in chat_completions).
- Calls _spawn_background_review(messages_snapshot=, review_memory=,
review_skills=) only when at least one trigger fires.
- Adds the external memory provider sync (_sync_external_memory_for_turn)
that the chat_completions path runs after every turn.
Tests:
Replaced the broken test_background_review_invoked (which only
asserted spawn.called) with three sharper tests:
- test_background_review_NOT_invoked_below_threshold:
single turn at default thresholds → no review fires (would have
caught the original 'every turn calls spawn with no args' bug)
- test_background_review_skill_trigger_fires_above_threshold:
10 tool_iterations at threshold=10 → review fires with
messages_snapshot=list, review_skills=True, counter resets
- test_background_review_signature_never_breaks: regression guard
asserting positional args are always empty and kwargs include
messages_snapshot
New TestReviewForkApiModeDowngrade class:
- test_codex_app_server_parent_downgrades_review_fork: drives the
real _spawn_background_review function (no mock at that level),
asserts the review_agent gets api_mode='codex_responses' when
the parent was codex_app_server.
Live-validated against real run_conversation:
- Counter ticked from 0 to 5 after a 5-tool-iteration turn
- _spawn_background_review fired exactly once with kwargs-only signature
- review_skills=True, review_memory=False
- messages_snapshot was 12 entries (5 assistant tool_calls + 5 tool
results + 1 final assistant + initial system/user)
- Counter reset to 0 after fire
170 codex-runtime tests, all green.
Docs: added a Self-improvement loop section to the codex runtime page
explaining both how the trigger logic stays equivalent and that the
review fork is auto-downgraded to codex_responses for the agent-loop
tools. Also clarified that apply_patch and update_plan ARE codex's
built-in tools (the previous version made it sound like they were
separate from 'codex's stuff' — they're not, all five tools listed
in 'What tools the model actually has' section 1 are codex built-ins).
* feat(codex-runtime): expose kanban tools through Hermes MCP callback
Kanban workers spawn as separate hermes chat -q subprocesses that read
the user's config.yaml. If model.openai_runtime: codex_app_server is set
globally (which is the whole point of opt-in), every dispatched worker
ALSO comes up on the codex runtime.
That mostly works — codex's built-in shell + apply_patch + update_plan
do the actual task work fine — but it had one critical break: the
worker handoff tools (kanban_complete, kanban_block, kanban_comment,
kanban_heartbeat) are Hermes-registered tools, not codex built-ins.
On the codex runtime, codex builds its own tool list and these never
reach the model, so the worker would do the work but not be able to
report back, hanging until the dispatcher's timeout escalates it as
zombie.
Fix: add all 9 kanban tools to the EXPOSED_TOOLS list in the Hermes
MCP callback. They dispatch statelessly through handle_function_call()
just like web_search and the others — they read HERMES_KANBAN_TASK
from env (set by the dispatcher), gate correctly (worker tools require
the env var, orchestrator tools require it unset), and write to
~/.hermes/kanban.db.
Why kanban tools work via stateless dispatch when delegate_task/memory/
session_search/todo don't: those four are listed in _AGENT_LOOP_TOOLS
(model_tools.py:493) and short-circuit in handle_function_call() with
'must be handled by the agent loop' — they need to mutate AIAgent's
mid-loop state. Kanban tools have no such requirement; they're pure
side-effect functions against the kanban.db plus state_meta.
Tools exposed:
Worker handoff (require HERMES_KANBAN_TASK):
kanban_complete, kanban_block, kanban_comment, kanban_heartbeat
Read-only board queries:
kanban_show, kanban_list
Orchestrator (require HERMES_KANBAN_TASK unset):
kanban_create, kanban_unblock, kanban_link
Tests:
- test_kanban_worker_tools_exposed: complete/block/comment/heartbeat
in EXPOSED_TOOLS (regression guard for the would-hang-worker bug)
- test_kanban_orchestrator_tools_exposed: create/show/list/unblock/link
Docs:
- New 'Workflow features' section in the docs page covering /goal,
kanban, and cron behavior on this runtime
- /goal: works fully via run_conversation feedback; only caveat is
approval-prompt noise on long writes-heavy goals (mitigated by
the default :workspace permission profile)
- Kanban: enumerated which tools are reachable via the callback and
why the env var propagates correctly through the codex subprocess
to the MCP server subprocess
- Cron: documented as 'not specifically tested' — same rules as the
CLI apply since cron runs through AIAgent.run_conversation
- Trade-offs table gained rows for /goal, kanban worker, kanban
orchestrator
172/172 codex-runtime tests green (+2 from kanban tests).
* docs(codex-runtime): wire /codex-runtime into slash-commands ref + flag aux token cost
Three docs gaps caught during a final audit:
1. /codex-runtime was only in the feature docs page, not in the
slash-commands reference. Added rows to both the CLI section and
the Messaging section so users discover it where they'd look for
slash command syntax.
2. CODEX_HOME and HERMES_KANBAN_TASK weren't in environment-variables.md.
CODEX_HOME lets users redirect Codex CLI's config dir (the migration
honors it). HERMES_KANBAN_TASK is set by the kanban dispatcher and
propagates to the codex subprocess + the hermes-tools MCP subprocess
so kanban worker tools gate correctly — documented as 'don't set
manually' since it's an internal handoff.
3. Aux client behavior on this runtime. When openai_runtime=
codex_app_server is on with the openai-codex provider, every aux
task (title generation, context compression, vision auto-detect,
session search summarization, the background self-improvement review
fork) flows through the user's ChatGPT subscription by default.
This is true for the existing codex_responses path too, but it's
more visible / important here because users explicitly opted in for
subscription billing. Added a 'Auxiliary tasks and ChatGPT
subscription token cost' section to the docs page with a YAML
example showing how to override specific aux tasks to a cheaper
model (typically google/gemini-3-flash-preview via OpenRouter).
Also documents how the self-improvement review fork gets
auto-downgraded from codex_app_server to codex_responses by the
fix earlier in this PR.
No code changes — pure docs. 172 codex-runtime tests still green.
* docs+test(codex-runtime): pin HOME passthrough, document multi-profile + CODEX_HOME
OpenClaw hit a real footgun in openclaw/openclaw#81562: when spawning
codex app-server they were synthesizing a per-agent HOME alongside
CODEX_HOME. That made every subprocess codex's shell tool launches
(gh, git, aws, npm, gcloud, ...) see a fake $HOME and miss the user's
real config files. They had to back it out in PR #81562 — keep
CODEX_HOME isolation, leave HOME alone.
Audit confirms Hermes' codex spawn doesn't have this problem. We do
os.environ.copy() and only overlay CODEX_HOME (when provided) and
RUST_LOG. HOME passes through unchanged. But it was an emergent
property without a test pinning it, so adding a regression guard:
test_spawn_env_preserves_HOME — confirms parent HOME survives intact
in the subprocess env
test_spawn_env_sets_CODEX_HOME_when_provided — confirms codex_home
arg still isolates
codex state correctly
Docs additions:
'HOME environment variable passthrough' section — calls out the
contract explicitly: CODEX_HOME isolates codex's own state, HOME
stays user-real so gh/git/aws/npm/etc. find their normal config.
Cites openclaw#81562 as the cautionary tale.
'Multi-profile / multi-tenant setups' section — addresses the
related concern: profiles share ~/.codex/ by default. For users who
want per-profile codex isolation (separate auth, separate plugins),
documents the manual CODEX_HOME=<profile-scoped-dir> approach.
Explains why we DON'T auto-scope CODEX_HOME per profile: doing so
would silently invalidate existing codex login state for anyone
upgrading to this PR with tokens already at ~/.codex/auth.json.
Opt-in is safer than surprising users.
174 codex-runtime tests (+2 from HOME guards), all green.
* fix(codex-runtime): TOML control-char escapes + atomic config.toml write
Two footguns caught in a final audit pass before merge.
Bug 1: TOML control characters not escaped
The _format_toml_value() helper escaped backslashes and double quotes
but passed literal control characters (\n, \t, \r, \f, \b) through
unchanged. TOML basic strings don't allow literal control characters
— a path or env var containing a newline would produce invalid TOML
that codex refuses to load.
Realistic exposure: pathological cases like a HERMES_HOME with a
trailing newline (env var concatenation accident), or a PYTHONPATH
with a tab from a multi-line shell heredoc.
Fix: escape all five TOML basic-string control sequences (\b \t \n
\f \r) in addition to \\ and \" that we already did. Order
matters — backslash must come first or the other escapes get
re-escaped.
Bug 2: config.toml write wasn't atomic
If the python process crashed between target.mkdir() and the
write_text() finishing, a half-written config.toml could be left
behind. On NFS / Windows / some FUSE mounts this is a real concern;
on ext4/APFS small writes are usually atomic in practice but not
guaranteed.
Fix: write to a tempfile.mkstemp() temp file in the same directory,
then Path.replace() (atomic same-dir rename on POSIX, ReplaceFile on
Windows). On rename failure, clean up the temp file so repeated
failed migrations don't pile up .config.toml.* files.
Tests:
- test_string_with_newline_escaped — \n in value → \n in output
- test_string_with_tab_escaped — \t in value → \t in output
- test_string_with_other_controls_escaped — \r, \f, \b
- test_windows_path_escaped_correctly — backslash doubling
- test_atomic_write_no_temp_leak_on_success — no .config.toml.*
left over after a successful write
- test_atomic_write_cleanup_on_rename_failure — temp file removed
when Path.replace raises (simulated disk full)
180 codex-runtime tests, all green (+6 from this commit).
Footguns audited but NOT fixed (with rationale):
- Concurrent migrations race. Two Hermes processes hitting
/codex-runtime codex_app_server within seconds of each other could
cause one writer to lose entries. Low probability (you'd have to
enable from two surfaces simultaneously) and low impact (just re-run
migration). Adding fcntl/msvcrt locking is more code than it's
worth here. The atomic rename above means each individual write is
consistent — only the merge step is racy.
- Codex protocol version drift. We pin MIN_CODEX_VERSION=0.125 and
check at runtime but don't reject too-new versions. Right call —
the protocol has been stable through 0.125 → 0.130. If OpenAI
breaks it later we'd see the error in test_codex_app_server_runtime
on CI before users hit it.
1724 lines
71 KiB
Python
1724 lines
71 KiB
Python
"""Slash command definitions and autocomplete for the Hermes CLI.
|
||
|
||
Central registry for all slash commands. Every consumer -- CLI help, gateway
|
||
dispatch, Telegram BotCommands, Slack subcommand mapping, autocomplete --
|
||
derives its data from ``COMMAND_REGISTRY``.
|
||
|
||
To add a command: add a ``CommandDef`` entry to ``COMMAND_REGISTRY``.
|
||
To add an alias: set ``aliases=("short",)`` on the existing ``CommandDef``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import time
|
||
from collections.abc import Callable, Mapping
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from utils import is_truthy_value
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# prompt_toolkit is an optional CLI dependency — only needed for
|
||
# SlashCommandCompleter and SlashCommandAutoSuggest. Gateway and test
|
||
# environments that lack it must still be able to import this module
|
||
# for resolve_command, gateway_help_lines, and COMMAND_REGISTRY.
|
||
try:
|
||
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
|
||
from prompt_toolkit.completion import Completer, Completion
|
||
except ImportError: # pragma: no cover
|
||
AutoSuggest = object # type: ignore[assignment,misc]
|
||
Completer = object # type: ignore[assignment,misc]
|
||
Suggestion = None # type: ignore[assignment]
|
||
Completion = None # type: ignore[assignment]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CommandDef dataclass
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@dataclass(frozen=True)
|
||
class CommandDef:
|
||
"""Definition of a single slash command."""
|
||
|
||
name: str # canonical name without slash: "background"
|
||
description: str # human-readable description
|
||
category: str # "Session", "Configuration", etc.
|
||
aliases: tuple[str, ...] = () # alternative names: ("bg",)
|
||
args_hint: str = "" # argument placeholder: "<prompt>", "[name]"
|
||
subcommands: tuple[str, ...] = () # tab-completable subcommands
|
||
cli_only: bool = False # only available in CLI
|
||
gateway_only: bool = False # only available in gateway/messaging
|
||
gateway_config_gate: str | None = None # config dotpath; when truthy, overrides cli_only for gateway
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Central registry -- single source of truth
|
||
# ---------------------------------------------------------------------------
|
||
|
||
COMMAND_REGISTRY: list[CommandDef] = [
|
||
# Session
|
||
CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
|
||
aliases=("reset",), args_hint="[name]"),
|
||
CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session",
|
||
gateway_only=True, args_hint="[off|help|session-id]"),
|
||
CommandDef("clear", "Clear screen and start a new session", "Session",
|
||
cli_only=True),
|
||
CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session",
|
||
cli_only=True),
|
||
CommandDef("history", "Show conversation history", "Session",
|
||
cli_only=True),
|
||
CommandDef("save", "Save the current conversation", "Session",
|
||
cli_only=True),
|
||
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
|
||
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
|
||
CommandDef("title", "Set a title for the current session", "Session",
|
||
args_hint="[name]"),
|
||
CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session",
|
||
args_hint="<platform>", cli_only=True),
|
||
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
|
||
aliases=("fork",), args_hint="[name]"),
|
||
CommandDef("compress", "Manually compress conversation context", "Session",
|
||
args_hint="[focus topic]"),
|
||
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
|
||
args_hint="[number]"),
|
||
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
|
||
cli_only=True, aliases=("snap",), args_hint="[create|restore <id>|prune]"),
|
||
CommandDef("stop", "Kill all running background processes", "Session"),
|
||
CommandDef("approve", "Approve a pending dangerous command", "Session",
|
||
gateway_only=True, args_hint="[session|always]"),
|
||
CommandDef("deny", "Deny a pending dangerous command", "Session",
|
||
gateway_only=True),
|
||
CommandDef("background", "Run a prompt in the background", "Session",
|
||
aliases=("bg", "btw"), args_hint="<prompt>"),
|
||
CommandDef("agents", "Show active agents and running tasks", "Session",
|
||
aliases=("tasks",)),
|
||
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
|
||
aliases=("q",), args_hint="<prompt>"),
|
||
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",
|
||
args_hint="<prompt>"),
|
||
CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session",
|
||
args_hint="[text | pause | resume | clear | status]"),
|
||
CommandDef("status", "Show session info", "Session"),
|
||
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
|
||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||
gateway_only=True, aliases=("set-home",)),
|
||
CommandDef("resume", "Resume a previously-named session", "Session",
|
||
args_hint="[name]"),
|
||
|
||
# Configuration
|
||
CommandDef("sessions", "Browse and resume previous sessions", "Session"),
|
||
|
||
# Configuration
|
||
CommandDef("config", "Show current configuration", "Configuration",
|
||
cli_only=True),
|
||
CommandDef("model", "Switch model for this session", "Configuration",
|
||
aliases=("provider",), args_hint="[model] [--provider name] [--global]"),
|
||
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
|
||
"Configuration", args_hint="[auto|codex_app_server]"),
|
||
CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info",
|
||
cli_only=True),
|
||
|
||
CommandDef("personality", "Set a predefined personality", "Configuration",
|
||
args_hint="[name]"),
|
||
CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
|
||
cli_only=True, aliases=("sb",)),
|
||
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
||
"Configuration", cli_only=True,
|
||
gateway_config_gate="display.tool_progress_command"),
|
||
CommandDef("footer", "Toggle gateway runtime-metadata footer on final replies",
|
||
"Configuration", args_hint="[on|off|status]",
|
||
subcommands=("on", "off", "status")),
|
||
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
|
||
"Configuration"),
|
||
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
||
args_hint="[level|show|hide]",
|
||
subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "show", "hide", "on", "off")),
|
||
CommandDef("fast", "Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast)", "Configuration",
|
||
args_hint="[normal|fast|status]",
|
||
subcommands=("normal", "fast", "status", "on", "off")),
|
||
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
||
cli_only=True, args_hint="[name]"),
|
||
CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration",
|
||
cli_only=True, args_hint="[kaomoji|emoji|unicode|ascii]",
|
||
subcommands=("kaomoji", "emoji", "unicode", "ascii")),
|
||
CommandDef("voice", "Toggle voice mode", "Configuration",
|
||
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
|
||
CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration",
|
||
cli_only=True, args_hint="[queue|steer|interrupt|status]",
|
||
subcommands=("queue", "steer", "interrupt", "status")),
|
||
|
||
# Tools & Skills
|
||
CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills",
|
||
args_hint="[list|disable|enable] [name...]", cli_only=True),
|
||
CommandDef("toolsets", "List available toolsets", "Tools & Skills",
|
||
cli_only=True),
|
||
CommandDef("skills", "Search, install, inspect, or manage skills",
|
||
"Tools & Skills", cli_only=True,
|
||
subcommands=("search", "browse", "inspect", "install")),
|
||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||
cli_only=True, args_hint="[subcommand]",
|
||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||
CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)",
|
||
"Tools & Skills", args_hint="[subcommand]",
|
||
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")),
|
||
CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)",
|
||
"Tools & Skills", args_hint="[subcommand]",
|
||
subcommands=("list", "ls", "show", "create", "assign", "link", "unlink",
|
||
"claim", "comment", "complete", "block", "unblock", "archive",
|
||
"tail", "dispatch", "context", "init", "gc")),
|
||
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills",
|
||
cli_only=True),
|
||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||
aliases=("reload_mcp",)),
|
||
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
|
||
"Tools & Skills", aliases=("reload_skills",)),
|
||
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
||
cli_only=True, args_hint="[connect|disconnect|status]",
|
||
subcommands=("connect", "disconnect", "status")),
|
||
CommandDef("plugins", "List installed plugins and their status",
|
||
"Tools & Skills", cli_only=True),
|
||
|
||
# Info
|
||
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
|
||
gateway_only=True, args_hint="[page]"),
|
||
CommandDef("help", "Show available commands", "Info"),
|
||
CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session",
|
||
gateway_only=True),
|
||
CommandDef("usage", "Show token usage and rate limits for the current session", "Info"),
|
||
CommandDef("insights", "Show usage insights and analytics", "Info",
|
||
args_hint="[days]"),
|
||
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
|
||
cli_only=True, aliases=("gateway",)),
|
||
CommandDef("copy", "Copy the last assistant response to clipboard", "Info",
|
||
cli_only=True, args_hint="[number]"),
|
||
CommandDef("paste", "Attach clipboard image from your clipboard", "Info",
|
||
cli_only=True),
|
||
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
||
cli_only=True, args_hint="<path>"),
|
||
CommandDef("update", "Update Hermes Agent to the latest version", "Info",
|
||
gateway_only=True),
|
||
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
|
||
|
||
# Exit
|
||
CommandDef("quit", "Exit the CLI", "Exit",
|
||
cli_only=True, aliases=("exit",)),
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Derived lookups -- rebuilt once at import time, refreshed by rebuild_lookups()
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _build_command_lookup() -> dict[str, CommandDef]:
|
||
"""Map every name and alias to its CommandDef."""
|
||
lookup: dict[str, CommandDef] = {}
|
||
for cmd in COMMAND_REGISTRY:
|
||
lookup[cmd.name] = cmd
|
||
for alias in cmd.aliases:
|
||
lookup[alias] = cmd
|
||
return lookup
|
||
|
||
|
||
_COMMAND_LOOKUP: dict[str, CommandDef] = _build_command_lookup()
|
||
|
||
|
||
def resolve_command(name: str) -> CommandDef | None:
|
||
"""Resolve a command name or alias to its CommandDef.
|
||
|
||
Accepts names with or without the leading slash.
|
||
"""
|
||
return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
|
||
|
||
|
||
def _build_description(cmd: CommandDef) -> str:
|
||
"""Build a CLI-facing description string including usage hint."""
|
||
if cmd.args_hint:
|
||
return f"{cmd.description} (usage: /{cmd.name} {cmd.args_hint})"
|
||
return cmd.description
|
||
|
||
|
||
# Backwards-compatible flat dict: "/command" -> description
|
||
COMMANDS: dict[str, str] = {}
|
||
for _cmd in COMMAND_REGISTRY:
|
||
if not _cmd.gateway_only:
|
||
COMMANDS[f"/{_cmd.name}"] = _build_description(_cmd)
|
||
for _alias in _cmd.aliases:
|
||
COMMANDS[f"/{_alias}"] = f"{_cmd.description} (alias for /{_cmd.name})"
|
||
|
||
# Backwards-compatible categorized dict
|
||
COMMANDS_BY_CATEGORY: dict[str, dict[str, str]] = {}
|
||
for _cmd in COMMAND_REGISTRY:
|
||
if not _cmd.gateway_only:
|
||
_cat = COMMANDS_BY_CATEGORY.setdefault(_cmd.category, {})
|
||
_cat[f"/{_cmd.name}"] = COMMANDS[f"/{_cmd.name}"]
|
||
for _alias in _cmd.aliases:
|
||
_cat[f"/{_alias}"] = COMMANDS[f"/{_alias}"]
|
||
|
||
|
||
# Subcommands lookup: "/cmd" -> ["sub1", "sub2", ...]
|
||
SUBCOMMANDS: dict[str, list[str]] = {}
|
||
for _cmd in COMMAND_REGISTRY:
|
||
if _cmd.subcommands:
|
||
SUBCOMMANDS[f"/{_cmd.name}"] = list(_cmd.subcommands)
|
||
|
||
# Also extract subcommands hinted in args_hint via pipe-separated patterns
|
||
# e.g. args_hint="[on|off|tts|status]" for commands that don't have explicit subcommands.
|
||
# NOTE: If a command already has explicit subcommands, this fallback is skipped.
|
||
# Use the `subcommands` field on CommandDef for intentional tab-completable args.
|
||
_PIPE_SUBS_RE = re.compile(r"[a-z]+(?:\|[a-z]+)+")
|
||
for _cmd in COMMAND_REGISTRY:
|
||
key = f"/{_cmd.name}"
|
||
if key in SUBCOMMANDS or not _cmd.args_hint:
|
||
continue
|
||
m = _PIPE_SUBS_RE.search(_cmd.args_hint)
|
||
if m:
|
||
SUBCOMMANDS[key] = m.group(0).split("|")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Gateway helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Set of all command names + aliases recognized by the gateway.
|
||
# Includes config-gated commands so the gateway can dispatch them
|
||
# (the handler checks the config gate at runtime).
|
||
GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
|
||
name
|
||
for cmd in COMMAND_REGISTRY
|
||
if not cmd.cli_only or cmd.gateway_config_gate
|
||
for name in (cmd.name, *cmd.aliases)
|
||
)
|
||
|
||
|
||
def is_gateway_known_command(name: str | None) -> bool:
|
||
"""Return True if ``name`` resolves to a gateway-dispatchable slash command.
|
||
|
||
This covers both built-in commands (``GATEWAY_KNOWN_COMMANDS`` derived
|
||
from ``COMMAND_REGISTRY``) and plugin-registered commands, which are
|
||
looked up lazily so importing this module never forces plugin
|
||
discovery. Gateway code uses this to decide whether to emit
|
||
``command:<name>`` hooks — plugin commands get the same lifecycle
|
||
events as built-ins.
|
||
"""
|
||
if not name:
|
||
return False
|
||
if name in GATEWAY_KNOWN_COMMANDS:
|
||
return True
|
||
for plugin_name, _description, _args_hint in _iter_plugin_command_entries():
|
||
if plugin_name == name:
|
||
return True
|
||
return False
|
||
|
||
|
||
# Commands with explicit Level-2 running-agent handlers in gateway/run.py.
|
||
# Listed here for introspection / tests; semantically a subset of
|
||
# "all resolvable commands" — which is the real bypass set (see
|
||
# should_bypass_active_session below).
|
||
ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
|
||
{
|
||
"agents",
|
||
"approve",
|
||
"background",
|
||
"commands",
|
||
"deny",
|
||
"help",
|
||
"new",
|
||
"profile",
|
||
"queue",
|
||
"restart",
|
||
"status",
|
||
"steer",
|
||
"stop",
|
||
"update",
|
||
}
|
||
)
|
||
|
||
|
||
def should_bypass_active_session(command_name: str | None) -> bool:
|
||
"""Return True for any resolvable slash command.
|
||
|
||
Rationale: every gateway-registered slash command either has a
|
||
specific Level-2 handler in gateway/run.py (/stop, /new, /model,
|
||
/approve, etc.) or reaches the running-agent catch-all that returns
|
||
a "busy — wait or /stop first" response. In both paths the command
|
||
is dispatched, not queued.
|
||
|
||
Queueing is always wrong for a recognized slash command because the
|
||
safety net in gateway.run discards any command text that reaches
|
||
the pending queue — which meant a mid-run /model (or /reasoning,
|
||
/voice, /insights, /title, /resume, /retry, /undo, /compress,
|
||
/usage, /reload-mcp, /sethome, /reset) would silently
|
||
interrupt the agent AND get discarded, producing a zero-char
|
||
response. See issue #5057 / PRs #6252, #10370, #4665.
|
||
|
||
ACTIVE_SESSION_BYPASS_COMMANDS remains the subset of commands with
|
||
explicit Level-2 handlers; the rest fall through to the catch-all.
|
||
"""
|
||
return resolve_command(command_name) is not None if command_name else False
|
||
|
||
|
||
def _resolve_config_gates() -> set[str]:
|
||
"""Return canonical names of commands whose ``gateway_config_gate`` is truthy.
|
||
|
||
Reads ``config.yaml`` and walks the dot-separated key path for each
|
||
config-gated command. Returns an empty set on any error so callers
|
||
degrade gracefully.
|
||
"""
|
||
gated = [c for c in COMMAND_REGISTRY if c.gateway_config_gate]
|
||
if not gated:
|
||
return set()
|
||
try:
|
||
from hermes_cli.config import read_raw_config
|
||
cfg = read_raw_config()
|
||
except Exception:
|
||
return set()
|
||
result: set[str] = set()
|
||
for cmd in gated:
|
||
val: Any = cfg
|
||
for key in cmd.gateway_config_gate.split("."):
|
||
if isinstance(val, dict):
|
||
val = val.get(key)
|
||
else:
|
||
val = None
|
||
break
|
||
if is_truthy_value(val, default=False):
|
||
result.add(cmd.name)
|
||
return result
|
||
|
||
|
||
def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = None) -> bool:
|
||
"""Check if *cmd* should appear in gateway surfaces (help, menus, mappings).
|
||
|
||
Unconditionally available when ``cli_only`` is False. When ``cli_only``
|
||
is True but ``gateway_config_gate`` is set, the command is available only
|
||
when the config value is truthy. Pass *config_overrides* (from
|
||
``_resolve_config_gates()``) to avoid re-reading config for every command.
|
||
"""
|
||
if not cmd.cli_only:
|
||
return True
|
||
if cmd.gateway_config_gate:
|
||
overrides = config_overrides if config_overrides is not None else _resolve_config_gates()
|
||
return cmd.name in overrides
|
||
return False
|
||
|
||
|
||
def _requires_argument(args_hint: str) -> bool:
|
||
"""Return True when selecting a command without text would be incomplete."""
|
||
return args_hint.strip().startswith("<")
|
||
|
||
|
||
def gateway_help_lines() -> list[str]:
|
||
"""Generate gateway help text lines from the registry."""
|
||
overrides = _resolve_config_gates()
|
||
lines: list[str] = []
|
||
for cmd in COMMAND_REGISTRY:
|
||
if not _is_gateway_available(cmd, overrides):
|
||
continue
|
||
args = f" {cmd.args_hint}" if cmd.args_hint else ""
|
||
alias_parts: list[str] = []
|
||
for a in cmd.aliases:
|
||
# Skip internal aliases like reload_mcp (underscore variant)
|
||
if a.replace("-", "_") == cmd.name.replace("-", "_") and a != cmd.name:
|
||
continue
|
||
alias_parts.append(f"`/{a}`")
|
||
alias_note = f" (alias: {', '.join(alias_parts)})" if alias_parts else ""
|
||
lines.append(f"`/{cmd.name}{args}` -- {cmd.description}{alias_note}")
|
||
return lines
|
||
|
||
|
||
def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
|
||
"""Yield (name, description, args_hint) tuples for all plugin slash commands.
|
||
|
||
Plugin commands are registered via
|
||
:func:`hermes_cli.plugins.PluginContext.register_command`. They behave
|
||
like ``CommandDef`` entries for gateway surfacing: they appear in the
|
||
Telegram command menu, in Slack's ``/hermes`` subcommand mapping, and
|
||
(via :func:`gateway.platforms.discord._register_slash_commands`) in
|
||
Discord's native slash command picker.
|
||
|
||
Lookup is lazy so importing this module never forces plugin discovery
|
||
(which can trigger filesystem scans and environment-dependent
|
||
behavior).
|
||
"""
|
||
try:
|
||
from hermes_cli.plugins import get_plugin_commands
|
||
except Exception:
|
||
return []
|
||
try:
|
||
commands = get_plugin_commands() or {}
|
||
except Exception:
|
||
return []
|
||
entries: list[tuple[str, str, str]] = []
|
||
for name, meta in commands.items():
|
||
if not isinstance(name, str) or not isinstance(meta, dict):
|
||
continue
|
||
description = str(meta.get("description") or f"Run /{name}")
|
||
args_hint = str(meta.get("args_hint") or "").strip()
|
||
entries.append((name, description, args_hint))
|
||
return entries
|
||
|
||
|
||
def telegram_bot_commands() -> list[tuple[str, str]]:
|
||
"""Return (command_name, description) pairs for Telegram setMyCommands.
|
||
|
||
Telegram command names cannot contain hyphens, so they are replaced with
|
||
underscores. Aliases are skipped -- Telegram shows one menu entry per
|
||
canonical command.
|
||
|
||
Built-in commands that require arguments (e.g. /queue, /steer, /background)
|
||
are **included** because their handlers return usage text when selected
|
||
without a payload, making them discoverable via autocomplete.
|
||
|
||
Plugin-registered slash commands that require arguments are **excluded**
|
||
because plugins may not provide a no-arg usage fallback.
|
||
"""
|
||
overrides = _resolve_config_gates()
|
||
result: list[tuple[str, str]] = []
|
||
for cmd in COMMAND_REGISTRY:
|
||
if not _is_gateway_available(cmd, overrides):
|
||
continue
|
||
# Built-in arg-taking commands are included — their handlers show
|
||
# usage text when invoked without arguments, and hiding them from
|
||
# the menu hurts discoverability (issue #24312).
|
||
tg_name = _sanitize_telegram_name(cmd.name)
|
||
if tg_name:
|
||
result.append((tg_name, cmd.description))
|
||
for name, description, args_hint in _iter_plugin_command_entries():
|
||
if _requires_argument(args_hint):
|
||
continue
|
||
tg_name = _sanitize_telegram_name(name)
|
||
if tg_name:
|
||
result.append((tg_name, description))
|
||
return result
|
||
|
||
|
||
_CMD_NAME_LIMIT = 32
|
||
"""Max command name length shared by Telegram and Discord."""
|
||
|
||
# Backward-compat alias — tests and external code may reference the old name.
|
||
_TG_NAME_LIMIT = _CMD_NAME_LIMIT
|
||
|
||
# Telegram Bot API allows only lowercase a-z, 0-9, and underscores in
|
||
# command names. This regex strips everything else after initial conversion.
|
||
_TG_INVALID_CHARS = re.compile(r"[^a-z0-9_]")
|
||
_TG_MULTI_UNDERSCORE = re.compile(r"_{2,}")
|
||
|
||
|
||
def _sanitize_telegram_name(raw: str) -> str:
|
||
"""Convert a command/skill/plugin name to a valid Telegram command name.
|
||
|
||
Telegram requires: 1-32 chars, lowercase a-z, digits 0-9, underscores only.
|
||
Steps: lowercase → replace hyphens with underscores → strip all other
|
||
invalid characters → collapse consecutive underscores → strip leading/
|
||
trailing underscores.
|
||
"""
|
||
name = raw.lower().replace("-", "_")
|
||
name = _TG_INVALID_CHARS.sub("", name)
|
||
name = _TG_MULTI_UNDERSCORE.sub("_", name)
|
||
return name.strip("_")
|
||
|
||
|
||
def _clamp_command_names(
|
||
entries: list[tuple[str, ...]],
|
||
reserved: set[str],
|
||
) -> list[tuple[str, ...]]:
|
||
"""Enforce 32-char command name limit with collision avoidance.
|
||
|
||
Both Telegram and Discord cap slash command names at 32 characters.
|
||
Names exceeding the limit are truncated. If truncation creates a duplicate
|
||
(against *reserved* names or earlier entries in the same batch), the name is
|
||
shortened to 31 chars and a digit ``0``-``9`` is appended to differentiate.
|
||
If all 10 digit slots are taken the entry is silently dropped.
|
||
|
||
Accepts tuples of any length >= 2. Extra elements beyond ``(name, desc)``
|
||
(e.g. ``cmd_key``) are passed through unchanged, so callers can attach
|
||
metadata that survives the rename.
|
||
"""
|
||
used: set[str] = set(reserved)
|
||
result: list[tuple] = []
|
||
for entry in entries:
|
||
name, desc, *extra = entry
|
||
if len(name) > _CMD_NAME_LIMIT:
|
||
candidate = name[:_CMD_NAME_LIMIT]
|
||
if candidate in used:
|
||
prefix = name[:_CMD_NAME_LIMIT - 1]
|
||
for digit in range(10):
|
||
candidate = f"{prefix}{digit}"
|
||
if candidate not in used:
|
||
break
|
||
else:
|
||
# All 10 digit slots exhausted — skip entry
|
||
continue
|
||
name = candidate
|
||
if name in used:
|
||
continue
|
||
used.add(name)
|
||
result.append((name, desc, *extra))
|
||
return result
|
||
|
||
|
||
# Backward-compat alias.
|
||
_clamp_telegram_names = _clamp_command_names
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Shared skill/plugin collection for gateway platforms
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _collect_gateway_skill_entries(
|
||
platform: str,
|
||
max_slots: int,
|
||
reserved_names: set[str],
|
||
desc_limit: int = 100,
|
||
sanitize_name: "Callable[[str], str] | None" = None,
|
||
) -> tuple[list[tuple[str, str, str]], int]:
|
||
"""Collect plugin + skill entries for a gateway platform.
|
||
|
||
Priority order:
|
||
1. Plugin slash commands (take precedence over skills)
|
||
2. Built-in skill commands (fill remaining slots, alphabetical)
|
||
|
||
Only skills are trimmed when the cap is reached.
|
||
Hub-installed skills are excluded. Per-platform disabled skills are
|
||
excluded.
|
||
|
||
Args:
|
||
platform: Platform identifier for per-platform skill filtering
|
||
(``"telegram"``, ``"discord"``, etc.).
|
||
max_slots: Maximum number of entries to return (remaining slots after
|
||
built-in/core commands).
|
||
reserved_names: Names already taken by built-in commands. Mutated
|
||
in-place as new names are added.
|
||
desc_limit: Max description length (40 for Telegram, 100 for Discord).
|
||
sanitize_name: Optional name transform applied before clamping, e.g.
|
||
:func:`_sanitize_telegram_name` for Telegram. May return an
|
||
empty string to signal "skip this entry".
|
||
|
||
Returns:
|
||
``(entries, hidden_count)`` where *entries* is a list of
|
||
``(name, description, cmd_key)`` triples and *hidden_count* is the
|
||
number of skill entries dropped due to the cap. ``cmd_key`` is the
|
||
original ``/skill-name`` key from :func:`get_skill_commands`.
|
||
"""
|
||
all_entries: list[tuple[str, str, str]] = []
|
||
|
||
# --- Tier 1: Plugin slash commands (never trimmed) ---------------------
|
||
plugin_pairs: list[tuple[str, str]] = []
|
||
try:
|
||
from hermes_cli.plugins import get_plugin_commands
|
||
plugin_cmds = get_plugin_commands()
|
||
for cmd_name in sorted(plugin_cmds):
|
||
name = sanitize_name(cmd_name) if sanitize_name else cmd_name
|
||
if not name:
|
||
continue
|
||
desc = plugin_cmds[cmd_name].get("description", "Plugin command")
|
||
if len(desc) > desc_limit:
|
||
desc = desc[:desc_limit - 3] + "..."
|
||
plugin_pairs.append((name, desc))
|
||
except Exception:
|
||
pass
|
||
|
||
plugin_pairs = _clamp_command_names(plugin_pairs, reserved_names)
|
||
reserved_names.update(n for n, _ in plugin_pairs)
|
||
# Plugins have no cmd_key — use empty string as placeholder
|
||
for n, d in plugin_pairs:
|
||
all_entries.append((n, d, ""))
|
||
|
||
# --- Tier 2: Built-in skill commands (trimmed at cap) -----------------
|
||
_platform_disabled: set[str] = set()
|
||
try:
|
||
from agent.skill_utils import get_disabled_skill_names
|
||
_platform_disabled = get_disabled_skill_names(platform=platform)
|
||
except Exception:
|
||
pass
|
||
|
||
skill_triples: list[tuple[str, str, str]] = []
|
||
try:
|
||
from agent.skill_commands import get_skill_commands
|
||
from tools.skills_tool import SKILLS_DIR
|
||
from agent.skill_utils import get_external_skills_dirs
|
||
_skills_dir = str(SKILLS_DIR.resolve())
|
||
_hub_dir = str((SKILLS_DIR / ".hub").resolve()).rstrip("/") + "/"
|
||
# Build set of allowed directory prefixes: local skills dir + any
|
||
# user-configured ``skills.external_dirs``. Ensure each prefix ends
|
||
# with ``/`` so ``/my-skills`` does not also match ``/my-skills-extra``.
|
||
# Without this widening, external skills are visible in
|
||
# ``hermes skills list`` and the agent's ``/skill-name`` dispatch but
|
||
# silently excluded from gateway slash menus (#8110).
|
||
_allowed_prefixes = [_skills_dir.rstrip("/") + "/"]
|
||
_allowed_prefixes.extend(
|
||
str(d).rstrip("/") + "/" for d in get_external_skills_dirs()
|
||
)
|
||
skill_cmds = get_skill_commands()
|
||
for cmd_key in sorted(skill_cmds):
|
||
info = skill_cmds[cmd_key]
|
||
skill_path = info.get("skill_md_path", "")
|
||
if not skill_path:
|
||
continue
|
||
if not any(skill_path.startswith(prefix) for prefix in _allowed_prefixes):
|
||
continue
|
||
if skill_path.startswith(_hub_dir):
|
||
continue
|
||
skill_name = info.get("name", "")
|
||
if skill_name in _platform_disabled:
|
||
continue
|
||
raw_name = cmd_key.lstrip("/")
|
||
name = sanitize_name(raw_name) if sanitize_name else raw_name
|
||
if not name:
|
||
continue
|
||
desc = info.get("description", "")
|
||
if len(desc) > desc_limit:
|
||
desc = desc[:desc_limit - 3] + "..."
|
||
skill_triples.append((name, desc, cmd_key))
|
||
except Exception:
|
||
pass
|
||
|
||
# Clamp names; cmd_key is passed through as extra payload so it survives
|
||
# any clamp-induced renames.
|
||
skill_triples = _clamp_command_names(skill_triples, reserved_names)
|
||
|
||
# Skills fill remaining slots — only tier that gets trimmed
|
||
remaining = max(0, max_slots - len(all_entries))
|
||
hidden_count = max(0, len(skill_triples) - remaining)
|
||
for n, d, k in skill_triples[:remaining]:
|
||
all_entries.append((n, d, k))
|
||
|
||
return all_entries[:max_slots], hidden_count
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Platform-specific wrappers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
|
||
"""Return Telegram menu commands capped to the Bot API limit.
|
||
|
||
Priority order (higher priority = never bumped by overflow):
|
||
1. Core CommandDef commands (always included)
|
||
2. Plugin slash commands (take precedence over skills)
|
||
3. Built-in skill commands (fill remaining slots, alphabetical)
|
||
|
||
Skills are the only tier that gets trimmed when the cap is hit.
|
||
User-installed hub skills are excluded — accessible via /skills.
|
||
Skills disabled for the ``"telegram"`` platform (via ``hermes skills
|
||
config``) are excluded from the menu entirely.
|
||
|
||
Returns:
|
||
(menu_commands, hidden_count) where hidden_count is the number of
|
||
skill commands omitted due to the cap.
|
||
"""
|
||
core_commands = list(telegram_bot_commands())
|
||
reserved_names = {n for n, _ in core_commands}
|
||
all_commands = list(core_commands)
|
||
|
||
remaining_slots = max(0, max_commands - len(all_commands))
|
||
entries, hidden_count = _collect_gateway_skill_entries(
|
||
platform="telegram",
|
||
max_slots=remaining_slots,
|
||
reserved_names=reserved_names,
|
||
desc_limit=40,
|
||
sanitize_name=_sanitize_telegram_name,
|
||
)
|
||
# Drop the cmd_key — Telegram only needs (name, desc) pairs.
|
||
all_commands.extend((n, d) for n, d, _k in entries)
|
||
return all_commands[:max_commands], hidden_count
|
||
|
||
|
||
def discord_skill_commands(
|
||
max_slots: int,
|
||
reserved_names: set[str],
|
||
) -> tuple[list[tuple[str, str, str]], int]:
|
||
"""Return skill entries for Discord slash command registration.
|
||
|
||
Same priority and filtering logic as :func:`telegram_menu_commands`
|
||
(plugins > skills, hub excluded, per-platform disabled excluded), but
|
||
adapted for Discord's constraints:
|
||
|
||
- Hyphens are allowed in names (no ``-`` → ``_`` sanitization)
|
||
- Descriptions capped at 100 chars (Discord's per-field max)
|
||
|
||
Args:
|
||
max_slots: Available command slots (100 minus existing built-in count).
|
||
reserved_names: Names of already-registered built-in commands.
|
||
|
||
Returns:
|
||
``(entries, hidden_count)`` where *entries* is a list of
|
||
``(discord_name, description, cmd_key)`` triples. ``cmd_key`` is
|
||
the original ``/skill-name`` key needed for the slash handler callback.
|
||
"""
|
||
return _collect_gateway_skill_entries(
|
||
platform="discord",
|
||
max_slots=max_slots,
|
||
reserved_names=set(reserved_names), # copy — don't mutate caller's set
|
||
desc_limit=100,
|
||
)
|
||
|
||
|
||
def discord_skill_commands_by_category(
|
||
reserved_names: set[str],
|
||
) -> tuple[dict[str, list[tuple[str, str, str]]], list[tuple[str, str, str]], int]:
|
||
"""Return skill entries organized by category for Discord ``/skill`` autocomplete.
|
||
|
||
Skills whose directory is nested at least 2 levels under a scan root
|
||
(e.g. ``creative/ascii-art/SKILL.md``) are grouped by their top-level
|
||
category. Root-level skills (e.g. ``dogfood/SKILL.md``) are returned as
|
||
*uncategorized*.
|
||
|
||
Scan roots include the local ``SKILLS_DIR`` **and** any configured
|
||
``skills.external_dirs`` — matching the widened filter applied to the
|
||
flat ``discord_skill_commands()`` collector in #18741. Without this
|
||
parity, external-dir skills are visible via ``hermes skills list`` and
|
||
the agent's ``/skill-name`` dispatch but silently absent from Discord's
|
||
``/skill`` autocomplete.
|
||
|
||
Filtering mirrors :func:`discord_skill_commands`: hub skills excluded,
|
||
per-platform disabled excluded, names clamped to 32 chars, descriptions
|
||
clamped to 100 chars.
|
||
|
||
The legacy 25-group × 25-subcommand caps (from the old nested
|
||
``/skill <cat> <name>`` layout) are **not** applied — the live caller
|
||
(``_register_skill_group`` in ``gateway/platforms/discord.py``, refactored
|
||
in PR #11580) flattens these results and feeds them into a single
|
||
autocomplete callback, which scales to thousands of entries without any
|
||
per-command payload concerns. ``hidden_count`` is retained in the return
|
||
tuple for backward compatibility and still reports skills dropped for
|
||
other reasons (32-char clamp collision vs a reserved name).
|
||
|
||
Returns:
|
||
``(categories, uncategorized, hidden_count)``
|
||
|
||
- *categories*: ``{category_name: [(name, description, cmd_key), ...]}``
|
||
- *uncategorized*: ``[(name, description, cmd_key), ...]``
|
||
- *hidden_count*: skills dropped due to name clamp collisions
|
||
against already-registered command names.
|
||
"""
|
||
from pathlib import Path as _P
|
||
|
||
_platform_disabled: set[str] = set()
|
||
try:
|
||
from agent.skill_utils import get_disabled_skill_names
|
||
_platform_disabled = get_disabled_skill_names(platform="discord")
|
||
except Exception:
|
||
pass
|
||
|
||
# Collect raw skill data --------------------------------------------------
|
||
categories: dict[str, list[tuple[str, str, str]]] = {}
|
||
uncategorized: list[tuple[str, str, str]] = []
|
||
# Map clamped-32-char-name → what it came from, so we can emit an
|
||
# actionable warning on collision. Reserved (gateway-builtin) command
|
||
# names are marked with a sentinel so the warning distinguishes
|
||
# "skill collided with a reserved command" from "two skills collided
|
||
# on the 32-char clamp" — the latter is the rename-worthy case.
|
||
_names_used: dict[str, str] = dict.fromkeys(reserved_names, "<reserved>")
|
||
hidden = 0
|
||
|
||
try:
|
||
from agent.skill_commands import get_skill_commands
|
||
from agent.skill_utils import get_external_skills_dirs
|
||
from tools.skills_tool import SKILLS_DIR
|
||
|
||
_skills_dir = SKILLS_DIR.resolve()
|
||
_hub_dir = (SKILLS_DIR / ".hub").resolve()
|
||
# Build list of (resolved_root, is_local) tuples. Each external dir
|
||
# becomes its own scan root for category derivation — a skill at
|
||
# ``<external>/mlops/foo/SKILL.md`` is still categorized as "mlops".
|
||
_scan_roots: list[_P] = [_skills_dir]
|
||
try:
|
||
for ext in get_external_skills_dirs():
|
||
try:
|
||
_scan_roots.append(_P(ext).resolve())
|
||
except Exception:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
skill_cmds = get_skill_commands()
|
||
|
||
for cmd_key in sorted(skill_cmds):
|
||
info = skill_cmds[cmd_key]
|
||
skill_path = info.get("skill_md_path", "")
|
||
if not skill_path:
|
||
continue
|
||
sp = _P(skill_path).resolve()
|
||
# Hub skills are loaded via the skill hub, not surfaced as
|
||
# slash commands.
|
||
if str(sp).startswith(str(_hub_dir)):
|
||
continue
|
||
# Accept skill if it lives under any scan root; record the
|
||
# matching root so we can derive the category correctly.
|
||
matched_root: _P | None = None
|
||
for root in _scan_roots:
|
||
try:
|
||
sp.relative_to(root)
|
||
except ValueError:
|
||
continue
|
||
matched_root = root
|
||
break
|
||
if matched_root is None:
|
||
continue
|
||
|
||
skill_name = info.get("name", "")
|
||
if skill_name in _platform_disabled:
|
||
continue
|
||
|
||
raw_name = cmd_key.lstrip("/")
|
||
# Clamp to 32 chars (Discord per-command name limit)
|
||
discord_name = raw_name[:32]
|
||
if discord_name in _names_used:
|
||
# Two skills whose first 32 chars are identical. One wins
|
||
# (the first one seen, which is alphabetical because the
|
||
# caller iterates ``sorted(skill_cmds)``); the other is
|
||
# dropped from Discord's /skill autocomplete.
|
||
#
|
||
# Silently counting this as ``hidden`` (the old behavior)
|
||
# meant skill authors had no way to discover the drop —
|
||
# their skill just didn't appear in the picker. Emit a
|
||
# WARNING naming both sides so the author can rename the
|
||
# losing skill's frontmatter name to something with a
|
||
# distinct 32-char prefix.
|
||
prior = _names_used[discord_name]
|
||
if prior == "<reserved>":
|
||
logger.warning(
|
||
"Discord /skill: %r (from %r) collides on its 32-char "
|
||
"clamp with a reserved gateway command name %r — the "
|
||
"skill will not appear in the /skill autocomplete. "
|
||
"Rename the skill's frontmatter ``name:`` to differ "
|
||
"in its first 32 chars.",
|
||
discord_name, cmd_key, discord_name,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"Discord /skill: %r and %r both clamp to %r on "
|
||
"Discord's 32-char command-name limit — only %r "
|
||
"will appear in the /skill autocomplete. Rename "
|
||
"one skill's frontmatter ``name:`` to differ in "
|
||
"its first 32 chars.",
|
||
prior, cmd_key, discord_name, prior,
|
||
)
|
||
hidden += 1
|
||
continue
|
||
_names_used[discord_name] = cmd_key
|
||
|
||
desc = info.get("description", "")
|
||
if len(desc) > 100:
|
||
desc = desc[:97] + "..."
|
||
|
||
# Determine category from the relative path within the matched
|
||
# scan root. e.g. creative/ascii-art/SKILL.md → ("creative", ...)
|
||
rel = sp.parent.relative_to(matched_root)
|
||
parts = rel.parts
|
||
if len(parts) >= 2:
|
||
cat = parts[0]
|
||
categories.setdefault(cat, []).append((discord_name, desc, cmd_key))
|
||
else:
|
||
uncategorized.append((discord_name, desc, cmd_key))
|
||
except Exception:
|
||
pass
|
||
|
||
return categories, uncategorized, hidden
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Slack native slash commands
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Slack slash command name constraints: lowercase a-z, 0-9, hyphens,
|
||
# underscores. Max 32 chars. Slack app manifest accepts up to 50 slash
|
||
# commands per app.
|
||
_SLACK_MAX_SLASH_COMMANDS = 50
|
||
_SLACK_NAME_LIMIT = 32
|
||
_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]")
|
||
_SLACK_RESERVED_COMMANDS = frozenset({
|
||
# Built-in Slack slash commands that cannot be registered by apps.
|
||
# https://slack.com/help/articles/201259356-Use-built-in-slash-commands
|
||
"me", "status", "away", "dnd", "shrug", "remind", "msg", "feed",
|
||
"who", "collapse", "expand", "leave", "join", "open", "search",
|
||
"topic", "mute", "pro", "shortcuts",
|
||
})
|
||
|
||
|
||
def _sanitize_slack_name(raw: str) -> str:
|
||
"""Convert a command name to a valid Slack slash command name.
|
||
|
||
Slack allows lowercase a-z, digits, hyphens, and underscores. Max 32
|
||
chars. Uppercase is lowercased; invalid chars are stripped.
|
||
"""
|
||
name = raw.lower()
|
||
name = _SLACK_INVALID_CHARS.sub("", name)
|
||
name = name.strip("-_")
|
||
return name[:_SLACK_NAME_LIMIT]
|
||
|
||
|
||
def slack_native_slashes() -> list[tuple[str, str, str]]:
|
||
"""Return (slash_name, description, usage_hint) triples for Slack.
|
||
|
||
Every gateway-available command in ``COMMAND_REGISTRY`` is surfaced as
|
||
a standalone Slack slash command (e.g. ``/btw``, ``/stop``, ``/model``),
|
||
matching Discord's and Telegram's model where every command is a
|
||
first-class slash and not a ``/hermes <verb>`` subcommand.
|
||
|
||
Both canonical names and aliases are included so users can type any
|
||
documented form (e.g. ``/background``, ``/bg``, and ``/btw`` all work).
|
||
Plugin-registered slash commands are included too.
|
||
|
||
Commands whose sanitized name collides with a Slack built-in
|
||
(e.g. ``/status``, ``/me``, ``/join``) are silently skipped. Users
|
||
can still reach them via ``/hermes <command>``.
|
||
|
||
Results are clamped to Slack's 50-command limit with duplicate-name
|
||
avoidance. ``/hermes`` is always reserved as the first entry so the
|
||
legacy ``/hermes <subcommand>`` form keeps working for anything that
|
||
gets dropped by the clamp or for free-form questions.
|
||
"""
|
||
overrides = _resolve_config_gates()
|
||
entries: list[tuple[str, str, str]] = []
|
||
seen: set[str] = set()
|
||
|
||
# Reserve /hermes as the catch-all top-level command.
|
||
entries.append(("hermes", "Talk to Hermes or run a subcommand", "[subcommand] [args]"))
|
||
seen.add("hermes")
|
||
|
||
def _add(name: str, desc: str, hint: str) -> None:
|
||
slack_name = _sanitize_slack_name(name)
|
||
if not slack_name or slack_name in seen:
|
||
return
|
||
if slack_name in _SLACK_RESERVED_COMMANDS:
|
||
return
|
||
if len(entries) >= _SLACK_MAX_SLASH_COMMANDS:
|
||
return
|
||
# Slack description cap is 2000 chars; keep it short.
|
||
entries.append((slack_name, desc[:140], hint[:100]))
|
||
seen.add(slack_name)
|
||
|
||
# First pass: canonical names (so they win slots if we hit the cap).
|
||
for cmd in COMMAND_REGISTRY:
|
||
if not _is_gateway_available(cmd, overrides):
|
||
continue
|
||
_add(cmd.name, cmd.description, cmd.args_hint or "")
|
||
|
||
# Second pass: aliases.
|
||
for cmd in COMMAND_REGISTRY:
|
||
if not _is_gateway_available(cmd, overrides):
|
||
continue
|
||
for alias in cmd.aliases:
|
||
# Skip aliases that only differ from canonical by case/punctuation
|
||
# normalization (already covered by _add dedup).
|
||
_add(alias, f"Alias for /{cmd.name} — {cmd.description}", cmd.args_hint or "")
|
||
|
||
# Third pass: plugin commands.
|
||
for name, description, args_hint in _iter_plugin_command_entries():
|
||
_add(name, description, args_hint or "")
|
||
|
||
return entries
|
||
|
||
|
||
def slack_app_manifest(request_url: str = "https://hermes-agent.local/slack/commands") -> dict[str, Any]:
|
||
"""Generate a Slack app manifest with all gateway commands as slashes.
|
||
|
||
``request_url`` is required by Slack's manifest schema for every slash
|
||
command, but in Socket Mode (which we use) Slack ignores it and routes
|
||
the command event through the WebSocket. A placeholder URL is fine.
|
||
|
||
The returned dict is the ``features.slash_commands`` portion only —
|
||
callers compose it into a full manifest (or merge into an existing
|
||
one). Keeping it narrow avoids coupling us to the rest of the manifest
|
||
schema (display_information, oauth_config, settings, etc.) which users
|
||
set up once in the Slack UI and rarely change.
|
||
"""
|
||
slashes = []
|
||
for name, desc, usage in slack_native_slashes():
|
||
entry = {
|
||
"command": f"/{name}",
|
||
"description": desc or f"Run /{name}",
|
||
"should_escape": False,
|
||
"url": request_url,
|
||
}
|
||
if usage:
|
||
entry["usage_hint"] = usage
|
||
slashes.append(entry)
|
||
return {"features": {"slash_commands": slashes}}
|
||
|
||
|
||
def slack_subcommand_map() -> dict[str, str]:
|
||
"""Return subcommand -> /command mapping for Slack /hermes handler.
|
||
|
||
Maps both canonical names and aliases so /hermes bg do stuff works
|
||
the same as /hermes background do stuff.
|
||
|
||
Plugin-registered slash commands are included so ``/hermes <plugin-cmd>``
|
||
routes through the plugin handler.
|
||
"""
|
||
overrides = _resolve_config_gates()
|
||
mapping: dict[str, str] = {}
|
||
for cmd in COMMAND_REGISTRY:
|
||
if not _is_gateway_available(cmd, overrides):
|
||
continue
|
||
mapping[cmd.name] = f"/{cmd.name}"
|
||
for alias in cmd.aliases:
|
||
mapping[alias] = f"/{alias}"
|
||
for name, _description, _args_hint in _iter_plugin_command_entries():
|
||
if name not in mapping:
|
||
mapping[name] = f"/{name}"
|
||
return mapping
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Autocomplete
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
# Per-process cache for /model<space> LM Studio autocomplete. Probing on
|
||
# every keystroke would block the UI; a short TTL keeps it live without
|
||
# hammering the server.
|
||
_LMSTUDIO_COMPLETION_CACHE: tuple[float, list[str]] | None = None
|
||
|
||
|
||
def _lmstudio_completion_models() -> list[str]:
|
||
"""Locally-loaded LM Studio models for /model autocomplete (cached, gated)."""
|
||
global _LMSTUDIO_COMPLETION_CACHE
|
||
# Gate: don't probe 127.0.0.1 on every keystroke for users who don't use LM Studio.
|
||
if not (os.environ.get("LM_API_KEY") or os.environ.get("LM_BASE_URL")):
|
||
try:
|
||
from hermes_cli.auth import _load_auth_store
|
||
store = _load_auth_store() or {}
|
||
if "lmstudio" not in (store.get("providers") or {}) \
|
||
and "lmstudio" not in (store.get("credential_pool") or {}):
|
||
return []
|
||
except Exception:
|
||
return []
|
||
now = time.time()
|
||
if _LMSTUDIO_COMPLETION_CACHE and (now - _LMSTUDIO_COMPLETION_CACHE[0]) < 30.0:
|
||
return _LMSTUDIO_COMPLETION_CACHE[1]
|
||
try:
|
||
from hermes_cli.models import fetch_lmstudio_models
|
||
models = fetch_lmstudio_models(
|
||
api_key=os.environ.get("LM_API_KEY", ""),
|
||
base_url=os.environ.get("LM_BASE_URL") or "http://127.0.0.1:1234/v1",
|
||
timeout=0.8,
|
||
)
|
||
except Exception:
|
||
models = []
|
||
_LMSTUDIO_COMPLETION_CACHE = (now, models)
|
||
return models
|
||
|
||
|
||
class SlashCommandCompleter(Completer):
|
||
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
|
||
|
||
def __init__(
|
||
self,
|
||
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
|
||
command_filter: Callable[[str], bool] | None = None,
|
||
) -> None:
|
||
self._skill_commands_provider = skill_commands_provider
|
||
self._command_filter = command_filter
|
||
# Cached project file list for fuzzy @ completions
|
||
self._file_cache: list[str] = []
|
||
self._file_cache_time: float = 0.0
|
||
self._file_cache_cwd: str = ""
|
||
|
||
def _command_allowed(self, slash_command: str) -> bool:
|
||
if self._command_filter is None:
|
||
return True
|
||
try:
|
||
return bool(self._command_filter(slash_command))
|
||
except Exception:
|
||
return True
|
||
|
||
def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]:
|
||
if self._skill_commands_provider is None:
|
||
return {}
|
||
try:
|
||
return self._skill_commands_provider() or {}
|
||
except Exception:
|
||
return {}
|
||
|
||
# Commands that open pickers when run without arguments.
|
||
# These should NOT receive a trailing space in completions because:
|
||
# - The TUI's submit handler applies completions on Enter if input differs
|
||
# - Adding space makes "/model" → "/model " which blocks picker execution
|
||
_PICKER_COMMANDS = frozenset({"model", "skin", "personality"})
|
||
|
||
@staticmethod
|
||
def _completion_text(cmd_name: str, word: str) -> str:
|
||
"""Return replacement text for a completion.
|
||
|
||
When the user has already typed the full command exactly (``/help``),
|
||
returning ``help`` would be a no-op and prompt_toolkit suppresses the
|
||
menu. Appending a trailing space keeps the dropdown visible and makes
|
||
backspacing retrigger it naturally.
|
||
|
||
However, commands that open pickers (model, skin, personality) should
|
||
NOT get a trailing space — the TUI would apply the completion on Enter
|
||
and block the picker from opening.
|
||
"""
|
||
if cmd_name != word:
|
||
return cmd_name
|
||
# Don't add space for picker commands — allows Enter to execute them
|
||
if cmd_name in SlashCommandCompleter._PICKER_COMMANDS:
|
||
return cmd_name
|
||
return f"{cmd_name} "
|
||
|
||
@staticmethod
|
||
def _extract_path_word(text: str) -> str | None:
|
||
"""Extract the current word if it looks like a file path.
|
||
|
||
Returns the path-like token under the cursor, or None if the
|
||
current word doesn't look like a path. A word is path-like when
|
||
it starts with ``./``, ``../``, ``~/``, ``/``, or contains a
|
||
``/`` separator (e.g. ``src/main.py``).
|
||
"""
|
||
if not text:
|
||
return None
|
||
# Walk backwards to find the start of the current "word".
|
||
# Words are delimited by spaces, but paths can contain almost anything.
|
||
i = len(text) - 1
|
||
while i >= 0 and text[i] != " ":
|
||
i -= 1
|
||
word = text[i + 1:]
|
||
if not word:
|
||
return None
|
||
# Only trigger path completion for path-like tokens
|
||
if word.startswith(("./", "../", "~/", "/")) or "/" in word:
|
||
return word
|
||
return None
|
||
|
||
@staticmethod
|
||
def _path_completions(word: str, limit: int = 30):
|
||
"""Yield Completion objects for file paths matching *word*."""
|
||
expanded = os.path.expanduser(word)
|
||
# Split into directory part and prefix to match inside it
|
||
if expanded.endswith("/"):
|
||
search_dir = expanded
|
||
prefix = ""
|
||
else:
|
||
search_dir = os.path.dirname(expanded) or "."
|
||
prefix = os.path.basename(expanded)
|
||
|
||
try:
|
||
entries = os.listdir(search_dir)
|
||
except OSError:
|
||
return
|
||
|
||
count = 0
|
||
prefix_lower = prefix.lower()
|
||
for entry in sorted(entries):
|
||
if prefix and not entry.lower().startswith(prefix_lower):
|
||
continue
|
||
if count >= limit:
|
||
break
|
||
|
||
full_path = os.path.join(search_dir, entry)
|
||
is_dir = os.path.isdir(full_path)
|
||
|
||
# Build the completion text (what replaces the typed word)
|
||
if word.startswith("~"):
|
||
display_path = "~/" + os.path.relpath(full_path, os.path.expanduser("~"))
|
||
elif os.path.isabs(word):
|
||
display_path = full_path
|
||
else:
|
||
# Keep relative
|
||
display_path = os.path.relpath(full_path)
|
||
|
||
if is_dir:
|
||
display_path += "/"
|
||
|
||
suffix = "/" if is_dir else ""
|
||
meta = "dir" if is_dir else _file_size_label(full_path)
|
||
|
||
yield Completion(
|
||
display_path,
|
||
start_position=-len(word),
|
||
display=entry + suffix,
|
||
display_meta=meta,
|
||
)
|
||
count += 1
|
||
|
||
@staticmethod
|
||
def _extract_context_word(text: str) -> str | None:
|
||
"""Extract a bare ``@`` token for context reference completions."""
|
||
if not text:
|
||
return None
|
||
# Walk backwards to find the start of the current word
|
||
i = len(text) - 1
|
||
while i >= 0 and text[i] != " ":
|
||
i -= 1
|
||
word = text[i + 1:]
|
||
if not word.startswith("@"):
|
||
return None
|
||
return word
|
||
|
||
def _context_completions(self, word: str, limit: int = 30):
|
||
"""Yield Claude Code-style @ context completions.
|
||
|
||
Bare ``@`` or ``@partial`` shows static references and matching
|
||
files/folders. ``@file:path`` and ``@folder:path`` are handled
|
||
by the existing path completion path.
|
||
"""
|
||
lowered = word.lower()
|
||
|
||
# Static context references
|
||
_STATIC_REFS = (
|
||
("@diff", "Git working tree diff"),
|
||
("@staged", "Git staged diff"),
|
||
("@file:", "Attach a file"),
|
||
("@folder:", "Attach a folder"),
|
||
("@git:", "Git log with diffs (e.g. @git:5)"),
|
||
("@url:", "Fetch web content"),
|
||
)
|
||
for candidate, meta in _STATIC_REFS:
|
||
if candidate.lower().startswith(lowered) and candidate.lower() != lowered:
|
||
yield Completion(
|
||
candidate,
|
||
start_position=-len(word),
|
||
display=candidate,
|
||
display_meta=meta,
|
||
)
|
||
|
||
# If the user typed @file: / @folder: (or just @file / @folder with
|
||
# no colon yet), delegate to path completions. Accepting the bare
|
||
# form lets the picker surface directories as soon as the user has
|
||
# typed `@folder`, without requiring them to first accept the static
|
||
# `@folder:` hint and re-trigger completion.
|
||
for prefix in ("@file:", "@folder:"):
|
||
bare = prefix[:-1]
|
||
|
||
if word == bare or word.startswith(prefix):
|
||
want_dir = prefix == "@folder:"
|
||
path_part = '' if word == bare else word[len(prefix):]
|
||
expanded = os.path.expanduser(path_part)
|
||
|
||
if not expanded or expanded == ".":
|
||
search_dir, match_prefix = ".", ""
|
||
elif expanded.endswith("/"):
|
||
search_dir, match_prefix = expanded, ""
|
||
else:
|
||
search_dir = os.path.dirname(expanded) or "."
|
||
match_prefix = os.path.basename(expanded)
|
||
|
||
try:
|
||
entries = os.listdir(search_dir)
|
||
except OSError:
|
||
return
|
||
|
||
count = 0
|
||
prefix_lower = match_prefix.lower()
|
||
for entry in sorted(entries):
|
||
if match_prefix and not entry.lower().startswith(prefix_lower):
|
||
continue
|
||
full_path = os.path.join(search_dir, entry)
|
||
is_dir = os.path.isdir(full_path)
|
||
# `@folder:` must only surface directories; `@file:` only
|
||
# regular files. Without this filter `@folder:` listed
|
||
# every .env / .gitignore in the cwd, defeating the
|
||
# explicit prefix and confusing users expecting a
|
||
# directory picker.
|
||
if want_dir != is_dir:
|
||
continue
|
||
if count >= limit:
|
||
break
|
||
display_path = os.path.relpath(full_path)
|
||
suffix = "/" if is_dir else ""
|
||
meta = "dir" if is_dir else _file_size_label(full_path)
|
||
completion = f"{prefix}{display_path}{suffix}"
|
||
yield Completion(
|
||
completion,
|
||
start_position=-len(word),
|
||
display=entry + suffix,
|
||
display_meta=meta,
|
||
)
|
||
count += 1
|
||
return
|
||
|
||
# Bare @ or @partial — fuzzy project-wide file search
|
||
query = word[1:] # strip the @
|
||
yield from self._fuzzy_file_completions(word, query, limit)
|
||
|
||
def _get_project_files(self) -> list[str]:
|
||
"""Return cached list of project files (refreshed every 5s)."""
|
||
cwd = os.getcwd()
|
||
now = time.monotonic()
|
||
if (
|
||
self._file_cache
|
||
and self._file_cache_cwd == cwd
|
||
and now - self._file_cache_time < 5.0
|
||
):
|
||
return self._file_cache
|
||
|
||
files: list[str] = []
|
||
# Try rg first (fast, respects .gitignore), then fd, then find.
|
||
for cmd in [
|
||
["rg", "--files", "--sortr=modified", cwd],
|
||
["rg", "--files", cwd],
|
||
["fd", "--type", "f", "--base-directory", cwd],
|
||
]:
|
||
tool = cmd[0]
|
||
if not shutil.which(tool):
|
||
continue
|
||
try:
|
||
proc = subprocess.run(
|
||
cmd, capture_output=True, text=True, timeout=2,
|
||
cwd=cwd, encoding="utf-8", errors="replace",
|
||
)
|
||
if proc.returncode == 0 and proc.stdout and proc.stdout.strip():
|
||
raw = proc.stdout.strip().split("\n")
|
||
# Store relative paths
|
||
for p in raw[:5000]:
|
||
rel = os.path.relpath(p, cwd) if os.path.isabs(p) else p
|
||
files.append(rel)
|
||
break
|
||
except (subprocess.TimeoutExpired, OSError):
|
||
continue
|
||
|
||
self._file_cache = files
|
||
self._file_cache_time = now
|
||
self._file_cache_cwd = cwd
|
||
return files
|
||
|
||
@staticmethod
|
||
def _score_path(filepath: str, query: str) -> int:
|
||
"""Score a file path against a fuzzy query. Higher = better match."""
|
||
if not query:
|
||
return 1 # show everything when query is empty
|
||
|
||
filename = os.path.basename(filepath)
|
||
lower_file = filename.lower()
|
||
lower_path = filepath.lower()
|
||
lower_q = query.lower()
|
||
|
||
# Exact filename match
|
||
if lower_file == lower_q:
|
||
return 100
|
||
# Filename starts with query
|
||
if lower_file.startswith(lower_q):
|
||
return 80
|
||
# Filename contains query as substring
|
||
if lower_q in lower_file:
|
||
return 60
|
||
# Full path contains query
|
||
if lower_q in lower_path:
|
||
return 40
|
||
# Initials / abbreviation match: e.g. "fo" matches "file_operations"
|
||
# Check if query chars appear in order in filename
|
||
qi = 0
|
||
for c in lower_file:
|
||
if qi < len(lower_q) and c == lower_q[qi]:
|
||
qi += 1
|
||
if qi == len(lower_q):
|
||
# Bonus if matches land on word boundaries (after _, -, /, .)
|
||
boundary_hits = 0
|
||
qi = 0
|
||
prev = "_" # treat start as boundary
|
||
for c in lower_file:
|
||
if qi < len(lower_q) and c == lower_q[qi]:
|
||
if prev in "_-./":
|
||
boundary_hits += 1
|
||
qi += 1
|
||
prev = c
|
||
if boundary_hits >= len(lower_q) * 0.5:
|
||
return 35
|
||
return 25
|
||
return 0
|
||
|
||
def _fuzzy_file_completions(self, word: str, query: str, limit: int = 20):
|
||
"""Yield fuzzy file completions for bare @query."""
|
||
files = self._get_project_files()
|
||
|
||
if not query:
|
||
# No query — show recently modified files (already sorted by mtime)
|
||
for fp in files[:limit]:
|
||
is_dir = fp.endswith("/")
|
||
filename = os.path.basename(fp)
|
||
kind = "folder" if is_dir else "file"
|
||
meta = "dir" if is_dir else _file_size_label(
|
||
os.path.join(os.getcwd(), fp)
|
||
)
|
||
yield Completion(
|
||
f"@{kind}:{fp}",
|
||
start_position=-len(word),
|
||
display=filename,
|
||
display_meta=meta,
|
||
)
|
||
return
|
||
|
||
# Score and rank
|
||
scored = []
|
||
for fp in files:
|
||
s = self._score_path(fp, query)
|
||
if s > 0:
|
||
scored.append((s, fp))
|
||
scored.sort(key=lambda x: (-x[0], x[1]))
|
||
|
||
for _, fp in scored[:limit]:
|
||
is_dir = fp.endswith("/")
|
||
filename = os.path.basename(fp)
|
||
kind = "folder" if is_dir else "file"
|
||
meta = "dir" if is_dir else _file_size_label(
|
||
os.path.join(os.getcwd(), fp)
|
||
)
|
||
yield Completion(
|
||
f"@{kind}:{fp}",
|
||
start_position=-len(word),
|
||
display=filename,
|
||
display_meta=f"{fp} {meta}" if meta else fp,
|
||
)
|
||
|
||
@staticmethod
|
||
def _skin_completions(sub_text: str, sub_lower: str):
|
||
"""Yield completions for /skin from available skins."""
|
||
try:
|
||
from hermes_cli.skin_engine import list_skins
|
||
for s in list_skins():
|
||
name = s["name"]
|
||
if name.startswith(sub_lower) and name != sub_lower:
|
||
yield Completion(
|
||
name,
|
||
start_position=-len(sub_text),
|
||
display=name,
|
||
display_meta=s.get("description", "") or s.get("source", ""),
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
@staticmethod
|
||
def _personality_completions(sub_text: str, sub_lower: str):
|
||
"""Yield completions for /personality from configured personalities."""
|
||
try:
|
||
from hermes_cli.config import load_config
|
||
personalities = load_config().get("agent", {}).get("personalities", {})
|
||
if "none".startswith(sub_lower) and "none" != sub_lower:
|
||
yield Completion(
|
||
"none",
|
||
start_position=-len(sub_text),
|
||
display="none",
|
||
display_meta="clear personality overlay",
|
||
)
|
||
for name, prompt in personalities.items():
|
||
if name.startswith(sub_lower) and name != sub_lower:
|
||
if isinstance(prompt, dict):
|
||
meta = prompt.get("description") or prompt.get("system_prompt", "")[:50]
|
||
else:
|
||
meta = str(prompt)[:50]
|
||
yield Completion(
|
||
name,
|
||
start_position=-len(sub_text),
|
||
display=name,
|
||
display_meta=meta,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _model_completions(self, sub_text: str, sub_lower: str):
|
||
"""Yield completions for /model from config aliases + built-in aliases."""
|
||
seen = set()
|
||
# Config-based direct aliases (preferred — include provider info)
|
||
try:
|
||
from hermes_cli.model_switch import (
|
||
_ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES,
|
||
)
|
||
_ensure_direct_aliases()
|
||
for name, da in DIRECT_ALIASES.items():
|
||
if name.startswith(sub_lower) and name != sub_lower:
|
||
seen.add(name)
|
||
yield Completion(
|
||
name,
|
||
start_position=-len(sub_text),
|
||
display=name,
|
||
display_meta=f"{da.model} ({da.provider})",
|
||
)
|
||
# Built-in catalog aliases not already covered
|
||
for name in sorted(MODEL_ALIASES.keys()):
|
||
if name in seen:
|
||
continue
|
||
if name.startswith(sub_lower) and name != sub_lower:
|
||
identity = MODEL_ALIASES[name]
|
||
yield Completion(
|
||
name,
|
||
start_position=-len(sub_text),
|
||
display=name,
|
||
display_meta=f"{identity.vendor}/{identity.family}",
|
||
)
|
||
except Exception:
|
||
pass
|
||
# LM Studio: surface locally-loaded models. Gated on the user actually
|
||
# having LM Studio configured (env var or auth-store entry) so we
|
||
# don't probe 127.0.0.1 on every keystroke for users who don't use it.
|
||
for name in _lmstudio_completion_models():
|
||
if name in seen:
|
||
continue
|
||
if name.startswith(sub_lower) and name != sub_lower:
|
||
yield Completion(
|
||
name,
|
||
start_position=-len(sub_text),
|
||
display=name,
|
||
display_meta="LM Studio",
|
||
)
|
||
|
||
def get_completions(self, document, complete_event):
|
||
text = document.text_before_cursor
|
||
if not text.startswith("/"):
|
||
# Try @ context completion (Claude Code-style)
|
||
ctx_word = self._extract_context_word(text)
|
||
if ctx_word is not None:
|
||
yield from self._context_completions(ctx_word)
|
||
return
|
||
# Try file path completion for non-slash input
|
||
path_word = self._extract_path_word(text)
|
||
if path_word is not None:
|
||
yield from self._path_completions(path_word)
|
||
return
|
||
|
||
# Check if we're completing a subcommand (base command already typed)
|
||
parts = text.split(maxsplit=1)
|
||
base_cmd = parts[0].lower()
|
||
if len(parts) > 1 or (len(parts) == 1 and text.endswith(" ")):
|
||
sub_text = parts[1] if len(parts) > 1 else ""
|
||
sub_lower = sub_text.lower()
|
||
|
||
# Dynamic completions for commands with runtime lists
|
||
if " " not in sub_text:
|
||
if base_cmd == "/model":
|
||
yield from self._model_completions(sub_text, sub_lower)
|
||
return
|
||
if base_cmd == "/skin":
|
||
yield from self._skin_completions(sub_text, sub_lower)
|
||
return
|
||
if base_cmd == "/personality":
|
||
yield from self._personality_completions(sub_text, sub_lower)
|
||
return
|
||
|
||
# Static subcommand completions
|
||
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
|
||
for sub in SUBCOMMANDS[base_cmd]:
|
||
if sub.startswith(sub_lower) and sub != sub_lower:
|
||
yield Completion(
|
||
sub,
|
||
start_position=-len(sub_text),
|
||
display=sub,
|
||
)
|
||
return
|
||
|
||
word = text[1:]
|
||
|
||
for cmd, desc in COMMANDS.items():
|
||
if not self._command_allowed(cmd):
|
||
continue
|
||
cmd_name = cmd[1:]
|
||
if cmd_name.startswith(word):
|
||
yield Completion(
|
||
self._completion_text(cmd_name, word),
|
||
start_position=-len(word),
|
||
display=cmd,
|
||
display_meta=desc,
|
||
)
|
||
|
||
for cmd, info in self._iter_skill_commands().items():
|
||
cmd_name = cmd[1:]
|
||
if cmd_name.startswith(word):
|
||
description = str(info.get("description", "Skill command"))
|
||
short_desc = description[:50] + ("..." if len(description) > 50 else "")
|
||
yield Completion(
|
||
self._completion_text(cmd_name, word),
|
||
start_position=-len(word),
|
||
display=cmd,
|
||
display_meta=f"⚡ {short_desc}",
|
||
)
|
||
|
||
# Plugin-registered slash commands
|
||
try:
|
||
from hermes_cli.plugins import get_plugin_commands
|
||
for cmd_name, cmd_info in get_plugin_commands().items():
|
||
if cmd_name.startswith(word):
|
||
desc = str(cmd_info.get("description", "Plugin command"))
|
||
short_desc = desc[:50] + ("..." if len(desc) > 50 else "")
|
||
yield Completion(
|
||
self._completion_text(cmd_name, word),
|
||
start_position=-len(word),
|
||
display=f"/{cmd_name}",
|
||
display_meta=f"🔌 {short_desc}",
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Inline auto-suggest (ghost text) for slash commands
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class SlashCommandAutoSuggest(AutoSuggest):
|
||
"""Inline ghost-text suggestions for slash commands and their subcommands.
|
||
|
||
Shows the rest of a command or subcommand in dim text as you type.
|
||
Falls back to history-based suggestions for non-slash input.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
history_suggest: AutoSuggest | None = None,
|
||
completer: SlashCommandCompleter | None = None,
|
||
) -> None:
|
||
self._history = history_suggest
|
||
self._completer = completer # Reuse its model cache
|
||
|
||
def get_suggestion(self, buffer, document):
|
||
text = document.text_before_cursor
|
||
|
||
# Only suggest for slash commands
|
||
if not text.startswith("/"):
|
||
# Fall back to history for regular text
|
||
if self._history:
|
||
return self._history.get_suggestion(buffer, document)
|
||
return None
|
||
|
||
parts = text.split(maxsplit=1)
|
||
base_cmd = parts[0].lower()
|
||
|
||
if len(parts) == 1 and not text.endswith(" "):
|
||
# Still typing the command name: /upd → suggest "ate"
|
||
word = text[1:].lower()
|
||
for cmd in COMMANDS:
|
||
if self._completer is not None and not self._completer._command_allowed(cmd):
|
||
continue
|
||
cmd_name = cmd[1:] # strip leading /
|
||
if cmd_name.startswith(word) and cmd_name != word:
|
||
return Suggestion(cmd_name[len(word):])
|
||
return None
|
||
|
||
# Command is complete — suggest subcommands or model names
|
||
sub_text = parts[1] if len(parts) > 1 else ""
|
||
sub_lower = sub_text.lower()
|
||
|
||
# Static subcommands
|
||
if self._completer is not None and not self._completer._command_allowed(base_cmd):
|
||
return None
|
||
if base_cmd in SUBCOMMANDS and SUBCOMMANDS[base_cmd]:
|
||
if " " not in sub_text:
|
||
for sub in SUBCOMMANDS[base_cmd]:
|
||
if sub.startswith(sub_lower) and sub != sub_lower:
|
||
return Suggestion(sub[len(sub_text):])
|
||
|
||
# Fall back to history
|
||
if self._history:
|
||
return self._history.get_suggestion(buffer, document)
|
||
return None
|
||
|
||
|
||
def _file_size_label(path: str) -> str:
|
||
"""Return a compact human-readable file size, or '' on error."""
|
||
try:
|
||
size = os.path.getsize(path)
|
||
except OSError:
|
||
return ""
|
||
if size < 1024:
|
||
return f"{size}B"
|
||
if size < 1024 * 1024:
|
||
return f"{size / 1024:.0f}K"
|
||
if size < 1024 * 1024 * 1024:
|
||
return f"{size / (1024 * 1024):.1f}M"
|
||
return f"{size / (1024 * 1024 * 1024):.1f}G"
|