diff --git a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx index 76fdf79f809..921ec485ae3 100644 --- a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx +++ b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx @@ -24,6 +24,7 @@ afterEach(cleanup) // state stays stale while the DOM already holds the text. function Harness({ busy = false, + disabled = false, queued = [], onSubmit, onQueue, @@ -31,6 +32,7 @@ function Harness({ onDrain }: { busy?: boolean + disabled?: boolean queued?: readonly string[] onSubmit: (text: string) => void onQueue: (text: string) => void @@ -52,6 +54,10 @@ function Harness({ } const submitDraft = () => { + if (disabled) { + return + } + const editor = editorRef.current if (editor) { const domText = composerPlainText(editor) @@ -84,6 +90,10 @@ function Harness({ const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0 + if (disabled) { + return + } + if (!busy && !hasLivePayload && queued.length > 0) { onDrain() @@ -186,4 +196,23 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)', expect(onDrain).toHaveBeenCalledTimes(1) expect(onSubmit).not.toHaveBeenCalled() }) + + it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => { + const onSubmit = vi.fn() + const onDrain = vi.fn() + const { getByTestId } = render( + + ) + const editor = getByTestId('editor') + + await act(async () => { + editor.textContent = 'draft while reconnecting' + fireEvent.input(editor) + fireEvent.keyDown(editor, { key: 'Enter' }) + }) + + expect(editor.textContent).toBe('draft while reconnecting') + expect(onDrain).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) }) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 87cbeb80b6c..595df800805 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -247,6 +247,8 @@ export function ChatBar({ const gatewayState = useStore($gatewayState) const newSessionPlaceholders = t.composer.newSessionPlaceholders const followUpPlaceholders = t.composer.followUpPlaceholders + const reconnecting = gatewayState === 'closed' || gatewayState === 'error' + const inputDisabled = disabled && !reconnecting // Resting placeholder: a starter for brand-new sessions, a continuation for // existing ones. Picked once and only re-rolled when we genuinely move to a @@ -277,11 +279,13 @@ export function ChatBar({ setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)) }, [followUpPlaceholders, newSessionPlaceholders, sessionId]) - // When the bar is disabled it's because the gateway isn't open. Distinguish a - // cold start ("Starting Hermes...") from a dropped connection we're trying to - // restore (e.g. after the Mac slept) so the stuck state reads as recoverable. + // When the transport is disabled it's because the gateway isn't open. + // Distinguish a cold start ("Starting Hermes...") from a dropped connection + // we're trying to restore. During reconnect, keep the textbox editable so a + // flaky network doesn't block drafting; only submit/backend actions stay + // disabled until the gateway is open again. const placeholder = disabled - ? gatewayState === 'closed' || gatewayState === 'error' + ? reconnecting ? t.composer.placeholderReconnecting : t.composer.placeholderStarting : restingPlaceholder @@ -323,13 +327,13 @@ export function ChatBar({ ) useEffect(() => { - if (!disabled) { + if (!inputDisabled) { focusInput() } - }, [disabled, focusInput, focusKey, focusRequestId]) + }, [focusInput, focusKey, focusRequestId, inputDisabled]) useEffect(() => { - if (disabled) { + if (inputDisabled) { return undefined } @@ -349,7 +353,7 @@ export function ChatBar({ offFocus() offInsert() } - }, [appendExternalText, disabled]) + }, [appendExternalText, inputDisabled]) // Keep draftRef in sync with the assistant-ui composer state for callers // that read the latest text outside the React render cycle. We don't push @@ -934,6 +938,10 @@ export function ChatBar({ const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0 + if (disabled) { + return + } + if (!busy && !hasLivePayload && queuedPrompts.length > 0) { void drainNextQueued() @@ -1476,6 +1484,10 @@ export function ChatBar({ } const submitDraft = () => { + if (disabled) { + return + } + // Source the text from the DOM editor, not React state. The AUI composer // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a // render, so on fast typing or IME composition the final keystroke(s) may @@ -1656,6 +1668,7 @@ export function ChatBar({ const input = (
window.setTimeout(closeTrigger, 80)} diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx index 5e35a2b2679..88dad33b1bd 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx @@ -3,23 +3,23 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { $desktopBoot } from '@/store/boot' import { $desktopOnboarding } from '@/store/onboarding' -import { $gatewayState, setGatewayState } from '@/store/session' +import { setGatewayState } from '@/store/session' import { BootFailureOverlay } from './boot-failure-overlay' import { GatewayConnectingOverlay } from './gateway-connecting-overlay' // Repro for the "remote gateway → stuck on CONNECTING, no way to settings" -// report. The connecting overlay (z-1200, full-screen, pointer-events on) is -// shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape +// report. The connecting overlay (z-1200, full-screen, pointer-events on) used +// to be shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape // hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" / // "Retry" — only renders when `boot.error` is set. // // useGatewayBoot only calls failDesktopBoot() (which sets boot.error) when the // INITIAL boot() throws. After the first successful connect (bootCompleted), // any later socket drop goes through scheduleReconnect(), which loops FOREVER -// against the dead remote and never sets boot.error. So gatewayState sits at -// 'closed'/'error' with boot.error null → CONNECTING forever, recovery overlay -// never appears, settings unreachable. +// against the dead remote. So gatewayState sits at 'closed'/'error' with +// boot.error null. The fix keeps the initial-boot overlay out of post-boot +// reconnects, leaving chat/settings usable while the reconnect loop runs. function resetStores() { setGatewayState('idle') @@ -75,7 +75,7 @@ describe('connecting overlay vs recovery surface', () => { expect(isConnectingShown()).toBe(false) }) - it('REPRO: remote socket drops AFTER a successful boot → stuck on CONNECTING, no recovery, no settings', () => { + it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', () => { // 1. Initial boot succeeded: gateway opened, boot completed (no error). setGatewayState('open') const { rerender } = render( @@ -97,14 +97,14 @@ describe('connecting overlay vs recovery surface', () => { ) - // The connecting overlay reappears and latches... - expect(isConnectingShown()).toBe(true) - // ...with NO recovery surface, because boot.error was never set. + // The initial-boot connecting overlay stays out of the way, so settings and + // the composer remain reachable during the reconnect loop. + expect(isConnectingShown()).toBe(false) expect(isRecoveryShown()).toBe(false) - // 3. Reconnect loops forever against the dead remote: gatewayState bounces - // closed → error → closed, boot.error never gets set. The user is - // pinned on CONNECTING with no path to Settings indefinitely. + // 3. Reconnect loops against the dead remote: gatewayState bounces closed + // → error → closed. Until the escalation path sets boot.error, the app + // remains usable instead of modal-blocked. setGatewayState('error') rerender( <> @@ -113,7 +113,7 @@ describe('connecting overlay vs recovery surface', () => { ) expect($desktopBoot.get().error).toBeNull() - expect(isConnectingShown()).toBe(true) + expect(isConnectingShown()).toBe(false) expect(isRecoveryShown()).toBe(false) }) diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index 2b442b7f74d..bff722b9a28 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -52,7 +52,13 @@ export function GatewayConnectingOverlay() { const [tail, setTail] = useState(TAIL) const [phase, setPhase] = useState('live') - const connecting = gatewayState !== 'open' && !boot.error + // The full-screen connecting overlay is for initial boot only. After a + // healthy boot, flaky networks / sleep-wake can drop the socket and flip the + // gateway state back to closed/error while the app reconnects. Do not cover + // the chat then — users should still be able to type drafts, open settings, + // and recover instead of staring at a modal CONNECTING screen. + const initialBootActive = boot.visible || boot.running || boot.progress < 100 + const connecting = gatewayState !== 'open' && !boot.error && initialBootActive // Latches once we've actually shown the overlay, so the brief frame where // gatewayState flips to "open" (connecting -> false) before the exit phase // kicks in doesn't unmount us and cause a flash. diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 870ed1b656c..521c5fcf91d 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -213,6 +213,13 @@ def build_top_level_parser(): default=False, help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills", ) + _inherited_flag( + parser, + "--safe-mode", + action="store_true", + default=False, + help="Troubleshooting mode: disable ALL customizations — user config, AGENTS.md/memory injection, plugins, and MCP servers (implies --ignore-user-config and --ignore-rules)", + ) _inherited_flag( parser, "--tui", @@ -366,6 +373,13 @@ def build_top_level_parser(): default=argparse.SUPPRESS, help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.", ) + _inherited_flag( + chat_parser, + "--safe-mode", + action="store_true", + default=argparse.SUPPRESS, + help="Troubleshooting mode: disable ALL customizations — user config, AGENTS.md/memory injection, plugins, and MCP servers (implies --ignore-user-config and --ignore-rules). Use to isolate whether a problem comes from your setup or from Hermes itself.", + ) chat_parser.add_argument( "--source", default=None, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f2ad89f7601..3d716e8eff5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2199,6 +2199,18 @@ def cmd_chat(args): if getattr(args, "yolo", False): os.environ["HERMES_YOLO_MODE"] = "1" + # --safe-mode: troubleshooting mode that disables ALL customizations. + # Inspired by Claude Code v2.1.169's --safe-mode (June 2026): run with a + # pristine environment to isolate whether a problem comes from the user's + # setup (config, rules files, plugins, MCP servers) or from Hermes itself. + # Implemented as a superset of --ignore-user-config + --ignore-rules plus + # plugin/MCP discovery suppression (HERMES_SAFE_MODE is checked by + # hermes_cli/plugins.py and tools/mcp_tool.py). + if getattr(args, "safe_mode", False): + os.environ["HERMES_SAFE_MODE"] = "1" + os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" + os.environ["HERMES_IGNORE_RULES"] = "1" + # --ignore-user-config: make load_cli_config() / load_config() skip the # user's ~/.hermes/config.yaml and return built-in defaults. Set BEFORE # importing cli (which runs `CLI_CONFIG = load_cli_config()` at module @@ -2256,8 +2268,8 @@ def cmd_chat(args): "checkpoints": getattr(args, "checkpoints", False), "pass_session_id": getattr(args, "pass_session_id", False), "max_turns": getattr(args, "max_turns", None), - "ignore_rules": getattr(args, "ignore_rules", False), - "ignore_user_config": getattr(args, "ignore_user_config", False), + "ignore_rules": getattr(args, "ignore_rules", False) or getattr(args, "safe_mode", False), + "ignore_user_config": getattr(args, "ignore_user_config", False) or getattr(args, "safe_mode", False), "compact": getattr(args, "compact", False), } # Filter out None values diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 8d1e3ca9e80..25bf83af302 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -1124,6 +1124,14 @@ class PluginManager: """ if self._discovered and not force: return + # Safe mode (--safe-mode / HERMES_SAFE_MODE=1): troubleshooting run + # with all customizations disabled. Skip plugin discovery entirely so + # no third-party code (hooks, tools, platforms) loads. Mark as + # discovered so callers see a clean empty registry, not a retry loop. + if env_var_enabled("HERMES_SAFE_MODE"): + logger.info("HERMES_SAFE_MODE=1 — plugin discovery skipped") + self._discovered = True + return if force: self._plugins.clear() self._hooks.clear() diff --git a/tests/hermes_cli/test_safe_mode.py b/tests/hermes_cli/test_safe_mode.py new file mode 100644 index 00000000000..2c52786facb --- /dev/null +++ b/tests/hermes_cli/test_safe_mode.py @@ -0,0 +1,130 @@ +"""Tests for `hermes chat --safe-mode` — pristine troubleshooting runs. + +Inspired by Claude Code v2.1.169's ``--safe-mode`` flag (June 2026), which +disables all customizations (CLAUDE.md, plugins, skills, hooks, MCP) for +troubleshooting. The Hermes equivalent: + +* implies ``--ignore-user-config`` (built-in config defaults) +* implies ``--ignore-rules`` (no AGENTS.md/memory/preloaded-skill injection) +* skips plugin discovery entirely (``hermes_cli.plugins``) +* loads zero MCP servers (``tools.mcp_tool._load_mcp_config``) +""" + +from __future__ import annotations + +import os + +import pytest + + +_VARS = ("HERMES_SAFE_MODE", "HERMES_IGNORE_USER_CONFIG", "HERMES_IGNORE_RULES") + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + for var in _VARS: + monkeypatch.delenv(var, raising=False) + yield + for var in _VARS: + os.environ.pop(var, None) + + +class TestSafeModeEnvWiring: + """cmd_chat must translate --safe-mode into the three env gates.""" + + def test_safe_mode_sets_all_gates(self): + # Mirrors the cmd_chat logic in hermes_cli/main.py. + class Args: + safe_mode = True + + args = Args() + if getattr(args, "safe_mode", False): + os.environ["HERMES_SAFE_MODE"] = "1" + os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" + os.environ["HERMES_IGNORE_RULES"] = "1" + + assert os.environ.get("HERMES_SAFE_MODE") == "1" + assert os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1" + assert os.environ.get("HERMES_IGNORE_RULES") == "1" + + +class TestSafeModePluginDiscovery: + """Plugin discovery must be a no-op under HERMES_SAFE_MODE=1.""" + + def test_discovery_skipped(self, monkeypatch): + monkeypatch.setenv("HERMES_SAFE_MODE", "1") + from hermes_cli.plugins import PluginManager + + mgr = PluginManager() + called = [] + monkeypatch.setattr( + mgr, "_discover_and_load_inner", lambda: called.append(True) + ) + mgr.discover_and_load() + assert called == [] # inner sweep never ran + assert mgr._discovered is True # registry settled as clean-empty + assert mgr._plugins == {} + + def test_discovery_runs_without_safe_mode(self, monkeypatch): + monkeypatch.delenv("HERMES_SAFE_MODE", raising=False) + from hermes_cli.plugins import PluginManager + + mgr = PluginManager() + called = [] + monkeypatch.setattr( + mgr, "_discover_and_load_inner", lambda: called.append(True) + ) + mgr.discover_and_load() + assert called == [True] + + +class TestSafeModeMCP: + """_load_mcp_config must return no servers under HERMES_SAFE_MODE=1.""" + + def test_mcp_servers_empty(self, monkeypatch): + monkeypatch.setenv("HERMES_SAFE_MODE", "1") + from tools.mcp_tool import _load_mcp_config + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "hermes_cli.config.load_config", + lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}}, + ) + assert _load_mcp_config() == {} + + def test_mcp_servers_load_without_safe_mode(self, monkeypatch): + monkeypatch.delenv("HERMES_SAFE_MODE", raising=False) + from tools.mcp_tool import _load_mcp_config + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "hermes_cli.config.load_config", + lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}}, + ) + servers = _load_mcp_config() + assert "github" in servers + + +class TestSafeModeParser: + """--safe-mode must parse on both the root parser and `hermes chat`.""" + + def test_chat_subcommand_accepts_flag(self): + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, _chat = build_top_level_parser() + args = parser.parse_args(["chat", "--safe-mode"]) + assert getattr(args, "safe_mode", False) is True + + def test_root_parser_accepts_flag(self): + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, _chat = build_top_level_parser() + args = parser.parse_args(["--safe-mode"]) + assert getattr(args, "safe_mode", False) is True + + def test_default_is_off(self): + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, _chat = build_top_level_parser() + args = parser.parse_args(["chat"]) + assert getattr(args, "safe_mode", False) is False diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 0cc4907ea72..1317b14dc35 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -2686,6 +2686,11 @@ def _load_mcp_config() -> Dict[str, dict]: """ try: from hermes_cli.config import load_config + # Safe mode (--safe-mode / HERMES_SAFE_MODE=1): troubleshooting run + # with all customizations disabled — no MCP servers connect. + from utils import env_var_enabled as _env_enabled + if _env_enabled("HERMES_SAFE_MODE"): + return {} config = load_config() servers = config.get("mcp_servers") if not servers or not isinstance(servers, dict): diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index bea0d2fc1cb..9cd9be7441a 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -112,6 +112,7 @@ Common options: | `--pass-session-id` | Pass the session ID into the system prompt. | | `--ignore-user-config` | Ignore `~/.hermes/config.yaml` and use built-in defaults. Credentials in `.env` are still loaded. Useful for isolated CI runs, reproducible bug reports, and third-party integrations. | | `--ignore-rules` | Skip auto-injection of `AGENTS.md`, `SOUL.md`, `.cursorrules`, persistent memory, and preloaded skills. Combine with `--ignore-user-config` for a fully isolated run. | +| `--safe-mode` | Troubleshooting mode: disable ALL customizations — user config, rules/memory injection, plugins, and MCP servers (implies `--ignore-user-config` and `--ignore-rules`). Use to isolate whether a problem comes from your setup or from Hermes itself. | | `--source ` | Session source tag for filtering (default: `cli`). Use `tool` for third-party integrations that should not appear in user session lists. | | `--max-turns ` | Maximum tool-calling iterations per conversation turn (default: 90, or `agent.max_turns` in config). | @@ -125,6 +126,7 @@ hermes chat --toolsets web,terminal,skills hermes chat --quiet -q "Return only JSON" hermes chat --worktree -q "Review this repo and open a PR" hermes chat --ignore-user-config --ignore-rules -q "Repro without my personal setup" +hermes chat --safe-mode -q "Is this bug mine or Hermes'?" ``` ### `hermes -z ` — scripted one-shot diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index a22499733e9..2419846a10f 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -595,6 +595,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us | `HERMES_ACCEPT_HOOKS` | Auto-approve any unseen shell hooks declared in `config.yaml` without a TTY prompt. Equivalent to `--accept-hooks` or `hooks_auto_accept: true`. | | `HERMES_IGNORE_USER_CONFIG` | Skip `~/.hermes/config.yaml` and use built-in defaults (credentials in `.env` still load). Equivalent to `--ignore-user-config`. | | `HERMES_IGNORE_RULES` | Skip auto-injection of `AGENTS.md`, `SOUL.md`, `.cursorrules`, memory, and preloaded skills. Equivalent to `--ignore-rules`. | +| `HERMES_SAFE_MODE` | Troubleshooting mode: disable ALL customizations — skips plugin discovery and MCP server loading. Set automatically by `--safe-mode` (which also sets the two flags above). | | `HERMES_MD_NAMES` | Comma-separated list of rules-file names to auto-inject (default: `AGENTS.md,CLAUDE.md,.cursorrules,SOUL.md`). | | `HERMES_TOOL_PROGRESS` | Deprecated compatibility variable for tool progress display. Prefer `display.tool_progress` in `config.yaml`. | | `HERMES_TOOL_PROGRESS_MODE` | Deprecated compatibility variable for tool progress mode. Prefer `display.tool_progress` in `config.yaml`. |