tools/terminal_tool.py: drop 30 pure re-export names (lifecycle/config/backends/
sudo/guards/result/interrupt/utils/_DockerEnvironment/is_managed_tool_gateway_ready)
and the noqa-F401 comments on the 25 names the facade itself uses. Sibling modules
(terminal_tool_backends/_result/_sudo/_lifecycle, environments/base, process_registry)
that read removed names through the facade now import from the defining module.
tools/environments/base.py: drop 11 re-exports (base_output/base_session_env/
path_utils) and the BaseEnvironment.stop() compat alias (no in-tree caller; the
lifecycle hasattr(env, 'stop') fallback stays for third-party envs).
tools/environments/docker.py: drop 1 re-export + the re-export comment.
Callers/tests repointed to tools.terminal_tool_{lifecycle,backends,sudo,config,
guards,result}, tools.interrupt, tools.environments.{base_output,base_session_env,
path_utils}.
Part A — pre_command observer hook (observer-first per #64182 ground rule 3):
- New VALID_HOOKS event `pre_command`: fires when a recognized slash command
is about to be dispatched, BEFORE the handler runs, on both surfaces:
- CLI: cli.py process_command (right after alias resolution)
- Gateway: gateway/run.py _handle_message cold-path canonical dispatch
- Payload: surface ('cli'|'gateway'), command (canonical), alias_used,
args_raw, session_key, platform. Return values IGNORED in v1; a plugin
returning a directive-shaped dict gets a debug log so future
block/rewrite adopters are discoverable (#64231 taxonomy).
- Deliberately NOT fired on the gateway running-agent intercept path
(/stop, /approve, busy_policy dispatch during an active run): those are
control-plane escape hatches on an in-flight run and must stay outside
plugin observation/veto reach.
- fire_pre_command_hook() helper never raises, so broken plugin infra can
never break command dispatch.
Part B — ctx.call_mcp (capability-gated, default-off, ground rule 4):
- PluginContext.call_mcp(server, tool, arguments, timeout=30): synchronous,
callable from plugin hooks/tools, routes through the EXISTING native MCP
client machinery (tools.mcp_tool._make_tool_handler: background loop,
trust-tier gates, circuit breaker, reconnect) — never a parallel client.
- Gate: plugins.entries.<id>.mcp_allowlist (list of server names).
Absent key / unreadable config / non-list value => default-deny.
Unlisted server raises PermissionError naming the exact config key.
TODO seam left for the #64228 declared-capability model.
- Bounded: timeout clamped to 1-600s and forwarded to the MCP loop call;
results capped at 64KB with truncation marker; stable
{ok, result|error, structuredContent?, truncated?} envelope.
Tests (transport mocked, no live MCP servers):
- tests/hermes_cli/test_pre_command_hook.py: both surfaces fire, canonical
alias reporting (/exit->quit, /q->queue), hook-before-handler ordering,
control-plane exclusion, hook failure non-fatal, observer-only directive
handling.
- tests/hermes_cli/test_plugin_call_mcp.py: default-deny (absent entry,
unreadable config, non-list, '*'), allowlist enforced per-server,
denied calls never touch transport, timeout forwarding/clamping,
result truncation, error/structuredContent envelopes.
Docs: hooks.md shipped-catalog row for pre_command; plugins.md
"Calling MCP servers from plugins" section with the security note.
Closes#64204