Add a pooled credential
+ hermes auth list List pooled credentials
+ hermes auth remove Remove pooled credential by index, id, or label
+ hermes auth reset Clear exhaustion status for a provider
+ hermes model Select default model
+ hermes fallback [list] Show fallback provider chain
+ hermes fallback add Add a fallback provider (same picker as `hermes model`)
+ hermes fallback remove Remove a fallback provider from the chain
+ hermes config View configuration
+ hermes config edit Edit config in $EDITOR
+ hermes config set model gpt-4 Set a config value
+ hermes gateway Run messaging gateway
+ hermes -s hermes-agent-dev,github-auth
+ hermes -w Start in isolated git worktree
+ hermes gateway install Install gateway background service
+ hermes sessions list List past sessions
+ hermes sessions browse Interactive session picker
+ hermes sessions rename ID T Rename/title a session
+ hermes logs View agent.log (last 50 lines)
+ hermes logs -f Follow agent.log in real time
+ hermes logs errors View errors.log
+ hermes logs --since 1h Lines from the last hour
+ hermes debug share Upload debug report for support
+ hermes update Update to latest version
+
+For more help on a command:
+ hermes --help
+"""
+
+
+def build_top_level_parser():
+ """Build the top-level parser, the subparsers action, and the ``chat`` subparser.
+
+ Returns ``(parser, subparsers, chat_parser)``. The caller wires
+ ``chat_parser.set_defaults(func=cmd_chat)`` and continues registering
+ other subparsers via ``subparsers.add_parser(...)``.
+ """
+ parser = argparse.ArgumentParser(
+ prog="hermes",
+ description="Hermes Agent - AI assistant with tool-calling capabilities",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=_EPILOGUE,
+ )
+
+ parser.add_argument(
+ "--version", "-V", action="store_true", help="Show version and exit"
+ )
+ parser.add_argument(
+ "-z",
+ "--oneshot",
+ metavar="PROMPT",
+ default=None,
+ help=(
+ "One-shot mode: send a single prompt and print ONLY the final "
+ "response text to stdout. No banner, no spinner, no tool "
+ "previews, no session_id line. Tools, memory, rules, and "
+ "AGENTS.md in the CWD are loaded as normal; approvals are "
+ "auto-bypassed. Intended for scripts / pipes."
+ ),
+ )
+ # --model / --provider are accepted at the top level so they can pair
+ # with -z without needing the `chat` subcommand. If neither -z nor a
+ # subcommand consumes them, they fall through harmlessly as None.
+ # Mirrors `hermes chat --model ... --provider ...` semantics.
+ _inherited_flag(
+ parser,
+ "-m",
+ "--model",
+ default=None,
+ help=(
+ "Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
+ "Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
+ ),
+ )
+ _inherited_flag(
+ parser,
+ "--provider",
+ default=None,
+ help=(
+ "Provider override for this invocation (e.g. openrouter, anthropic). "
+ "Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
+ ),
+ )
+ parser.add_argument(
+ "-t",
+ "--toolsets",
+ default=None,
+ help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.",
+ )
+ parser.add_argument(
+ "--resume",
+ "-r",
+ metavar="SESSION",
+ default=None,
+ help="Resume a previous session by ID or title",
+ )
+ parser.add_argument(
+ "--continue",
+ "-c",
+ dest="continue_last",
+ nargs="?",
+ const=True,
+ default=None,
+ metavar="SESSION_NAME",
+ help="Resume a session by name, or the most recent if no name given",
+ )
+ parser.add_argument(
+ "--worktree",
+ "-w",
+ action="store_true",
+ default=False,
+ help="Run in an isolated git worktree (for parallel agents)",
+ )
+ _inherited_flag(
+ parser,
+ "--accept-hooks",
+ action="store_true",
+ default=False,
+ help=(
+ "Auto-approve any unseen shell hooks declared in config.yaml "
+ "without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
+ "hooks_auto_accept: true in config.yaml. Use on CI / headless "
+ "runs that can't prompt."
+ ),
+ )
+ _inherited_flag(
+ parser,
+ "--skills",
+ "-s",
+ action="append",
+ default=None,
+ help="Preload one or more skills for the session (repeat flag or comma-separate)",
+ )
+ _inherited_flag(
+ parser,
+ "--yolo",
+ action="store_true",
+ default=False,
+ help="Bypass all dangerous command approval prompts (use at your own risk)",
+ )
+ _inherited_flag(
+ parser,
+ "--pass-session-id",
+ action="store_true",
+ default=False,
+ help="Include the session ID in the agent's system prompt",
+ )
+ _inherited_flag(
+ parser,
+ "--ignore-user-config",
+ action="store_true",
+ default=False,
+ help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
+ )
+ _inherited_flag(
+ parser,
+ "--ignore-rules",
+ action="store_true",
+ default=False,
+ help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
+ )
+ _inherited_flag(
+ parser,
+ "--tui",
+ action="store_true",
+ default=False,
+ help="Launch the modern TUI instead of the classic REPL",
+ )
+ _inherited_flag(
+ parser,
+ "--dev",
+ dest="tui_dev",
+ action="store_true",
+ default=False,
+ help="With --tui: run TypeScript sources via tsx (skip dist build)",
+ )
+
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
+
+ # =========================================================================
+ # chat command
+ # =========================================================================
+ chat_parser = subparsers.add_parser(
+ "chat",
+ help="Interactive chat with the agent",
+ description="Start an interactive chat session with Hermes Agent",
+ )
+ chat_parser.add_argument(
+ "-q", "--query", help="Single query (non-interactive mode)"
+ )
+ chat_parser.add_argument(
+ "--image", help="Optional local image path to attach to a single query"
+ )
+ _inherited_flag(
+ chat_parser,
+ "-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)",
+ )
+ chat_parser.add_argument(
+ "-t", "--toolsets", help="Comma-separated toolsets to enable"
+ )
+ _inherited_flag(
+ chat_parser,
+ "-s",
+ "--skills",
+ action="append",
+ default=argparse.SUPPRESS,
+ help="Preload one or more skills for the session (repeat flag or comma-separate)",
+ )
+ _inherited_flag(
+ chat_parser,
+ "--provider",
+ # No `choices=` here: user-defined providers from config.yaml `providers:`
+ # are also valid values, and runtime resolution (resolve_runtime_provider)
+ # handles validation/error reporting consistently with the top-level
+ # `--provider` flag.
+ default=None,
+ help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
+ )
+ chat_parser.add_argument(
+ "-v", "--verbose", action="store_true", help="Verbose output"
+ )
+ chat_parser.add_argument(
+ "-Q",
+ "--quiet",
+ action="store_true",
+ help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.",
+ )
+ chat_parser.add_argument(
+ "--resume",
+ "-r",
+ metavar="SESSION_ID",
+ default=argparse.SUPPRESS,
+ help="Resume a previous session by ID (shown on exit)",
+ )
+ chat_parser.add_argument(
+ "--continue",
+ "-c",
+ dest="continue_last",
+ nargs="?",
+ const=True,
+ default=argparse.SUPPRESS,
+ metavar="SESSION_NAME",
+ help="Resume a session by name, or the most recent if no name given",
+ )
+ chat_parser.add_argument(
+ "--worktree",
+ "-w",
+ action="store_true",
+ default=argparse.SUPPRESS,
+ help="Run in an isolated git worktree (for parallel agents on the same repo)",
+ )
+ _inherited_flag(
+ chat_parser,
+ "--accept-hooks",
+ action="store_true",
+ default=argparse.SUPPRESS,
+ help=(
+ "Auto-approve any unseen shell hooks declared in config.yaml "
+ "without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
+ "hooks_auto_accept: in config.yaml)."
+ ),
+ )
+ chat_parser.add_argument(
+ "--checkpoints",
+ action="store_true",
+ default=False,
+ help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
+ )
+ chat_parser.add_argument(
+ "--max-turns",
+ type=int,
+ default=None,
+ metavar="N",
+ help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)",
+ )
+ _inherited_flag(
+ chat_parser,
+ "--yolo",
+ action="store_true",
+ default=argparse.SUPPRESS,
+ help="Bypass all dangerous command approval prompts (use at your own risk)",
+ )
+ _inherited_flag(
+ chat_parser,
+ "--pass-session-id",
+ action="store_true",
+ default=argparse.SUPPRESS,
+ help="Include the session ID in the agent's system prompt",
+ )
+ _inherited_flag(
+ chat_parser,
+ "--ignore-user-config",
+ action="store_true",
+ default=argparse.SUPPRESS,
+ help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
+ )
+ _inherited_flag(
+ chat_parser,
+ "--ignore-rules",
+ action="store_true",
+ 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.",
+ )
+ chat_parser.add_argument(
+ "--source",
+ default=None,
+ help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
+ )
+ _inherited_flag(
+ chat_parser,
+ "--tui",
+ action="store_true",
+ default=False,
+ help="Launch the modern TUI instead of the classic REPL",
+ )
+ _inherited_flag(
+ chat_parser,
+ "--dev",
+ dest="tui_dev",
+ action="store_true",
+ default=False,
+ help="With --tui: run TypeScript sources via tsx (skip dist build)",
+ )
+
+ return parser, subparsers, chat_parser
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index 482e3c47a20..5ff5638b91e 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -43,6 +43,7 @@
from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config
from hermes_constants import OPENROUTER_BASE_URL
+from utils import atomic_replace, atomic_yaml_write, is_truthy_value
logger = logging.getLogger(__name__)
@@ -71,6 +72,14 @@
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
+MINIMAX_OAUTH_CLIENT_ID = "78257093-7e40-4613-99e0-527b14b39113"
+MINIMAX_OAUTH_SCOPE = "group_id profile model.completion"
+MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code"
+MINIMAX_OAUTH_GLOBAL_BASE = "https://api.minimax.io"
+MINIMAX_OAUTH_CN_BASE = "https://api.minimaxi.com"
+MINIMAX_OAUTH_GLOBAL_INFERENCE = "https://api.minimax.io/anthropic"
+MINIMAX_OAUTH_CN_INFERENCE = "https://api.minimaxi.com/anthropic"
+MINIMAX_OAUTH_REFRESH_SKEW_SECONDS = 60
DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
@@ -109,6 +118,12 @@
DEFAULT_GEMINI_CLOUDCODE_BASE_URL = "cloudcode-pa://google"
GEMINI_OAUTH_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60 # refresh 60s before expiry
+# LM Studio's default no-auth mode still requires *some* non-empty bearer for
+# the API-key code paths (auxiliary_client, runtime resolver) to treat the
+# provider as configured. This sentinel is sent only to LM Studio, never to
+# any remote service.
+LMSTUDIO_NOAUTH_PLACEHOLDER = "dummy-lm-api-key"
+
# =============================================================================
# Provider Registry
@@ -119,7 +134,7 @@ class ProviderConfig:
"""Describes a known inference provider."""
id: str
name: str
- auth_type: str # "oauth_device_code", "oauth_external", or "api_key"
+ auth_type: str # "oauth_device_code", "oauth_external", "oauth_minimax", or "api_key"
portal_base_url: str = ""
inference_base_url: str = ""
client_id: str = ""
@@ -159,6 +174,14 @@ class ProviderConfig:
auth_type="oauth_external",
inference_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
),
+ "lmstudio": ProviderConfig(
+ id="lmstudio",
+ name="LM Studio",
+ auth_type="api_key",
+ inference_base_url="http://127.0.0.1:1234/v1",
+ api_key_env_vars=("LM_API_KEY",),
+ base_url_env_var="LM_BASE_URL",
+ ),
"copilot": ProviderConfig(
id="copilot",
name="GitHub Copilot",
@@ -224,6 +247,14 @@ class ProviderConfig:
api_key_env_vars=("ARCEEAI_API_KEY",),
base_url_env_var="ARCEE_BASE_URL",
),
+ "gmi": ProviderConfig(
+ id="gmi",
+ name="GMI Cloud",
+ auth_type="api_key",
+ inference_base_url="https://api.gmi-serving.com/v1",
+ api_key_env_vars=("GMI_API_KEY",),
+ base_url_env_var="GMI_BASE_URL",
+ ),
"minimax": ProviderConfig(
id="minimax",
name="MiniMax",
@@ -232,6 +263,17 @@ class ProviderConfig:
api_key_env_vars=("MINIMAX_API_KEY",),
base_url_env_var="MINIMAX_BASE_URL",
),
+ "minimax-oauth": ProviderConfig(
+ id="minimax-oauth",
+ name="MiniMax (OAuth \u00b7 minimax.io)",
+ auth_type="oauth_minimax",
+ portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE,
+ inference_base_url=MINIMAX_OAUTH_GLOBAL_INFERENCE,
+ client_id=MINIMAX_OAUTH_CLIENT_ID,
+ scope=MINIMAX_OAUTH_SCOPE,
+ extra={"region": "global", "cn_portal_base_url": MINIMAX_OAUTH_CN_BASE,
+ "cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE},
+ ),
"anthropic": ProviderConfig(
id="anthropic",
name="Anthropic",
@@ -340,6 +382,14 @@ class ProviderConfig:
api_key_env_vars=("XIAOMI_API_KEY",),
base_url_env_var="XIAOMI_BASE_URL",
),
+ "tencent-tokenhub": ProviderConfig(
+ id="tencent-tokenhub",
+ name="Tencent TokenHub",
+ auth_type="api_key",
+ inference_base_url="https://tokenhub.tencentmaas.com/v1",
+ api_key_env_vars=("TOKENHUB_API_KEY",),
+ base_url_env_var="TOKENHUB_BASE_URL",
+ ),
"ollama-cloud": ProviderConfig(
id="ollama-cloud",
name="Ollama Cloud",
@@ -366,6 +416,40 @@ class ProviderConfig:
),
}
+# Auto-extend PROVIDER_REGISTRY with any api-key provider registered in
+# providers/ that is not already declared above. New providers only need a
+# plugins/model-providers// plugin — no edits to this file required.
+try:
+ from providers import list_providers as _list_providers_for_registry
+ for _pp in _list_providers_for_registry():
+ if _pp.name in PROVIDER_REGISTRY:
+ continue
+ if _pp.auth_type != "api_key" or not _pp.env_vars:
+ continue
+ # Skip providers that need custom token resolution or are special-cased
+ # in resolve_provider() (copilot/kimi/zai have bespoke token refresh;
+ # openrouter/custom are aggregator/user-supplied and handled outside
+ # the registry — adding them here breaks runtime_provider resolution
+ # that relies on `openrouter not in PROVIDER_REGISTRY`).
+ if _pp.name in {"copilot", "kimi-coding", "kimi-coding-cn", "zai", "openrouter", "custom"}:
+ continue
+ _api_key_vars = tuple(v for v in _pp.env_vars if not v.endswith("_BASE_URL") and not v.endswith("_URL"))
+ _base_url_var = next((v for v in _pp.env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")), None)
+ PROVIDER_REGISTRY[_pp.name] = ProviderConfig(
+ id=_pp.name,
+ name=_pp.display_name or _pp.name,
+ auth_type="api_key",
+ inference_base_url=_pp.base_url,
+ api_key_env_vars=_api_key_vars or _pp.env_vars,
+ base_url_env_var=_base_url_var or "",
+ )
+ # Also register aliases so resolve_provider() resolves them
+ for _alias in _pp.aliases:
+ if _alias not in PROVIDER_REGISTRY:
+ PROVIDER_REGISTRY[_alias] = PROVIDER_REGISTRY[_pp.name]
+except Exception:
+ pass
+
# =============================================================================
# Anthropic Key Helper
@@ -467,11 +551,27 @@ def _resolve_api_key_provider_secret(
pass
return "", ""
+ from hermes_cli.config import get_env_value
for env_var in pconfig.api_key_env_vars:
- val = os.getenv(env_var, "").strip()
+ # Check both os.environ and ~/.hermes/.env file
+ val = (get_env_value(env_var) or "").strip()
if has_usable_secret(val):
return val, env_var
+ # Fallback: try credential pool (e.g. zai key stored via auth.json)
+ try:
+ from agent.credential_pool import load_pool
+ pool = load_pool(provider_id)
+ if pool and pool.has_credentials():
+ entry = pool.peek()
+ if entry:
+ key = getattr(entry, "access_token", "") or getattr(entry, "runtime_api_key", "")
+ key = str(key).strip()
+ if has_usable_secret(key):
+ return key, f"credential_pool:{provider_id}"
+ except Exception:
+ pass
+
return "", ""
@@ -680,6 +780,73 @@ def _auth_file_path() -> Path:
return path
+def _global_auth_file_path() -> Optional[Path]:
+ """Return the global-root auth.json when the process is in profile mode.
+
+ Returns ``None`` when the profile and global root resolve to the same
+ directory (classic mode, or custom HERMES_HOME that is not a profile).
+ Used by read-only fallback paths so providers authed at the root are
+ visible to profile processes that haven't configured them locally.
+
+ See issue #18594 follow-up (credential_pool shadowing).
+ """
+ try:
+ from hermes_constants import get_default_hermes_root
+ global_root = get_default_hermes_root()
+ except Exception:
+ return None
+ profile_home = get_hermes_home()
+ try:
+ if profile_home.resolve(strict=False) == global_root.resolve(strict=False):
+ return None
+ except Exception:
+ if profile_home == global_root:
+ return None
+ # No pytest seat belt here: this is a pure read-only path, and
+ # ``_load_global_auth_store()`` wraps the read in a try/except so an
+ # unreadable global file can never break the profile process. The
+ # write-side seat belt still lives on ``_auth_file_path()`` where it
+ # belongs (that's what protects the real user's auth store from being
+ # corrupted by a mis-configured test).
+ return global_root / "auth.json"
+
+
+def _load_global_auth_store() -> Dict[str, Any]:
+ """Load the global-root auth store (read-only fallback).
+
+ Returns an empty dict when no global fallback exists (classic mode,
+ or the global auth.json is absent). Never raises on missing file.
+
+ Seat belt: under pytest, refuses to read the real user's
+ ``~/.hermes/auth.json`` even when HERMES_HOME is set to a profile
+ path. The hermetic conftest does not redirect ``HOME``, so
+ ``get_default_hermes_root()`` for a profile-shaped HERMES_HOME can
+ still resolve to the real user's home on a dev machine. That would
+ leak real credentials into tests. This guard uses the unmodified
+ ``HOME`` env var (what ``os.path.expanduser('~')`` would resolve to),
+ not ``Path.home()``, because ``Path.home`` is sometimes monkeypatched
+ by fixtures that want to relocate the global root to a tmp path.
+ """
+ global_path = _global_auth_file_path()
+ if global_path is None or not global_path.exists():
+ return {}
+ if os.environ.get("PYTEST_CURRENT_TEST"):
+ real_home_env = os.environ.get("HOME", "")
+ if real_home_env:
+ real_root = Path(real_home_env) / ".hermes" / "auth.json"
+ try:
+ if global_path.resolve(strict=False) == real_root.resolve(strict=False):
+ return {}
+ except Exception:
+ pass
+ try:
+ return _load_auth_store(global_path)
+ except Exception:
+ # A malformed global store must not break profile reads. The
+ # profile's own auth store is still authoritative.
+ return {}
+
+
def _auth_lock_path() -> Path:
return _auth_file_path().with_suffix(".lock")
@@ -796,7 +963,7 @@ def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
- os.replace(tmp_path, auth_file)
+ atomic_replace(tmp_path, auth_file)
try:
dir_fd = os.open(str(auth_file.parent), os.O_RDONLY)
except OSError:
@@ -866,15 +1033,50 @@ def get_auth_provider_display_name(provider_id: str) -> str:
def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
- """Return the persisted credential pool, or one provider slice."""
+ """Return the persisted credential pool, or one provider slice.
+
+ In profile mode, the profile's credential pool is authoritative. If a
+ provider has no entries in the profile, entries from the global-root
+ ``auth.json`` are used as a read-only fallback — so workers spawned in a
+ profile can see providers that were only authenticated at global scope.
+
+ Profile entries always win: the global fallback only applies per-provider
+ when the profile has zero entries for that provider. Once the user runs
+ ``hermes auth add `` inside the profile, profile entries
+ fully shadow global for that provider on the next read.
+
+ Writes always go to the profile (``write_credential_pool`` is unchanged).
+ See issue #18594 follow-up.
+ """
auth_store = _load_auth_store()
pool = auth_store.get("credential_pool")
if not isinstance(pool, dict):
pool = {}
+
+ global_pool: Dict[str, Any] = {}
+ global_store = _load_global_auth_store()
+ maybe_global_pool = global_store.get("credential_pool") if global_store else None
+ if isinstance(maybe_global_pool, dict):
+ global_pool = maybe_global_pool
+
if provider_id is None:
- return dict(pool)
+ merged = dict(pool)
+ for gp_key, gp_entries in global_pool.items():
+ if not isinstance(gp_entries, list) or not gp_entries:
+ continue
+ # Per-provider shadowing: profile wins whenever it has ANY entries.
+ existing = merged.get(gp_key)
+ if isinstance(existing, list) and existing:
+ continue
+ merged[gp_key] = list(gp_entries)
+ return merged
+
provider_entries = pool.get(provider_id)
- return list(provider_entries) if isinstance(provider_entries, list) else []
+ if isinstance(provider_entries, list) and provider_entries:
+ return list(provider_entries)
+ # Profile has no entries for this provider — fall back to global.
+ global_entries = global_pool.get(provider_id)
+ return list(global_entries) if isinstance(global_entries, list) else []
def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path:
@@ -933,9 +1135,25 @@ def unsuppress_credential_source(provider_id: str, source: str) -> bool:
def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]:
- """Return persisted auth state for a provider, or None."""
+ """Return persisted auth state for a provider, or None.
+
+ In profile mode, falls back to the global-root ``auth.json`` when the
+ profile has no state for this provider. Profile state always wins when
+ present. Writes (``_save_auth_store`` / ``persist_*_credentials``) are
+ unchanged — they still target the profile only. This mirrors
+ ``read_credential_pool``'s per-provider shadowing semantics so that
+ ``_seed_from_singletons`` can reseed a profile's credential pool from
+ global-scope provider state (e.g. a globally-authenticated Anthropic
+ OAuth or Nous device-code session). See issue #18594 follow-up.
+ """
auth_store = _load_auth_store()
- return _load_provider_state(auth_store, provider_id)
+ state = _load_provider_state(auth_store, provider_id)
+ if state is not None:
+ return state
+ global_store = _load_global_auth_store()
+ if not global_store:
+ return None
+ return _load_provider_state(global_store, provider_id)
def get_active_provider() -> Optional[str]:
@@ -1104,7 +1322,9 @@ def resolve_provider(
"kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
"step": "stepfun", "stepfun-coding-plan": "stepfun",
"arcee-ai": "arcee", "arceeai": "arcee",
+ "gmi-cloud": "gmi", "gmicloud": "gmi",
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
+ "minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth",
"alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan",
"alibaba_coding_plan": "alibaba-coding-plan",
"claude": "anthropic", "claude-code": "anthropic",
@@ -1116,15 +1336,28 @@ def resolve_provider(
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "google-gemini-cli": "google-gemini-cli", "gemini-cli": "google-gemini-cli", "gemini-oauth": "google-gemini-cli",
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
+ "tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub",
+ "tencent-cloud": "tencent-tokenhub", "tencentmaas": "tencent-tokenhub",
"aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock",
"go": "opencode-go", "opencode-go-sub": "opencode-go",
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
+ "lmstudio": "lmstudio", "lm-studio": "lmstudio", "lm_studio": "lmstudio",
# Local server aliases — route through the generic custom provider
- "lmstudio": "custom", "lm-studio": "custom", "lm_studio": "custom",
"ollama": "custom", "ollama_cloud": "ollama-cloud",
"vllm": "custom", "llamacpp": "custom",
"llama.cpp": "custom", "llama-cpp": "custom",
}
+ # Extend with aliases declared in plugins/model-providers// that aren't already mapped.
+ # This keeps providers/ as the single source for new aliases while the
+ # hardcoded dict above remains authoritative for existing ones.
+ try:
+ from providers import list_providers as _lp
+ for _pp in _lp():
+ for _alias in _pp.aliases:
+ if _alias not in _PROVIDER_ALIASES:
+ _PROVIDER_ALIASES[_alias] = _pp.name
+ except Exception:
+ pass
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
if normalized == "openrouter":
@@ -1167,8 +1400,11 @@ def resolve_provider(
continue
# GitHub tokens are commonly present for repo/tool access but should not
# hijack inference auto-selection unless the user explicitly chooses
- # Copilot/GitHub Models as the provider.
- if pid == "copilot":
+ # Copilot/GitHub Models as the provider. LM Studio is a local server
+ # whose availability isn't implied by LM_API_KEY presence (it may be
+ # offline, and the no-auth setup uses a placeholder value), so it
+ # also requires explicit selection.
+ if pid in ("copilot", "lmstudio"):
continue
for env_var in pconfig.api_key_env_vars:
if has_usable_secret(os.getenv(env_var, "")):
@@ -2407,8 +2643,8 @@ def _resolve_verify(
tls_state = tls_state if isinstance(tls_state, dict) else {}
effective_insecure = (
- bool(insecure) if insecure is not None
- else bool(tls_state.get("insecure", False))
+ is_truthy_value(insecure, default=False) if insecure is not None
+ else is_truthy_value(tls_state.get("insecure", False), default=False)
)
effective_ca = (
ca_bundle
@@ -2516,6 +2752,208 @@ def _poll_for_token(
# Nous Portal — token refresh, agent key minting, model discovery
# =============================================================================
+# -----------------------------------------------------------------------------
+# Shared Nous token store — lets OAuth credentials persist across profiles
+# so a new `hermes --profile auth add nous --type oauth` can one-tap
+# import instead of running the full device-code flow every time.
+#
+# File lives at ${HERMES_SHARED_AUTH_DIR}/nous_auth.json, defaulting to
+# ~/.hermes/shared/nous_auth.json. It is OUTSIDE any named profile's
+# HERMES_HOME so named profiles (which typically live under
+# ~/.hermes/profiles//) all see the same file.
+#
+# Written on successful login and on every runtime refresh so the stored
+# refresh_token stays current even if one profile refreshes and rotates it.
+# If ever the stored refresh_token does go stale server-side, import fails
+# gracefully and the user falls back to the normal device-code flow.
+# -----------------------------------------------------------------------------
+
+NOUS_SHARED_STORE_FILENAME = "nous_auth.json"
+
+
+def _nous_shared_auth_dir() -> Path:
+ """Resolve the directory that holds the shared Nous token store.
+
+ Honors ``HERMES_SHARED_AUTH_DIR`` so tests can redirect it to a tmp
+ path without touching the real user's home. Defaults to
+ ``~/.hermes/shared/``.
+ """
+ override = os.getenv("HERMES_SHARED_AUTH_DIR", "").strip()
+ if override:
+ return Path(override).expanduser()
+ return Path.home() / ".hermes" / "shared"
+
+
+def _nous_shared_store_path() -> Path:
+ path = _nous_shared_auth_dir() / NOUS_SHARED_STORE_FILENAME
+ # Seat belt: if pytest is running and this resolves to a path under the
+ # real user's home, refuse rather than silently corrupt cross-profile
+ # state. Tests must set HERMES_SHARED_AUTH_DIR to a tmp_path (conftest
+ # does not do this automatically — mirror the _auth_file_path() guard
+ # so forgetting to set it fails loudly instead of writing to the real
+ # shared store).
+ if os.environ.get("PYTEST_CURRENT_TEST"):
+ real_home_shared = (
+ Path.home() / ".hermes" / "shared" / NOUS_SHARED_STORE_FILENAME
+ ).resolve(strict=False)
+ try:
+ resolved = path.resolve(strict=False)
+ except Exception:
+ resolved = path
+ if resolved == real_home_shared:
+ raise RuntimeError(
+ f"Refusing to touch real user shared Nous auth store during test run: "
+ f"{path}. Set HERMES_SHARED_AUTH_DIR to a tmp_path in your test fixture."
+ )
+ return path
+
+
+def _write_shared_nous_state(state: Dict[str, Any]) -> None:
+ """Persist a minimal copy of the Nous OAuth state to the shared store.
+
+ Best-effort: any failure is swallowed after logging. The shared store
+ is a convenience layer; the per-profile auth.json remains the source
+ of truth.
+
+ We deliberately omit the short-lived ``agent_key`` (24h TTL, profile-
+ specific) — only the long-lived OAuth tokens are cross-profile useful.
+ """
+ refresh_token = state.get("refresh_token")
+ access_token = state.get("access_token")
+ if not (isinstance(refresh_token, str) and refresh_token.strip()):
+ # No refresh_token = nothing worth sharing across profiles
+ return
+ if not (isinstance(access_token, str) and access_token.strip()):
+ return
+
+ shared = {
+ "_schema": 1,
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "token_type": state.get("token_type") or "Bearer",
+ "scope": state.get("scope") or DEFAULT_NOUS_SCOPE,
+ "client_id": state.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
+ "portal_base_url": state.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
+ "inference_base_url": state.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL,
+ "obtained_at": state.get("obtained_at"),
+ "expires_at": state.get("expires_at"),
+ "updated_at": datetime.now(timezone.utc).isoformat(),
+ }
+ try:
+ path = _nous_shared_store_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.write_text(json.dumps(shared, indent=2, sort_keys=True))
+ try:
+ os.chmod(tmp, 0o600)
+ except OSError:
+ pass
+ os.replace(tmp, path)
+ _oauth_trace(
+ "nous_shared_store_written",
+ path=str(path),
+ refresh_token_fp=_token_fingerprint(refresh_token),
+ )
+ except Exception as exc:
+ logger.debug("Failed to write shared Nous auth store: %s", exc)
+
+
+def _read_shared_nous_state() -> Optional[Dict[str, Any]]:
+ """Return the shared Nous OAuth state if present and well-formed.
+
+ Returns ``None`` when the file is missing, unreadable, malformed, or
+ lacks required fields. Callers should treat ``None`` as "no shared
+ credentials available — fall through to device-code".
+ """
+ try:
+ path = _nous_shared_store_path()
+ except RuntimeError:
+ # Test seat belt tripped — treat as missing
+ return None
+ if not path.is_file():
+ return None
+ try:
+ payload = json.loads(path.read_text())
+ except (OSError, ValueError) as exc:
+ logger.debug("Shared Nous auth store at %s is unreadable: %s", path, exc)
+ return None
+ if not isinstance(payload, dict):
+ return None
+ refresh_token = payload.get("refresh_token")
+ access_token = payload.get("access_token")
+ if not (isinstance(refresh_token, str) and refresh_token.strip()):
+ return None
+ if not (isinstance(access_token, str) and access_token.strip()):
+ return None
+ return payload
+
+
+def _try_import_shared_nous_state(
+ *,
+ timeout_seconds: float = 15.0,
+ min_key_ttl_seconds: int = 5 * 60,
+) -> Optional[Dict[str, Any]]:
+ """Attempt to rehydrate Nous OAuth state from the shared store.
+
+ Reads the shared file (if present), runs a forced refresh+mint using
+ the stored refresh_token to produce a fresh access_token + agent_key
+ scoped to this profile, and returns the full auth_state dict ready
+ for ``persist_nous_credentials()``.
+
+ Returns ``None`` when no shared state is available or the rehydrate
+ fails for any reason (expired refresh_token, portal unreachable,
+ etc.) — caller should then fall through to the normal device-code
+ flow.
+ """
+ shared = _read_shared_nous_state()
+ if not shared:
+ return None
+
+ # Build a full state dict so refresh_nous_oauth_from_state has every
+ # field it needs. force_refresh=True gets us a fresh access_token
+ # for this profile; force_mint=True gets us a fresh agent_key.
+ state: Dict[str, Any] = {
+ "access_token": shared.get("access_token"),
+ "refresh_token": shared.get("refresh_token"),
+ "client_id": shared.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
+ "portal_base_url": shared.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
+ "inference_base_url": shared.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL,
+ "token_type": shared.get("token_type") or "Bearer",
+ "scope": shared.get("scope") or DEFAULT_NOUS_SCOPE,
+ "obtained_at": shared.get("obtained_at"),
+ "expires_at": shared.get("expires_at"),
+ "agent_key": None,
+ "agent_key_expires_at": None,
+ "tls": {"insecure": False, "ca_bundle": None},
+ }
+
+ try:
+ refreshed = refresh_nous_oauth_from_state(
+ state,
+ min_key_ttl_seconds=min_key_ttl_seconds,
+ timeout_seconds=timeout_seconds,
+ force_refresh=True,
+ force_mint=True,
+ )
+ except AuthError as exc:
+ _oauth_trace(
+ "nous_shared_import_failed",
+ error_type=type(exc).__name__,
+ error_code=getattr(exc, "code", None),
+ )
+ logger.debug("Shared Nous import failed: %s", exc)
+ return None
+ except Exception as exc:
+ _oauth_trace(
+ "nous_shared_import_failed",
+ error_type=type(exc).__name__,
+ )
+ logger.debug("Shared Nous import failed: %s", exc)
+ return None
+
+ return refreshed
+
+
def _refresh_access_token(
*,
client: httpx.Client,
@@ -2918,6 +3356,12 @@ def persist_nous_credentials(
_save_provider_state(auth_store, "nous", state)
_save_auth_store(auth_store)
+ # Mirror to the shared store so a new profile can one-tap import
+ # these credentials via `hermes auth add nous --type oauth`. Best-
+ # effort: any I/O failure is logged and swallowed (the per-profile
+ # auth.json is still the source of truth).
+ _write_shared_nous_state(state)
+
pool = load_pool("nous")
return next(
(e for e in pool.entries() if e.source == NOUS_DEVICE_CODE_SOURCE),
@@ -2986,6 +3430,11 @@ def _persist_state(reason: str) -> None:
refresh_token_fp=_token_fingerprint(state.get("refresh_token")),
access_token_fp=_token_fingerprint(state.get("access_token")),
)
+ # Mirror post-refresh state to the shared store so sibling
+ # profiles don't hold stale refresh_tokens after rotation.
+ # Best-effort — any failure is logged and swallowed inside
+ # _write_shared_nous_state.
+ _write_shared_nous_state(state)
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
@@ -3446,6 +3895,13 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
key_source = ""
api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig)
+ # No-auth LM Studio: substitute a placeholder so runtime / auxiliary_client
+ # see the local server as configured. doctor still reports unconfigured
+ # because get_api_key_provider_status uses the raw secret resolver.
+ if not api_key and provider_id == "lmstudio":
+ api_key = LMSTUDIO_NOAUTH_PLACEHOLDER
+ key_source = key_source or "default"
+
env_url = ""
if pconfig.base_url_env_var:
env_url = os.getenv(pconfig.base_url_env_var, "").strip()
@@ -3573,7 +4029,7 @@ def _update_config_for_provider(
config["model"] = model_cfg
- config_path.write_text(yaml.safe_dump(config, sort_keys=False))
+ atomic_yaml_write(config_path, config, sort_keys=False)
return config_path
@@ -3632,7 +4088,7 @@ def _reset_config_provider() -> Path:
model["provider"] = "auto"
if "base_url" in model:
model["base_url"] = OPENROUTER_BASE_URL
- config_path.write_text(yaml.safe_dump(config, sort_keys=False))
+ atomic_yaml_write(config_path, config, sort_keys=False)
return config_path
@@ -4056,6 +4512,328 @@ def _codex_device_code_login() -> Dict[str, Any]:
}
+# ==================== MiniMax Portal OAuth ====================
+
+def _minimax_pkce_pair() -> tuple:
+ """Generate (code_verifier, code_challenge_S256, state) for MiniMax OAuth."""
+ import secrets
+ verifier = secrets.token_urlsafe(64)[:96]
+ challenge = base64.urlsafe_b64encode(
+ hashlib.sha256(verifier.encode()).digest()
+ ).decode().rstrip("=")
+ state = secrets.token_urlsafe(16)
+ return verifier, challenge, state
+
+
+def _minimax_request_user_code(
+ client: httpx.Client, *, portal_base_url: str, client_id: str,
+ code_challenge: str, state: str,
+) -> Dict[str, Any]:
+ response = client.post(
+ f"{portal_base_url}/oauth/code",
+ data={
+ "response_type": "code",
+ "client_id": client_id,
+ "scope": MINIMAX_OAUTH_SCOPE,
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256",
+ "state": state,
+ },
+ headers={
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "application/json",
+ "x-request-id": str(uuid.uuid4()),
+ },
+ )
+ if response.status_code != 200:
+ raise AuthError(
+ f"MiniMax OAuth authorization failed: {response.text or response.reason_phrase}",
+ provider="minimax-oauth", code="authorization_failed",
+ )
+ payload = response.json()
+ for field in ("user_code", "verification_uri", "expired_in"):
+ if field not in payload:
+ raise AuthError(
+ f"MiniMax OAuth response missing field: {field}",
+ provider="minimax-oauth", code="authorization_incomplete",
+ )
+ if payload.get("state") != state:
+ raise AuthError(
+ "MiniMax OAuth state mismatch (possible CSRF).",
+ provider="minimax-oauth", code="state_mismatch",
+ )
+ return payload
+
+
+def _minimax_poll_token(
+ client: httpx.Client, *, portal_base_url: str, client_id: str,
+ user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int],
+) -> Dict[str, Any]:
+ # OpenClaw treats expired_in as a unix-ms timestamp (Date.now() < expireTimeMs).
+ # Defensive parsing: if it's small enough to be a duration, treat as seconds.
+ import time as _time
+ now_ms = int(_time.time() * 1000)
+ if expired_in > now_ms // 2:
+ # Looks like a unix-ms timestamp.
+ deadline = expired_in / 1000.0
+ else:
+ # Treat as duration in seconds from now.
+ deadline = _time.time() + max(1, expired_in)
+ interval = max(2.0, (interval_ms or 2000) / 1000.0)
+
+ while _time.time() < deadline:
+ response = client.post(
+ f"{portal_base_url}/oauth/token",
+ data={
+ "grant_type": MINIMAX_OAUTH_GRANT_TYPE,
+ "client_id": client_id,
+ "user_code": user_code,
+ "code_verifier": code_verifier,
+ },
+ headers={
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "application/json",
+ },
+ )
+ try:
+ payload = response.json() if response.text else {}
+ except Exception:
+ payload = {}
+
+ if response.status_code != 200:
+ msg = (payload.get("base_resp", {}) or {}).get("status_msg") or response.text
+ raise AuthError(
+ f"MiniMax OAuth error: {msg or 'unknown'}",
+ provider="minimax-oauth", code="token_exchange_failed",
+ )
+
+ status = payload.get("status")
+ if status == "error":
+ raise AuthError(
+ "MiniMax OAuth reported an error. Please try again later.",
+ provider="minimax-oauth", code="authorization_denied",
+ )
+ if status == "success":
+ if not all(payload.get(k) for k in ("access_token", "refresh_token", "expired_in")):
+ raise AuthError(
+ "MiniMax OAuth success payload missing required token fields.",
+ provider="minimax-oauth", code="token_incomplete",
+ )
+ return payload
+ # "pending" or any other status -> keep polling
+ _time.sleep(interval)
+
+ raise AuthError(
+ "MiniMax OAuth timed out before authorization completed.",
+ provider="minimax-oauth", code="timeout",
+ )
+
+
+def _minimax_save_auth_state(auth_state: Dict[str, Any]) -> None:
+ """Persist MiniMax OAuth state to Hermes auth store (~/.hermes/auth.json)."""
+ with _auth_store_lock():
+ auth_store = _load_auth_store()
+ _save_provider_state(auth_store, "minimax-oauth", auth_state)
+ _save_auth_store(auth_store)
+
+
+def _minimax_oauth_login(
+ *, region: str = "global", open_browser: bool = True,
+ timeout_seconds: float = 15.0,
+) -> Dict[str, Any]:
+ """Run MiniMax OAuth flow, persist tokens, return auth state dict."""
+ pconfig = PROVIDER_REGISTRY["minimax-oauth"]
+ if region == "cn":
+ portal_base_url = pconfig.extra["cn_portal_base_url"]
+ inference_base_url = pconfig.extra["cn_inference_base_url"]
+ else:
+ portal_base_url = pconfig.portal_base_url
+ inference_base_url = pconfig.inference_base_url
+
+ verifier, challenge, state = _minimax_pkce_pair()
+
+ if _is_remote_session():
+ open_browser = False
+
+ print(f"Starting Hermes login via MiniMax ({region}) OAuth...")
+ print(f"Portal: {portal_base_url}")
+
+ with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
+ headers={"Accept": "application/json"},
+ follow_redirects=True) as client:
+ code_data = _minimax_request_user_code(
+ client, portal_base_url=portal_base_url,
+ client_id=pconfig.client_id,
+ code_challenge=challenge, state=state,
+ )
+ verification_url = str(code_data["verification_uri"])
+ user_code = str(code_data["user_code"])
+
+ print()
+ print("To continue:")
+ print(f" 1. Open: {verification_url}")
+ print(f" 2. If prompted, enter code: {user_code}")
+ if open_browser:
+ if webbrowser.open(verification_url):
+ print(" (Opened browser for verification)")
+ else:
+ print(" Could not open browser automatically -- use the URL above.")
+
+ interval_raw = code_data.get("interval")
+ interval_ms = int(interval_raw) if interval_raw is not None else None
+ print("Waiting for approval...")
+
+ token_data = _minimax_poll_token(
+ client, portal_base_url=portal_base_url,
+ client_id=pconfig.client_id,
+ user_code=user_code, code_verifier=verifier,
+ expired_in=int(code_data["expired_in"]),
+ interval_ms=interval_ms,
+ )
+
+ now = datetime.now(timezone.utc)
+ expires_in_s = int(token_data["expired_in"])
+ expires_at = now.timestamp() + expires_in_s
+
+ auth_state = {
+ "provider": "minimax-oauth",
+ "region": region,
+ "portal_base_url": portal_base_url,
+ "inference_base_url": inference_base_url,
+ "client_id": pconfig.client_id,
+ "scope": MINIMAX_OAUTH_SCOPE,
+ "token_type": token_data.get("token_type", "Bearer"),
+ "access_token": token_data["access_token"],
+ "refresh_token": token_data["refresh_token"],
+ "resource_url": token_data.get("resource_url"),
+ "obtained_at": now.isoformat(),
+ "expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
+ "expires_in": expires_in_s,
+ }
+
+ _minimax_save_auth_state(auth_state)
+ print("\u2713 MiniMax OAuth login successful.")
+ if msg := token_data.get("notification_message"):
+ print(f"Note from MiniMax: {msg}")
+ return auth_state
+
+
+def _refresh_minimax_oauth_state(
+ state: Dict[str, Any], *, timeout_seconds: float = 15.0,
+ force: bool = False,
+) -> Dict[str, Any]:
+ """Refresh MiniMax OAuth access token if close to expiry (or forced)."""
+ if not state.get("refresh_token"):
+ raise AuthError(
+ "MiniMax OAuth state has no refresh_token; please re-login.",
+ provider="minimax-oauth", code="no_refresh_token", relogin_required=True,
+ )
+ try:
+ expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
+ except Exception:
+ expires_at = 0.0
+ now = time.time()
+ if not force and (expires_at - now) > MINIMAX_OAUTH_REFRESH_SKEW_SECONDS:
+ return state
+
+ portal_base_url = state["portal_base_url"]
+ with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
+ follow_redirects=True) as client:
+ response = client.post(
+ f"{portal_base_url}/oauth/token",
+ data={
+ "grant_type": "refresh_token",
+ "client_id": state["client_id"],
+ "refresh_token": state["refresh_token"],
+ },
+ headers={
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "application/json",
+ },
+ )
+ if response.status_code != 200:
+ body = response.text.lower()
+ relogin = any(m in body for m in
+ ("invalid_grant", "refresh_token_reused", "invalid_refresh_token"))
+ raise AuthError(
+ f"MiniMax OAuth refresh failed: {response.text or response.reason_phrase}",
+ provider="minimax-oauth", code="refresh_failed",
+ relogin_required=relogin,
+ )
+ payload = response.json()
+ if payload.get("status") != "success":
+ raise AuthError(
+ "MiniMax OAuth refresh did not return success.",
+ provider="minimax-oauth", code="refresh_failed",
+ relogin_required=True,
+ )
+ now_dt = datetime.now(timezone.utc)
+ expires_in_s = int(payload["expired_in"])
+ new_state = dict(state)
+ new_state.update({
+ "access_token": payload["access_token"],
+ "refresh_token": payload.get("refresh_token", state["refresh_token"]),
+ "obtained_at": now_dt.isoformat(),
+ "expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s,
+ tz=timezone.utc).isoformat(),
+ "expires_in": expires_in_s,
+ })
+ _minimax_save_auth_state(new_state)
+ return new_state
+
+
+def resolve_minimax_oauth_runtime_credentials(
+ *, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS,
+) -> Dict[str, Any]:
+ """Return {provider, api_key, base_url, source} for minimax-oauth."""
+ state = get_provider_auth_state("minimax-oauth")
+ if not state or not state.get("access_token"):
+ raise AuthError(
+ "Not logged into MiniMax OAuth. Run `hermes model` and select "
+ "MiniMax (OAuth).",
+ provider="minimax-oauth", code="not_logged_in", relogin_required=True,
+ )
+ state = _refresh_minimax_oauth_state(state)
+ return {
+ "provider": "minimax-oauth",
+ "api_key": state["access_token"],
+ "base_url": state["inference_base_url"].rstrip("/"),
+ "source": "oauth",
+ }
+
+
+def get_minimax_oauth_auth_status() -> Dict[str, Any]:
+ """Return auth status dict for MiniMax OAuth provider."""
+ state = get_provider_auth_state("minimax-oauth")
+ if not state or not state.get("access_token"):
+ return {"logged_in": False, "provider": "minimax-oauth"}
+ try:
+ expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
+ token_valid = (expires_at - time.time()) > 0
+ except Exception:
+ token_valid = bool(state.get("access_token"))
+ return {
+ "logged_in": token_valid,
+ "provider": "minimax-oauth",
+ "region": state.get("region", "global"),
+ "expires_at": state.get("expires_at"),
+ }
+
+
+def _login_minimax_oauth(args, pconfig: ProviderConfig) -> None:
+ """CLI entry for MiniMax OAuth login."""
+ region = getattr(args, "region", None) or "global"
+ open_browser = not getattr(args, "no_browser", False)
+ timeout = getattr(args, "timeout", None) or 15.0
+ try:
+ _minimax_oauth_login(
+ region=region, open_browser=open_browser, timeout_seconds=timeout,
+ )
+ except AuthError as exc:
+ print(format_auth_error(exc))
+ raise SystemExit(1)
+
+
def _nous_device_code_login(
*,
portal_base_url: Optional[str] = None,
@@ -4198,17 +4976,47 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
)
try:
- auth_state = _nous_device_code_login(
- portal_base_url=getattr(args, "portal_url", None),
- inference_base_url=getattr(args, "inference_url", None),
- client_id=getattr(args, "client_id", None) or pconfig.client_id,
- scope=getattr(args, "scope", None) or pconfig.scope,
- open_browser=not getattr(args, "no_browser", False),
- timeout_seconds=timeout_seconds,
- insecure=insecure,
- ca_bundle=ca_bundle,
- min_key_ttl_seconds=5 * 60,
- )
+ auth_state = None
+
+ # Codex-style auto-import: before launching a fresh device-code
+ # flow, check the shared store for an existing Nous credential
+ # from any other profile. If present, offer to rehydrate it.
+ shared = _read_shared_nous_state()
+ if shared:
+ try:
+ shared_path = _nous_shared_store_path()
+ except RuntimeError:
+ shared_path = None
+ print()
+ if shared_path:
+ print(f"Found existing Nous OAuth credentials at {shared_path}")
+ else:
+ print("Found existing shared Nous OAuth credentials")
+ try:
+ do_import = input("Import these credentials? [Y/n]: ").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ do_import = "y"
+ if do_import in ("", "y", "yes"):
+ print("Rehydrating Nous session from shared credentials...")
+ auth_state = _try_import_shared_nous_state(
+ timeout_seconds=timeout_seconds,
+ min_key_ttl_seconds=5 * 60,
+ )
+ if auth_state is None:
+ print("Could not refresh shared credentials — falling back to device-code login.")
+
+ if auth_state is None:
+ auth_state = _nous_device_code_login(
+ portal_base_url=getattr(args, "portal_url", None),
+ inference_base_url=getattr(args, "inference_url", None),
+ client_id=getattr(args, "client_id", None) or pconfig.client_id,
+ scope=getattr(args, "scope", None) or pconfig.scope,
+ open_browser=not getattr(args, "no_browser", False),
+ timeout_seconds=timeout_seconds,
+ insecure=insecure,
+ ca_bundle=ca_bundle,
+ min_key_ttl_seconds=5 * 60,
+ )
inference_base_url = auth_state["inference_base_url"]
@@ -4225,6 +5033,11 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
_save_provider_state(auth_store, "nous", auth_state)
saved_to = _save_auth_store(auth_store)
+ # Mirror to the shared store so other profiles can one-tap import
+ # these credentials. Best-effort: any I/O failure is logged and
+ # swallowed inside the helper.
+ _write_shared_nous_state(auth_state)
+
print()
print("Login successful!")
print(f" Auth state: {saved_to}")
@@ -4244,10 +5057,10 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
)
from hermes_cli.models import (
- _PROVIDER_MODELS, get_pricing_for_provider,
+ get_curated_nous_model_ids, get_pricing_for_provider,
check_nous_free_tier, partition_nous_models_by_tier,
)
- model_ids = _PROVIDER_MODELS.get("nous", [])
+ model_ids = get_curated_nous_model_ids()
print()
unavailable_models: list = []
diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py
index 94ea2559c46..a29776aea23 100644
--- a/hermes_cli/auth_commands.py
+++ b/hermes_cli/auth_commands.py
@@ -33,7 +33,7 @@
# Providers that support OAuth login in addition to API keys.
-_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"}
+_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"}
def _get_custom_provider_names() -> list:
@@ -170,7 +170,7 @@ def auth_add_command(args) -> None:
if provider.startswith(CUSTOM_POOL_PREFIX):
requested_type = AUTH_TYPE_API_KEY
else:
- requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"} else AUTH_TYPE_API_KEY
+ requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"} else AUTH_TYPE_API_KEY
pool = load_pool(provider)
@@ -245,6 +245,47 @@ def auth_add_command(args) -> None:
return
if provider == "nous":
+ # Codex-style auto-import: if a shared Nous credential lives at
+ # ~/.hermes/shared/nous_auth.json (written by any previous
+ # successful login), offer to import it instead of running the
+ # full device-code flow. This makes `hermes --profile
+ # auth add nous --type oauth` a one-tap operation for users who
+ # run multiple profiles.
+ shared = auth_mod._read_shared_nous_state()
+ if shared:
+ try:
+ path = auth_mod._nous_shared_store_path()
+ except RuntimeError:
+ path = None
+ print()
+ if path:
+ print(f"Found existing Nous OAuth credentials at {path}")
+ else:
+ print("Found existing shared Nous OAuth credentials")
+ try:
+ do_import = input("Import these credentials? [Y/n]: ").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ do_import = "y"
+ if do_import in ("", "y", "yes"):
+ print("Rehydrating Nous session from shared credentials...")
+ rehydrated = auth_mod._try_import_shared_nous_state(
+ timeout_seconds=getattr(args, "timeout", None) or 15.0,
+ min_key_ttl_seconds=max(
+ 60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))
+ ),
+ )
+ if rehydrated is not None:
+ custom_label = (getattr(args, "label", None) or "").strip() or None
+ entry = auth_mod.persist_nous_credentials(rehydrated, label=custom_label)
+ shown_label = entry.label if entry is not None else label_from_token(
+ rehydrated.get("access_token", ""), _oauth_default_label(provider, 1),
+ )
+ print(f'Imported {provider} OAuth credentials: "{shown_label}"')
+ return
+ # Rehydrate failed (expired refresh_token, portal down, etc.)
+ # — fall through to device-code flow.
+ print("Could not refresh shared credentials — falling back to device-code login.")
+
creds = auth_mod._nous_device_code_login(
portal_base_url=getattr(args, "portal_url", None),
inference_base_url=getattr(args, "inference_url", None),
@@ -333,6 +374,27 @@ def auth_add_command(args) -> None:
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
return
+ if provider == "minimax-oauth":
+ from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
+ creds = resolve_minimax_oauth_runtime_credentials()
+ label = (getattr(args, "label", None) or "").strip() or label_from_token(
+ creds["api_key"],
+ _oauth_default_label(provider, len(pool.entries()) + 1),
+ )
+ entry = PooledCredential(
+ provider=provider,
+ id=uuid.uuid4().hex[:6],
+ label=label,
+ auth_type=AUTH_TYPE_OAUTH,
+ priority=0,
+ source=f"{SOURCE_MANUAL}:minimax_oauth",
+ access_token=creds["api_key"],
+ base_url=creds.get("base_url"),
+ )
+ pool.add_entry(entry)
+ print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
+ return
+
raise SystemExit(f"`hermes auth add {provider}` is not implemented for auth type {requested_type} yet.")
diff --git a/hermes_cli/azure_detect.py b/hermes_cli/azure_detect.py
index 4ed4c1d0b7a..8dd0d632a9f 100644
--- a/hermes_cli/azure_detect.py
+++ b/hermes_cli/azure_detect.py
@@ -34,7 +34,7 @@
from typing import Optional
from urllib import request as urllib_request
from urllib.error import HTTPError, URLError
-from urllib.parse import urlparse, urlunparse
+from urllib.parse import urlparse
logger = logging.getLogger(__name__)
diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py
index 8b5b90ef1f9..dce199a5ab4 100644
--- a/hermes_cli/backup.py
+++ b/hermes_cli/backup.py
@@ -36,12 +36,23 @@
"__pycache__", # bytecode caches — regenerated on import
".git", # nested git dirs (profiles shouldn't have these, but safety)
"node_modules", # js deps if website/ somehow leaks in
+ "backups", # prior auto-backups — don't nest backups exponentially
+ "checkpoints", # session-local trajectory caches — regenerated per-session,
+ # session-hash-keyed so they don't port to another machine anyway
}
# File-name suffixes to skip
_EXCLUDED_SUFFIXES = (
".pyc",
".pyo",
+ # SQLite sidecar files — the backup takes a consistent snapshot of ``*.db``
+ # via ``sqlite3.backup()``, so shipping the live WAL / shared-memory /
+ # rollback-journal alongside would pair a fresh snapshot with stale sidecar
+ # state and produce a torn restore on the next open. They're transient and
+ # regenerated on first connection anyway.
+ ".db-wal",
+ ".db-shm",
+ ".db-journal",
)
# File names to skip (runtime state that's meaningless on another machine)
@@ -50,6 +61,9 @@
"cron.pid",
}
+# zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600.
+_SECRET_FILE_NAMES = {".env", "auth.json", "state.db"}
+
def _should_exclude(rel_path: Path) -> bool:
"""Return True if *rel_path* (relative to hermes root) should be skipped."""
@@ -370,6 +384,8 @@ def run_import(args) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(target, "wb") as dst:
dst.write(src.read())
+ if target.name in _SECRET_FILE_NAMES:
+ os.chmod(target, 0o600)
restored += 1
except (PermissionError, OSError) as exc:
errors.append(f" {rel}: {exc}")
@@ -454,6 +470,12 @@ def run_import(args) -> None:
# Critical state files to include in quick snapshots (relative to HERMES_HOME).
# Everything else is either regeneratable (logs, cache) or managed separately
# (skills, repo, sessions/).
+#
+# Entries may be individual files OR directories. Directories are captured
+# recursively; missing entries are silently skipped. Pairing data lives in
+# platform-specific JSON blobs outside state.db, so it's listed here explicitly
+# — `hermes update` snapshots this set before pulling so approved-user lists
+# are recoverable if anything goes wrong (issue #15733).
_QUICK_STATE_FILES = (
"state.db",
"config.yaml",
@@ -463,6 +485,10 @@ def run_import(args) -> None:
"gateway_state.json",
"channel_directory.json",
"processes.json",
+ # Pairing stores (generic + per-platform JSONs outside state.db)
+ "pairing", # legacy location (gateway/pairing.py)
+ "platforms/pairing", # new location (gateway/pairing.py)
+ "feishu_comment_pairing.json", # Feishu comment subscription pairings
)
_QUICK_SNAPSHOTS_DIR = "state-snapshots"
@@ -498,7 +524,27 @@ def create_quick_snapshot(
for rel in _QUICK_STATE_FILES:
src = home / rel
- if not src.exists() or not src.is_file():
+ if not src.exists():
+ continue
+
+ if src.is_dir():
+ # Walk the directory and record each file individually in the
+ # manifest so restore can treat them uniformly. Empty dirs are
+ # skipped (nothing to snapshot).
+ for sub in src.rglob("*"):
+ if not sub.is_file():
+ continue
+ sub_rel = sub.relative_to(home).as_posix()
+ dst = snap_dir / sub_rel
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ shutil.copy2(sub, dst)
+ manifest[sub_rel] = dst.stat().st_size
+ except (OSError, PermissionError) as exc:
+ logger.warning("Could not snapshot %s: %s", sub_rel, exc)
+ continue
+
+ if not src.is_file():
continue
dst = snap_dir / rel
@@ -653,3 +699,241 @@ def run_quick_backup(args) -> None:
print(f" Restore with: /snapshot restore {snap_id}")
else:
print("No state files found to snapshot.")
+
+
+# ---------------------------------------------------------------------------
+# Shared full-zip backup helper
+# ---------------------------------------------------------------------------
+
+def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]:
+ """Write a full zip snapshot of ``hermes_root`` to ``out_path``.
+
+ Uses the same exclusion rules and SQLite safe-copy as :func:`run_backup`.
+ Returns the output path on success, None on failure (nothing to back up,
+ or write error — caller should surface the outcome but not raise).
+ """
+ files_to_add: list[tuple[Path, Path]] = []
+ try:
+ for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
+ dp = Path(dirpath)
+ # Prune excluded directories in-place so os.walk doesn't descend
+ dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
+
+ for fname in filenames:
+ fpath = dp / fname
+ try:
+ rel = fpath.relative_to(hermes_root)
+ except ValueError:
+ continue
+
+ if _should_exclude(rel):
+ continue
+
+ # Skip the output zip itself if it already exists inside root.
+ try:
+ if fpath.resolve() == out_path.resolve():
+ continue
+ except (OSError, ValueError):
+ pass
+
+ files_to_add.append((fpath, rel))
+ except OSError as exc:
+ logger.warning("Full-zip backup: walk failed: %s", exc)
+ return None
+
+ if not files_to_add:
+ return None
+
+ try:
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
+ for abs_path, rel_path in files_to_add:
+ try:
+ if abs_path.suffix == ".db":
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
+ tmp_db = Path(tmp.name)
+ try:
+ if _safe_copy_db(abs_path, tmp_db):
+ zf.write(tmp_db, arcname=str(rel_path))
+ finally:
+ tmp_db.unlink(missing_ok=True)
+ else:
+ zf.write(abs_path, arcname=str(rel_path))
+ except (PermissionError, OSError, ValueError) as exc:
+ logger.debug("Skipping %s in zip backup: %s", rel_path, exc)
+ continue
+ except OSError as exc:
+ logger.warning("Full-zip backup: zip write failed: %s", exc)
+ # Best-effort cleanup of partial file
+ try:
+ out_path.unlink(missing_ok=True)
+ except OSError:
+ pass
+ return None
+
+ return out_path
+
+
+# ---------------------------------------------------------------------------
+# Pre-update auto-backup
+# ---------------------------------------------------------------------------
+
+_PRE_UPDATE_BACKUPS_DIR = "backups"
+_PRE_UPDATE_PREFIX = "pre-update-"
+_PRE_UPDATE_DEFAULT_KEEP = 5
+
+
+def _pre_update_backup_dir(hermes_home: Optional[Path] = None) -> Path:
+ home = hermes_home or get_hermes_home()
+ return home / _PRE_UPDATE_BACKUPS_DIR
+
+
+def _prune_pre_update_backups(backup_dir: Path, keep: int) -> int:
+ """Remove oldest pre-update backups beyond the keep limit.
+
+ Returns the number of files deleted. Only touches files matching
+ ``pre-update-*.zip`` so hand-made zips dropped in the same directory
+ are never touched.
+
+ ``keep`` is floored to 1 because this helper is only called immediately
+ after a fresh backup is written: deleting that backup right after the
+ user paid the disk/CPU cost to create it would leave them worse off
+ than no backup at all (and the wrapper in ``main.py`` would still print
+ a misleading ``Saved: `` line for a file that no longer exists).
+ Operators who genuinely don't want a backup should set
+ ``updates.pre_update_backup: false`` in config — that gates creation.
+ """
+ if keep < 1:
+ keep = 1
+ if not backup_dir.exists():
+ return 0
+
+ backups = sorted(
+ (p for p in backup_dir.iterdir()
+ if p.is_file() and p.name.startswith(_PRE_UPDATE_PREFIX) and p.suffix.lower() == ".zip"),
+ key=lambda p: p.name,
+ reverse=True,
+ )
+
+ deleted = 0
+ for p in backups[keep:]:
+ try:
+ p.unlink()
+ deleted += 1
+ except OSError as exc:
+ logger.warning("Failed to prune backup %s: %s", p.name, exc)
+
+ return deleted
+
+
+def create_pre_update_backup(
+ hermes_home: Optional[Path] = None,
+ keep: int = _PRE_UPDATE_DEFAULT_KEEP,
+) -> Optional[Path]:
+ """Create a full zip backup of HERMES_HOME under ``backups/``.
+
+ Mirrors :func:`run_backup` (same exclusion rules, same SQLite safe-copy)
+ but writes to ``/backups/pre-update-.zip`` and
+ auto-prunes old pre-update backups.
+
+ Returns the path to the created zip, or ``None`` if no files were
+ found or the backup could not be created. Never raises — the caller
+ (``hermes update``) should continue even if the backup fails.
+ """
+ hermes_root = hermes_home or get_default_hermes_root()
+ if not hermes_root.is_dir():
+ return None
+
+ backup_dir = _pre_update_backup_dir(hermes_root)
+ try:
+ backup_dir.mkdir(parents=True, exist_ok=True)
+ except OSError as exc:
+ logger.warning("Could not create pre-update backup dir %s: %s", backup_dir, exc)
+ return None
+
+ stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
+ out_path = backup_dir / f"{_PRE_UPDATE_PREFIX}{stamp}.zip"
+
+ result = _write_full_zip_backup(out_path, hermes_root)
+ if result is None:
+ return None
+
+ _prune_pre_update_backups(backup_dir, keep=keep)
+ return out_path
+
+
+# ---------------------------------------------------------------------------
+# Pre-migration auto-backup (used by `hermes claw migrate`)
+# ---------------------------------------------------------------------------
+
+_PRE_MIGRATION_PREFIX = "pre-migration-"
+_PRE_MIGRATION_DEFAULT_KEEP = 5
+
+
+def _prune_pre_migration_backups(backup_dir: Path, keep: int) -> int:
+ """Remove oldest pre-migration backups beyond the keep limit.
+
+ Only touches files matching ``pre-migration-*.zip`` so other backups in
+ the same directory are never touched.
+ """
+ if keep < 0:
+ keep = 0
+ if not backup_dir.exists():
+ return 0
+
+ backups = sorted(
+ (p for p in backup_dir.iterdir()
+ if p.is_file() and p.name.startswith(_PRE_MIGRATION_PREFIX) and p.suffix.lower() == ".zip"),
+ key=lambda p: p.name,
+ reverse=True,
+ )
+
+ deleted = 0
+ for p in backups[keep:]:
+ try:
+ p.unlink()
+ deleted += 1
+ except OSError as exc:
+ logger.warning("Failed to prune pre-migration backup %s: %s", p.name, exc)
+
+ return deleted
+
+
+def create_pre_migration_backup(
+ hermes_home: Optional[Path] = None,
+ keep: int = _PRE_MIGRATION_DEFAULT_KEEP,
+) -> Optional[Path]:
+ """Create a full zip backup of HERMES_HOME under ``backups/`` before a
+ ``hermes claw migrate`` apply.
+
+ Shares implementation with :func:`create_pre_update_backup` via
+ ``_write_full_zip_backup`` — same exclusions, same SQLite safe-copy,
+ restorable with ``hermes import ``. Writes to
+ ``/backups/pre-migration-.zip`` and auto-prunes
+ old pre-migration backups.
+
+ Returns the path to the created zip, or ``None`` if nothing was found
+ to back up (fresh install) or the write failed. Never raises — the
+ caller decides whether to abort or proceed.
+ """
+ hermes_root = hermes_home or get_default_hermes_root()
+ if not hermes_root.is_dir():
+ return None
+
+ # Reuses the shared backups/ directory so `hermes import` and the
+ # update-backup listing pick up pre-migration archives too.
+ backup_dir = _pre_update_backup_dir(hermes_root)
+ try:
+ backup_dir.mkdir(parents=True, exist_ok=True)
+ except OSError as exc:
+ logger.warning("Could not create pre-migration backup dir %s: %s", backup_dir, exc)
+ return None
+
+ stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
+ out_path = backup_dir / f"{_PRE_MIGRATION_PREFIX}{stamp}.zip"
+
+ result = _write_full_zip_backup(out_path, hermes_root)
+ if result is None:
+ return None
+
+ _prune_pre_migration_backups(backup_dir, keep=keep)
+ return out_path
diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py
index 0f792592f9d..c8446f04d9c 100644
--- a/hermes_cli/banner.py
+++ b/hermes_cli/banner.py
@@ -5,6 +5,7 @@
import json
import logging
+import os
import shutil
import subprocess
import threading
@@ -122,35 +123,36 @@ def get_available_skills() -> Dict[str, List[str]]:
# Cache update check results for 6 hours to avoid repeated git fetches
_UPDATE_CHECK_CACHE_SECONDS = 6 * 3600
+# Sentinel returned when we know an update exists but can't count commits
+# (e.g. nix-built hermes — no local git history to count against).
+UPDATE_AVAILABLE_NO_COUNT = -1
-def check_for_updates() -> Optional[int]:
- """Check how many commits behind origin/main the local repo is.
+_UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
- Does a ``git fetch`` at most once every 6 hours (cached to
- ``~/.hermes/.update_check``). Returns the number of commits behind,
- or ``None`` if the check fails or isn't applicable.
- """
- hermes_home = get_hermes_home()
- repo_dir = hermes_home / "hermes-agent"
- cache_file = hermes_home / ".update_check"
- # Must be a git repo — fall back to project root for dev installs
- if not (repo_dir / ".git").exists():
- repo_dir = Path(__file__).parent.parent.resolve()
- if not (repo_dir / ".git").exists():
- return None
+def _check_via_rev(local_rev: str) -> Optional[int]:
+ """Compare an embedded git revision to upstream main via ls-remote.
- # Read cache
- now = time.time()
+ Returns 0 if up-to-date, ``UPDATE_AVAILABLE_NO_COUNT`` if behind,
+ or ``None`` on failure.
+ """
try:
- if cache_file.exists():
- cached = json.loads(cache_file.read_text())
- if now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS:
- return cached.get("behind")
+ result = subprocess.run(
+ ["git", "ls-remote", _UPSTREAM_REPO_URL, "refs/heads/main"],
+ capture_output=True, text=True, timeout=10,
+ )
except Exception:
- pass
+ return None
+ if result.returncode != 0 or not result.stdout:
+ return None
+ upstream_rev = result.stdout.split()[0]
+ if not upstream_rev:
+ return None
+ return 0 if upstream_rev == local_rev else UPDATE_AVAILABLE_NO_COUNT
- # Fetch latest refs (fast — only downloads ref metadata, no files)
+
+def _check_via_local_git(repo_dir: Path) -> Optional[int]:
+ """Count commits behind origin/main in a local checkout."""
try:
subprocess.run(
["git", "fetch", "origin", "--quiet"],
@@ -160,7 +162,6 @@ def check_for_updates() -> Optional[int]:
except Exception:
pass # Offline or timeout — use stale refs, that's fine
- # Count commits behind
try:
result = subprocess.run(
["git", "rev-list", "--count", "HEAD..origin/main"],
@@ -168,15 +169,52 @@ def check_for_updates() -> Optional[int]:
cwd=str(repo_dir),
)
if result.returncode == 0:
- behind = int(result.stdout.strip())
- else:
- behind = None
+ return int(result.stdout.strip())
except Exception:
- behind = None
+ pass
+ return None
+
+
+def check_for_updates() -> Optional[int]:
+ """Check whether a Hermes update is available.
+
+ Two paths: if ``HERMES_REVISION`` is set (nix builds embed it), compare
+ it to upstream main via ``git ls-remote``. Otherwise look for a local
+ git checkout and count commits behind ``origin/main``.
+
+ Returns the number of commits behind, ``UPDATE_AVAILABLE_NO_COUNT`` (-1)
+ if behind but the count is unknown, ``0`` if up-to-date, or ``None`` if
+ the check failed or doesn't apply. Cached for 6 hours.
+ """
+ hermes_home = get_hermes_home()
+ cache_file = hermes_home / ".update_check"
+ embedded_rev = os.environ.get("HERMES_REVISION") or None
+
+ # Read cache — invalidate if the embedded rev has changed since last check
+ now = time.time()
+ try:
+ if cache_file.exists():
+ cached = json.loads(cache_file.read_text())
+ if (
+ now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
+ and cached.get("rev") == embedded_rev
+ ):
+ return cached.get("behind")
+ except Exception:
+ pass
+
+ if embedded_rev:
+ behind = _check_via_rev(embedded_rev)
+ else:
+ repo_dir = hermes_home / "hermes-agent"
+ if not (repo_dir / ".git").exists():
+ repo_dir = Path(__file__).parent.parent.resolve()
+ if not (repo_dir / ".git").exists():
+ return None
+ behind = _check_via_local_git(repo_dir)
- # Write cache
try:
- cache_file.write_text(json.dumps({"ts": now, "behind": behind}))
+ cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
except Exception:
pass
@@ -549,20 +587,29 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
# Update check — use prefetched result if available
try:
behind = get_update_result(timeout=0.5)
- if behind and behind > 0:
- from hermes_cli.config import recommended_update_command
- commits_word = "commit" if behind == 1 else "commits"
- right_lines.append(
- f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
- f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
- )
+ if behind is not None and behind != 0:
+ from hermes_cli.config import get_managed_update_command, recommended_update_command
+ if behind > 0:
+ commits_word = "commit" if behind == 1 else "commits"
+ right_lines.append(
+ f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
+ f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
+ )
+ else:
+ # UPDATE_AVAILABLE_NO_COUNT: nix-built hermes; we know an update
+ # exists but not by how much, and we don't know how the user
+ # installed it (nix run, profile, system flake, home-manager).
+ managed_cmd = get_managed_update_command()
+ line = "[bold yellow]⚠ update available[/]"
+ if managed_cmd:
+ line += f"[dim yellow] — run [bold]{managed_cmd}[/bold][/]"
+ right_lines.append(line)
except Exception:
pass # Never break the banner over an update check
right_content = "\n".join(right_lines)
layout_table.add_row(left_content, right_content)
- agent_name = _skin_branding("agent_name", "Hermes Agent")
title_color = _skin_color("banner_title", "#FFD700")
border_color = _skin_color("banner_border", "#CD7F32")
version_label = format_banner_version_label()
diff --git a/hermes_cli/browser_connect.py b/hermes_cli/browser_connect.py
new file mode 100644
index 00000000000..89c9d2c6521
--- /dev/null
+++ b/hermes_cli/browser_connect.py
@@ -0,0 +1,138 @@
+"""Shared helpers for attaching Hermes to a local Chrome CDP port."""
+
+from __future__ import annotations
+
+import os
+import platform
+import shlex
+import shutil
+import subprocess
+
+from hermes_constants import get_hermes_home
+
+
+DEFAULT_BROWSER_CDP_PORT = 9222
+DEFAULT_BROWSER_CDP_URL = f"http://127.0.0.1:{DEFAULT_BROWSER_CDP_PORT}"
+
+_DARWIN_APPS = (
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
+)
+
+_WINDOWS_INSTALL_PARTS = (
+ ("Google", "Chrome", "Application", "chrome.exe"),
+ ("Chromium", "Application", "chrome.exe"),
+ ("Chromium", "Application", "chromium.exe"),
+ ("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
+ ("Microsoft", "Edge", "Application", "msedge.exe"),
+)
+
+_LINUX_BIN_NAMES = (
+ "google-chrome", "google-chrome-stable", "chromium-browser",
+ "chromium", "brave-browser", "microsoft-edge",
+)
+
+_WINDOWS_BIN_NAMES = (
+ "chrome.exe", "msedge.exe", "brave.exe", "chromium.exe",
+ "chrome", "msedge", "brave", "chromium",
+)
+
+
+def get_chrome_debug_candidates(system: str) -> list[str]:
+ candidates: list[str] = []
+ seen: set[str] = set()
+
+ def add(path: str | None) -> None:
+ if not path:
+ return
+ normalized = os.path.normcase(os.path.normpath(path))
+ if normalized in seen or not os.path.isfile(path):
+ return
+ candidates.append(path)
+ seen.add(normalized)
+
+ def add_install_paths(bases: tuple[str | None, ...]) -> None:
+ for base in filter(None, bases):
+ for parts in _WINDOWS_INSTALL_PARTS:
+ add(os.path.join(base, *parts))
+
+ if system == "Darwin":
+ for app in _DARWIN_APPS:
+ add(app)
+ return candidates
+
+ if system == "Windows":
+ for name in _WINDOWS_BIN_NAMES:
+ add(shutil.which(name))
+ add_install_paths((
+ os.environ.get("ProgramFiles"),
+ os.environ.get("ProgramFiles(x86)"),
+ os.environ.get("LOCALAPPDATA"),
+ ))
+ return candidates
+
+ for name in _LINUX_BIN_NAMES:
+ add(shutil.which(name))
+ add_install_paths(("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"))
+ return candidates
+
+
+def chrome_debug_data_dir() -> str:
+ return str(get_hermes_home() / "chrome-debug")
+
+
+def _chrome_debug_args(port: int) -> list[str]:
+ return [
+ f"--remote-debugging-port={port}",
+ f"--user-data-dir={chrome_debug_data_dir()}",
+ "--no-first-run",
+ "--no-default-browser-check",
+ ]
+
+
+def manual_chrome_debug_command(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> str | None:
+ system = system or platform.system()
+ candidates = get_chrome_debug_candidates(system)
+
+ if candidates:
+ argv = [candidates[0], *_chrome_debug_args(port)]
+ return subprocess.list2cmdline(argv) if system == "Windows" else shlex.join(argv)
+
+ if system == "Darwin":
+ data_dir = chrome_debug_data_dir()
+ return (
+ f'open -a "Google Chrome" --args --remote-debugging-port={port} '
+ f'--user-data-dir="{data_dir}" --no-first-run --no-default-browser-check'
+ )
+
+ return None
+
+
+def _detach_kwargs(system: str) -> dict:
+ if system != "Windows":
+ return {"start_new_session": True}
+ flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
+ subprocess, "CREATE_NEW_PROCESS_GROUP", 0
+ )
+ return {"creationflags": flags} if flags else {}
+
+
+def try_launch_chrome_debug(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> bool:
+ system = system or platform.system()
+ candidates = get_chrome_debug_candidates(system)
+ if not candidates:
+ return False
+
+ os.makedirs(chrome_debug_data_dir(), exist_ok=True)
+ try:
+ subprocess.Popen(
+ [candidates[0], *_chrome_debug_args(port)],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ **_detach_kwargs(system),
+ )
+ return True
+ except Exception:
+ return False
diff --git a/hermes_cli/checkpoints.py b/hermes_cli/checkpoints.py
new file mode 100644
index 00000000000..cac5cd0979f
--- /dev/null
+++ b/hermes_cli/checkpoints.py
@@ -0,0 +1,244 @@
+"""`hermes checkpoints` CLI subcommand.
+
+Gives users direct visibility and control over the filesystem checkpoint
+store at ``~/.hermes/checkpoints/``. Actions:
+
+ hermes checkpoints # same as `status`
+ hermes checkpoints status # total size, project count, breakdown
+ hermes checkpoints list # per-project checkpoint counts + workdir
+ hermes checkpoints prune [opts] # force a sweep (ignores the 24h marker)
+ hermes checkpoints clear [-f] # nuke the entire base (asks first)
+ hermes checkpoints clear-legacy # delete just the legacy-* archives
+
+Examples::
+
+ hermes checkpoints
+ hermes checkpoints prune --retention-days 3 --max-size-mb 200
+ hermes checkpoints clear -f
+
+None of these require the agent to be running. Safe to call any time.
+"""
+
+from __future__ import annotations
+
+import argparse
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict
+
+
+def _fmt_bytes(n: int) -> str:
+ units = ("B", "KB", "MB", "GB", "TB")
+ size = float(n or 0)
+ for unit in units:
+ if size < 1024 or unit == units[-1]:
+ if unit == "B":
+ return f"{int(size)} {unit}"
+ return f"{size:.1f} {unit}"
+ size /= 1024
+ return f"{size:.1f} TB"
+
+
+def _fmt_ts(ts: Any) -> str:
+ try:
+ return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M")
+ except (TypeError, ValueError):
+ return "—"
+
+
+def _fmt_age(ts: Any) -> str:
+ try:
+ age = time.time() - float(ts)
+ except (TypeError, ValueError):
+ return "—"
+ if age < 0:
+ return "now"
+ if age < 60:
+ return f"{int(age)}s ago"
+ if age < 3600:
+ return f"{int(age / 60)}m ago"
+ if age < 86400:
+ return f"{int(age / 3600)}h ago"
+ return f"{int(age / 86400)}d ago"
+
+
+def cmd_status(args: argparse.Namespace) -> int:
+ from tools.checkpoint_manager import store_status
+
+ info = store_status()
+ base = info["base"]
+ print(f"Checkpoint base: {base}")
+ print(f"Total size: {_fmt_bytes(info['total_size_bytes'])}")
+ print(f" store/ {_fmt_bytes(info['store_size_bytes'])}")
+ print(f" legacy-* {_fmt_bytes(info['legacy_size_bytes'])}")
+ print(f"Projects: {info['project_count']}")
+
+ projects = sorted(
+ info["projects"],
+ key=lambda p: (p.get("last_touch") or 0),
+ reverse=True,
+ )
+ if projects:
+ print()
+ print(f" {'WORKDIR':<60} {'COMMITS':>7} {'LAST TOUCH':>12} STATE")
+ for p in projects[: args.limit if hasattr(args, "limit") and args.limit else 20]:
+ wd = p.get("workdir") or "(unknown)"
+ if len(wd) > 60:
+ wd = "…" + wd[-59:]
+ exists = p.get("exists")
+ state = "live" if exists else "orphan"
+ commits = p.get("commits", 0)
+ last = _fmt_age(p.get("last_touch"))
+ print(f" {wd:<60} {commits:>7} {last:>12} {state}")
+
+ legacy = info.get("legacy_archives", [])
+ if legacy:
+ print()
+ print(f"Legacy archives ({len(legacy)}):")
+ for arch in sorted(legacy, key=lambda a: a.get("mtime", 0), reverse=True):
+ print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}")
+ print()
+ print("Clear with: hermes checkpoints clear-legacy")
+ return 0
+
+
+def cmd_list(args: argparse.Namespace) -> int:
+ # `list` is just a terser status — already covered.
+ return cmd_status(args)
+
+
+def cmd_prune(args: argparse.Namespace) -> int:
+ from tools.checkpoint_manager import prune_checkpoints
+
+ retention_days = args.retention_days
+ max_size_mb = args.max_size_mb
+
+ print("Pruning checkpoint store…")
+ print(f" retention_days: {retention_days}")
+ print(f" delete_orphans: {not args.keep_orphans}")
+ print(f" max_total_size_mb: {max_size_mb}")
+ print()
+
+ result = prune_checkpoints(
+ retention_days=retention_days,
+ delete_orphans=not args.keep_orphans,
+ max_total_size_mb=max_size_mb,
+ )
+ print(f"Scanned: {result['scanned']}")
+ print(f"Deleted orphan: {result['deleted_orphan']}")
+ print(f"Deleted stale: {result['deleted_stale']}")
+ print(f"Errors: {result['errors']}")
+ print(f"Bytes reclaimed: {_fmt_bytes(result['bytes_freed'])}")
+ return 0
+
+
+def _confirm(prompt: str) -> bool:
+ try:
+ resp = input(f"{prompt} [y/N]: ").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ print()
+ return False
+ return resp in ("y", "yes")
+
+
+def cmd_clear(args: argparse.Namespace) -> int:
+ from tools.checkpoint_manager import CHECKPOINT_BASE, clear_all, store_status
+
+ info = store_status()
+ if info["total_size_bytes"] == 0 and not Path(CHECKPOINT_BASE).exists():
+ print("Nothing to clear — checkpoint base does not exist.")
+ return 0
+
+ print(f"This will delete the ENTIRE checkpoint base at {info['base']}")
+ print(f" size: {_fmt_bytes(info['total_size_bytes'])}")
+ print(f" projects: {info['project_count']}")
+ print(f" legacy dirs: {len(info.get('legacy_archives', []))}")
+ print()
+ print("All /rollback history for every working directory will be lost.")
+ if not args.force and not _confirm("Proceed?"):
+ print("Aborted.")
+ return 1
+
+ result = clear_all()
+ if result["deleted"]:
+ print(f"Cleared. Reclaimed {_fmt_bytes(result['bytes_freed'])}.")
+ return 0
+ print("Could not clear checkpoint base (see logs).")
+ return 2
+
+
+def cmd_clear_legacy(args: argparse.Namespace) -> int:
+ from tools.checkpoint_manager import clear_legacy, store_status
+
+ info = store_status()
+ legacy = info.get("legacy_archives", [])
+ if not legacy:
+ print("No legacy archives to clear.")
+ return 0
+
+ total = sum(a.get("size_bytes", 0) for a in legacy)
+ print(f"Found {len(legacy)} legacy archive(s), total {_fmt_bytes(total)}:")
+ for arch in legacy:
+ print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}")
+ print()
+ print("Legacy archives hold pre-v2 per-project shadow repos, moved aside")
+ print("during the single-store migration. Delete when you're confident")
+ print("you don't need the old /rollback history.")
+ if not args.force and not _confirm("Delete all legacy archives?"):
+ print("Aborted.")
+ return 1
+
+ result = clear_legacy()
+ print(f"Deleted {result['deleted']} archive(s), reclaimed {_fmt_bytes(result['bytes_freed'])}.")
+ return 0
+
+
+def register_cli(parser: argparse.ArgumentParser) -> None:
+ """Wire subcommands onto the ``hermes checkpoints`` parser."""
+ parser.set_defaults(func=cmd_status) # bare `hermes checkpoints` → status
+ subs = parser.add_subparsers(dest="checkpoints_command", metavar="COMMAND")
+
+ p_status = subs.add_parser(
+ "status",
+ help="Show total size, project count, and per-project breakdown",
+ )
+ p_status.add_argument("--limit", type=int, default=20,
+ help="Max projects to list (default 20)")
+ p_status.set_defaults(func=cmd_status)
+
+ p_list = subs.add_parser(
+ "list",
+ help="Alias for 'status'",
+ )
+ p_list.add_argument("--limit", type=int, default=20)
+ p_list.set_defaults(func=cmd_list)
+
+ p_prune = subs.add_parser(
+ "prune",
+ help="Delete orphan/stale checkpoints and GC the store",
+ )
+ p_prune.add_argument("--retention-days", type=int, default=7,
+ help="Drop projects whose last_touch is older than N days (default 7)")
+ p_prune.add_argument("--max-size-mb", type=int, default=500,
+ help="After orphan/stale prune, drop oldest commits "
+ "per project until total size <= this (default 500)")
+ p_prune.add_argument("--keep-orphans", action="store_true",
+ help="Skip deleting projects whose workdir no longer exists")
+ p_prune.set_defaults(func=cmd_prune)
+
+ p_clear = subs.add_parser(
+ "clear",
+ help="Delete the entire checkpoint base (all /rollback history)",
+ )
+ p_clear.add_argument("-f", "--force", action="store_true",
+ help="Skip confirmation prompt")
+ p_clear.set_defaults(func=cmd_clear)
+
+ p_legacy = subs.add_parser(
+ "clear-legacy",
+ help="Delete only the legacy-/ archives from v1 migration",
+ )
+ p_legacy.add_argument("-f", "--force", action="store_true",
+ help="Skip confirmation prompt")
+ p_legacy.set_defaults(func=cmd_clear_legacy)
diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py
index aa0c288280c..5f9d728252d 100644
--- a/hermes_cli/claw.py
+++ b/hermes_cli/claw.py
@@ -4,7 +4,8 @@
hermes claw migrate # Preview then migrate (always shows preview first)
hermes claw migrate --dry-run # Preview only, no changes
hermes claw migrate --yes # Skip confirmation prompt
- hermes claw migrate --preset full --overwrite # Full migration, overwrite conflicts
+ hermes claw migrate --preset full --overwrite --migrate-secrets # Full run w/ secrets
+ hermes claw migrate --no-backup # Skip pre-migration snapshot
hermes claw cleanup # Archive leftover OpenClaw directories
hermes claw cleanup --dry-run # Preview what would be archived
"""
@@ -15,6 +16,7 @@
import sys
from datetime import datetime
from pathlib import Path
+from typing import Optional
from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config
from hermes_constants import get_optional_skills_dir
@@ -233,6 +235,9 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
"""
findings: list[tuple[Path, str]] = []
+ if not source_dir.exists():
+ return findings
+
# Direct state files in the root
for name in ("todo.json", "sessions", "logs"):
candidate = source_dir / name
@@ -241,7 +246,12 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
findings.append((candidate, f"Root {kind}: {name}"))
# State files inside workspace directories
- for child in sorted(source_dir.iterdir()):
+ try:
+ children = sorted(source_dir.iterdir())
+ except OSError:
+ return findings
+
+ for child in children:
if not child.is_dir() or child.name.startswith("."):
continue
# Check for workspace-like subdirectories
@@ -321,10 +331,13 @@ def _cmd_migrate(args):
migrate_secrets = getattr(args, "migrate_secrets", False)
workspace_target = getattr(args, "workspace_target", None)
skill_conflict = getattr(args, "skill_conflict", "skip")
+ no_backup = getattr(args, "no_backup", False)
- # If using the "full" preset, secrets are included by default
- if preset == "full":
- migrate_secrets = True
+ # Secrets are never included implicitly — they must be explicitly requested
+ # via --migrate-secrets, even under --preset full. This mirrors OpenClaw's
+ # migrate-hermes posture (two-phase: run once without secrets, rerun with
+ # --include-secrets) and prevents a --preset full invocation from silently
+ # importing API keys that the user may not have intended to copy.
print()
print(
@@ -431,15 +444,24 @@ def _cmd_migrate(args):
preview_summary = preview_report.get("summary", {})
preview_count = preview_summary.get("migrated", 0)
+ preview_conflicts = preview_summary.get("conflict", 0)
- if preview_count == 0:
+ # "Nothing to migrate" means nothing migrated AND nothing blocked by
+ # conflicts. If there are conflicts, we still want to show the plan and
+ # surface the refusal/--overwrite guidance instead of silently bailing.
+ if preview_count == 0 and preview_conflicts == 0:
print()
print_info("Nothing to migrate from OpenClaw.")
_print_migration_report(preview_report, dry_run=True)
return
print()
- print_header(f"Migration Preview — {preview_count} item(s) would be imported")
+ if preview_count > 0:
+ print_header(f"Migration Preview — {preview_count} item(s) would be imported")
+ else:
+ print_header(
+ f"Migration Preview — {preview_conflicts} conflict(s), nothing would be imported"
+ )
print_info("No changes have been made yet. Review the list below:")
_print_migration_report(preview_report, dry_run=True)
@@ -447,6 +469,24 @@ def _cmd_migrate(args):
if dry_run:
return
+ # ── Phase 1b: Refuse if the plan has conflicts and --overwrite is not set ─
+ # Modelled on OpenClaw's assertConflictFreePlan() — apply is a safe no-op
+ # on conflicts unless the user explicitly opts in to overwriting. Without
+ # this guard, the user would answer "yes, proceed" and silently end up
+ # with a migration that skipped every conflicting item.
+ if preview_conflicts > 0 and not overwrite:
+ print()
+ print_error(
+ f"Plan has {preview_conflicts} conflict(s). Refusing to apply."
+ )
+ print_info(
+ "Each conflict is an item whose target already exists in ~/.hermes/. "
+ "Re-run with --overwrite to replace conflicting targets (item-level "
+ "backups are written to the migration report directory)."
+ )
+ print_info("Or re-run with --dry-run to review the full plan.")
+ return
+
# ── Phase 2: Confirm and execute ───────────────────────────
print()
if not auto_yes:
@@ -458,6 +498,32 @@ def _cmd_migrate(args):
print_info("Migration cancelled.")
return
+ # ── Phase 2b: Pre-apply backup of the Hermes home ─────────
+ # Delegates to hermes_cli.backup.create_pre_migration_backup(), which
+ # shares implementation with the pre-update backup (same exclusion
+ # rules, same SQLite safe-copy, zip format) so the archive is
+ # restorable with `hermes import`. Mirrors OpenClaw's
+ # createPreMigrationBackup posture — one atomic restore point before
+ # any mutation, auto-pruned to the last 5 pre-migration zips.
+ backup_archive: Optional[Path] = None
+ if not no_backup:
+ try:
+ from hermes_cli.backup import create_pre_migration_backup, _format_size
+ backup_archive = create_pre_migration_backup(hermes_home=hermes_home)
+ if backup_archive:
+ size_str = _format_size(backup_archive.stat().st_size)
+ print()
+ print_success(f"Pre-migration backup: {backup_archive} ({size_str})")
+ print_info(f"Restore with: hermes import {backup_archive.name}")
+ except Exception as e:
+ print()
+ print_error(f"Could not create pre-migration backup: {e}")
+ print_info(
+ "Re-run with --no-backup to skip, or free up disk space under the Hermes home."
+ )
+ logger.debug("Pre-migration backup error", exc_info=True)
+ return
+
try:
migrator = mod.Migrator(
source_root=source_dir.resolve(),
@@ -476,6 +542,9 @@ def _cmd_migrate(args):
print()
print_error(f"Migration failed: {e}")
logger.debug("OpenClaw migration error", exc_info=True)
+ if backup_archive:
+ print_info(f"A pre-migration backup is available at: {backup_archive}")
+ print_info(f"Restore with: hermes import {backup_archive.name}")
return
# Print results
diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py
index 4d650487b49..2cf2c3e9f40 100644
--- a/hermes_cli/commands.py
+++ b/hermes_cli/commands.py
@@ -10,6 +10,7 @@
from __future__ import annotations
+import logging
import os
import re
import shutil
@@ -19,6 +20,10 @@
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
@@ -59,9 +64,13 @@ class CommandDef:
COMMAND_REGISTRY: list[CommandDef] = [
# Session
CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
- aliases=("reset",)),
+ 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",
@@ -84,15 +93,15 @@ class CommandDef:
CommandDef("deny", "Deny a pending dangerous command", "Session",
gateway_only=True),
CommandDef("background", "Run a prompt in the background", "Session",
- aliases=("bg",), args_hint=""),
- CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session",
- args_hint=""),
+ aliases=("bg", "btw"), args_hint=""),
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=""),
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",
args_hint=""),
+ 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("profile", "Show active profile name and home directory", "Info"),
CommandDef("sethome", "Set this chat as the home channel", "Session",
@@ -115,6 +124,9 @@ class CommandDef:
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",
@@ -125,11 +137,14 @@ class CommandDef:
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|interrupt|status]",
- subcommands=("queue", "interrupt", "status")),
+ 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",
@@ -142,10 +157,20 @@ class CommandDef:
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)",
+ "Tools & Skills", args_hint="[subcommand]",
+ subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore")),
+ 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")),
@@ -355,7 +380,7 @@ def _resolve_config_gates() -> set[str]:
else:
val = None
break
- if val:
+ if is_truthy_value(val, default=False):
result.add(cmd.name)
return result
@@ -376,6 +401,11 @@ def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = N
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()
@@ -432,7 +462,9 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
Telegram command names cannot contain hyphens, so they are replaced with
underscores. Aliases are skipped -- Telegram shows one menu entry per
- canonical command.
+ canonical command. Commands that require arguments are skipped because
+ selecting a Telegram BotCommand sends only ``/command`` and would execute
+ an incomplete command.
Plugin-registered slash commands are included so plugins get native
autocomplete in Telegram without touching core code.
@@ -442,10 +474,14 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
for cmd in COMMAND_REGISTRY:
if not _is_gateway_available(cmd, overrides):
continue
+ if _requires_argument(cmd.args_hint):
+ continue
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():
+ 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))
@@ -479,9 +515,9 @@ def _sanitize_telegram_name(raw: str) -> str:
def _clamp_command_names(
- entries: list[tuple[str, str]],
+ entries: list[tuple[str, ...]],
reserved: set[str],
-) -> list[tuple[str, str]]:
+) -> list[tuple[str, ...]]:
"""Enforce 32-char command name limit with collision avoidance.
Both Telegram and Discord cap slash command names at 32 characters.
@@ -489,10 +525,15 @@ def _clamp_command_names(
(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[str, str]] = []
- for name, desc in entries:
+ 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:
@@ -508,7 +549,7 @@ def _clamp_command_names(
if name in used:
continue
used.add(name)
- result.append((name, desc))
+ result.append((name, desc, *extra))
return result
@@ -591,13 +632,26 @@ def _collect_gateway_skill_entries(
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())
+ _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.startswith(_skills_dir):
+ 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
@@ -615,17 +669,15 @@ def _collect_gateway_skill_entries(
except Exception:
pass
- # Clamp names; _clamp_command_names works on (name, desc) pairs so we
- # need to zip/unzip.
- skill_pairs = [(n, d) for n, d, _ in skill_triples]
- key_by_pair = {(n, d): k for n, d, k in skill_triples}
- skill_pairs = _clamp_command_names(skill_pairs, reserved_names)
+ # 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_pairs) - remaining)
- for n, d in skill_pairs[:remaining]:
- all_entries.append((n, d, key_by_pair.get((n, d), "")))
+ 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
@@ -701,24 +753,40 @@ def discord_skill_commands(
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`` subcommand groups.
+ """Return skill entries organized by category for Discord ``/skill`` autocomplete.
- Skills whose directory is nested at least 2 levels under ``SKILLS_DIR``
+ 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* — the caller should register them as direct subcommands
- of the ``/skill`` group.
-
- The same filtering as :func:`discord_skill_commands` is applied: hub
- skills excluded, per-platform disabled excluded, names clamped.
+ *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 `` 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 Discord group limits
- (25 subcommand groups, 25 subcommands per group)
+ - *hidden_count*: skills dropped due to name clamp collisions
+ against already-registered command names.
"""
from pathlib import Path as _P
@@ -732,14 +800,33 @@ def discord_skill_commands_by_category(
# Collect raw skill data --------------------------------------------------
categories: dict[str, list[tuple[str, str, str]]] = {}
uncategorized: list[tuple[str, str, str]] = []
- _names_used: set[str] = set(reserved_names)
+ # 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] = {n: "" for n in reserved_names}
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
+ # ``/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):
@@ -748,33 +835,72 @@ def discord_skill_commands_by_category(
if not skill_path:
continue
sp = _P(skill_path).resolve()
- # Skip skills outside SKILLS_DIR or from the hub
- if not str(sp).startswith(str(_skills_dir)):
- continue
+ # 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 limit)
+ # 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 == "":
+ 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.add(discord_name)
+ _names_used[discord_name] = cmd_key
desc = info.get("description", "")
if len(desc) > 100:
desc = desc[:97] + "..."
- # Determine category from the relative path within SKILLS_DIR.
- # e.g. creative/ascii-art/SKILL.md → parts = ("creative", "ascii-art")
- try:
- rel = sp.parent.relative_to(_skills_dir)
- except ValueError:
- continue
+ # 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]
@@ -784,28 +910,128 @@ def discord_skill_commands_by_category(
except Exception:
pass
- # Enforce Discord limits: 25 subcommand groups, 25 subcommands each ------
- _MAX_GROUPS = 25
- _MAX_PER_GROUP = 25
+ 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 `` 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.
- trimmed_categories: dict[str, list[tuple[str, str, str]]] = {}
- group_count = 0
- for cat in sorted(categories):
- if group_count >= _MAX_GROUPS:
- hidden += len(categories[cat])
+ 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 ``.
+
+ 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 `` 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
- entries = categories[cat][:_MAX_PER_GROUP]
- hidden += max(0, len(categories[cat]) - _MAX_PER_GROUP)
- trimmed_categories[cat] = entries
- group_count += 1
+ 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
- # Uncategorized skills also count against the 25 top-level limit
- remaining_slots = _MAX_GROUPS - group_count
- if len(uncategorized) > remaining_slots:
- hidden += len(uncategorized) - remaining_slots
- uncategorized = uncategorized[:remaining_slots]
- return trimmed_categories, uncategorized, hidden
+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]:
@@ -835,6 +1061,42 @@ def slack_subcommand_map() -> dict[str, str]:
# Autocomplete
# ---------------------------------------------------------------------------
+
+# Per-process cache for /model 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."""
@@ -866,6 +1128,12 @@ def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]:
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.
@@ -874,8 +1142,17 @@ def _completion_text(cmd_name: str, word: str) -> str:
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.
"""
- return f"{cmd_name} " if cmd_name == word else cmd_name
+ 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:
@@ -1258,6 +1535,19 @@ def _model_completions(self, sub_text: str, sub_lower: str):
)
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
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 3b5e24a376d..cf2b0b528a6 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -30,34 +30,69 @@
_IS_WINDOWS = platform.system() == "Windows"
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}
+# (path, mtime_ns, size) -> cached expanded config dict.
+# load_config() returns a deepcopy of the cached value when the file
+# hasn't changed since the last load, skipping yaml.safe_load +
+# _deep_merge + _normalize_* + _expand_env_vars (~13 ms/call).
+# save_config() + migrate_config() write via atomic_yaml_write which
+# produces a fresh inode, so stat() sees a new mtime_ns and the next
+# load repopulates automatically — no explicit invalidation hook.
+_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
+# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as
+# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
+# the user's on-disk values without defaults merged in.
+_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
# (managed by setup/provider flows directly).
_EXTRA_ENV_KEYS = frozenset({
"OPENAI_API_KEY", "OPENAI_BASE_URL",
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
- "DISCORD_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL",
+ "DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME",
+ "TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME",
+ "SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME",
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
+ "SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME",
+ "SMS_HOME_CHANNEL", "SMS_HOME_CHANNEL_NAME",
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
+ "DINGTALK_HOME_CHANNEL", "DINGTALK_HOME_CHANNEL_NAME",
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
+ "FEISHU_HOME_CHANNEL", "FEISHU_HOME_CHANNEL_NAME",
+ "YUANBAO_HOME_CHANNEL", "YUANBAO_HOME_CHANNEL_NAME",
"WECOM_BOT_ID", "WECOM_SECRET",
"WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID",
"WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY",
"WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT",
+ "WECOM_HOME_CHANNEL", "WECOM_HOME_CHANNEL_NAME",
"WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL",
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
"BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD",
+ "BLUEBUBBLES_HOME_CHANNEL", "BLUEBUBBLES_HOME_CHANNEL_NAME",
"QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME",
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat)
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
+ "IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
+ "IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
- "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
+ "MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
- "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD",
+ "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD",
"MATRIX_RECOVERY_KEY",
+ # Langfuse observability plugin — optional tuning keys + standard SDK vars.
+ # Activation is via plugins.enabled (opt-in through `hermes plugins enable
+ # observability/langfuse` or `hermes tools → Langfuse`); credentials gate
+ # the plugin at runtime.
+ "HERMES_LANGFUSE_ENV",
+ "HERMES_LANGFUSE_RELEASE",
+ "HERMES_LANGFUSE_SAMPLE_RATE",
+ "HERMES_LANGFUSE_MAX_CHARS",
+ "HERMES_LANGFUSE_DEBUG",
+ "LANGFUSE_PUBLIC_KEY",
+ "LANGFUSE_SECRET_KEY",
+ "LANGFUSE_BASE_URL",
})
import yaml
@@ -206,6 +241,7 @@ def get_container_exec_info() -> Optional[dict]:
# Re-export from hermes_constants — canonical definition lives there.
from hermes_constants import get_hermes_home # noqa: F811,E402
+from utils import atomic_replace
def get_config_path() -> Path:
"""Get the main config file path."""
@@ -314,7 +350,7 @@ def ensure_hermes_home():
else:
home.mkdir(parents=True, exist_ok=True)
_secure_dir(home)
- for subdir in ("cron", "sessions", "logs", "memories"):
+ for subdir in ("cron", "sessions", "logs", "logs/curator", "memories"):
d = home / subdir
d.mkdir(parents=True, exist_ok=True)
_secure_dir(d)
@@ -335,6 +371,10 @@ def _ensure_hermes_home_managed(home: Path):
f"{d} does not exist. "
"Run 'sudo nixos-rebuild switch' first."
)
+ # Curator reports dir is a sub-path of logs/; create it if missing.
+ # In managed mode the activation script may not know about this subdir,
+ # so we mkdir it ourselves (it's inside an already-secured logs/ dir).
+ (home / "logs" / "curator").mkdir(parents=True, exist_ok=True)
# Inside umask(0o007) scope — SOUL.md will be created as 0660
_ensure_default_soul_md(home)
@@ -360,7 +400,12 @@ def _ensure_hermes_home_managed(home: Path):
# The gateway stops accepting new work, waits for running agents
# to finish, then interrupts any remaining runs after the timeout.
# 0 = no drain, interrupt immediately.
- "restart_drain_timeout": 60,
+ #
+ # 180s is calibrated for realistic in-flight agent turns: a typical
+ # coding conversation mid-reasoning runs 60–150s per call, so a 60s
+ # budget routinely interrupted legitimate work on /restart. Raise
+ # further in config.yaml if you run very-long-reasoning models.
+ "restart_drain_timeout": 180,
# Max app-level retry attempts for API errors (connection drops,
# provider timeouts, 5xx, etc.) before the agent surfaces the
# failure. The OpenAI SDK already does its own low-level retries
@@ -389,6 +434,35 @@ def _ensure_hermes_home_managed(home: Path):
# (60+ tool iterations with tiny output) before users assume the
# bot is dead and /restart.
"gateway_notify_interval": 180,
+ # Freshness window for the gateway auto-continue note (seconds).
+ # After a gateway crash/restart/SIGTERM mid-run, the next user
+ # message gets a "[System note: your previous turn was
+ # interrupted — process the unfinished tool result(s) first]"
+ # prepended so the model picks up where it left off. That's the
+ # right behaviour while the interruption is fresh, but stale
+ # markers (transcript last touched hours or days ago) can revive
+ # an unrelated old task when the user's next message starts new
+ # work. This window is the max age of the last persisted
+ # transcript row for which we still inject the continue note.
+ # Default 3600s comfortably covers a long turn (gateway_timeout
+ # default is 1800s) plus runtime slack. Set to 0 to disable the
+ # gate and restore pre-fix behaviour (always inject).
+ "gateway_auto_continue_freshness": 3600,
+ # How user-attached images are presented to the main model on each turn.
+ # "auto" — attach natively when the active model reports
+ # supports_vision=True AND the user hasn't explicitly
+ # configured auxiliary.vision.provider. Otherwise fall
+ # back to text (vision_analyze pre-analysis).
+ # "native" — always attach natively; non-vision models will either
+ # error at the provider or get a last-chance text fallback
+ # (see run_agent._prepare_messages_for_api).
+ # "text" — always pre-analyze with vision_analyze and prepend the
+ # description as text; the main model never sees pixels.
+ # Affects gateway platforms, the TUI, and CLI /attach. vision_analyze
+ # remains available as a tool regardless of this setting — the routing
+ # only controls how inbound user images are presented.
+ "image_input_mode": "auto",
+ "disabled_toolsets": [],
},
"terminal": {
@@ -437,7 +511,8 @@ def _ensure_hermes_home_managed(home: Path):
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
- # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)
+ "vercel_runtime": "node24",
+ # Container resource limits (docker, singularity, modal, daytona, vercel_sandbox — ignored for local/ssh)
"container_cpu": 1,
"container_memory": 5120, # MB (default 5GB)
"container_disk": 51200, # MB (default 50GB)
@@ -453,18 +528,42 @@ def _ensure_hermes_home_managed(home: Path):
# Explicit opt-in: mount the host cwd into /workspace for Docker sessions.
# Default off because passing host directories into a sandbox weakens isolation.
"docker_mount_cwd_to_workspace": False,
+ # Explicit opt-in: run the Docker container as the host user's uid:gid
+ # (via `--user`). When enabled, files written into bind-mounted dirs
+ # (docker_volumes, the persistent workspace, or the auto-mounted cwd)
+ # are owned by your host user instead of root, which avoids needing
+ # `sudo chown` after container runs. Default off to preserve behavior
+ # for images whose entrypoints expect to start as root (e.g. the
+ # bundled Hermes image, which drops to the `hermes` user via gosu).
+ # When on, SETUID/SETGID caps are omitted from the container since
+ # no privilege drop is needed.
+ "docker_run_as_host_user": False,
# Persistent shell — keep a long-lived bash shell across execute() calls
# so cwd/env vars/shell variables survive between commands.
# Enabled by default for non-local backends (SSH); local is always opt-in
# via TERMINAL_LOCAL_PERSISTENT env var.
"persistent_shell": True,
},
-
+
+ "web": {
+ "backend": "", # shared fallback — applies to both search and extract
+ "search_backend": "", # per-capability override for web_search (e.g. "searxng")
+ "extract_backend": "", # per-capability override for web_extract (e.g. "native")
+ },
+
"browser": {
"inactivity_timeout": 120,
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
"record_sessions": False, # Auto-record browser sessions as WebM videos
"allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
+ # Browser engine for local mode. Passed as ``--engine `` to
+ # agent-browser v0.25.3+.
+ # "auto" — use Chrome (default, don't pass --engine at all)
+ # "lightpanda" — use Lightpanda (1.3-5.8x faster navigation, no screenshots)
+ # "chrome" — explicitly request Chrome
+ # Also settable via AGENT_BROWSER_ENGINE env var.
+ "engine": "auto",
+ "auto_local_for_private_urls": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud
"cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome
# CDP supervisor — dialog + frame detection via a persistent WebSocket.
# Active only when a CDP-capable backend is attached (Browserbase or
@@ -481,11 +580,42 @@ def _ensure_hermes_home_managed(home: Path):
},
# Filesystem checkpoints — automatic snapshots before destructive file ops.
- # When enabled, the agent takes a snapshot of the working directory once per
- # conversation turn (on first write_file/patch call). Use /rollback to restore.
+ # When enabled, the agent takes a snapshot of the working directory once
+ # per conversation turn (on first write_file/patch call). Use /rollback
+ # to restore.
+ #
+ # Defaults changed in v2 (single shared shadow store, real pruning):
+ # - enabled: True -> False (opt-in; most users never use /rollback)
+ # - max_snapshots: 50 -> 20 (now actually enforced via ref rewrite)
+ # - auto_prune: False -> True (orphans/stale pruned automatically)
+ # Opt in via ``hermes chat --checkpoints`` or set enabled=True here.
"checkpoints": {
- "enabled": True,
- "max_snapshots": 50, # Max checkpoints to keep per directory
+ "enabled": False,
+ # Max checkpoints to keep per working directory. Pre-v2 this only
+ # limited the `/rollback` listing; v2 actually rewrites the ref and
+ # garbage-collects older commits.
+ "max_snapshots": 20,
+ # Hard ceiling on total ``~/.hermes/checkpoints/`` size (MB). When
+ # exceeded, the oldest checkpoint per project is dropped in a
+ # round-robin pass until total size falls under the cap.
+ # 0 disables the size cap.
+ "max_total_size_mb": 500,
+ # Skip any single file larger than this when staging a checkpoint.
+ # Prevents accidental snapshotting of datasets, model weights, and
+ # other large generated assets. 0 disables the filter.
+ "max_file_size_mb": 10,
+ # Auto-maintenance: hermes sweeps the checkpoint base at startup
+ # (at most once per ``min_interval_hours``) and:
+ # * deletes project entries whose workdir no longer exists (orphan)
+ # * deletes project entries whose last_touch is older than
+ # ``retention_days``
+ # * GCs the single shared store to reclaim unreachable objects
+ # * enforces ``max_total_size_mb`` across remaining projects
+ # * deletes ``legacy-*`` archives older than ``retention_days``
+ "auto_prune": True,
+ "retention_days": 7,
+ "delete_orphans": True,
+ "min_interval_hours": 24,
},
# Maximum characters returned by a single read_file call. Reads that
@@ -513,12 +643,30 @@ def _ensure_hermes_home_managed(home: Path):
"max_line_length": 2000,
},
+ # Tool loop guardrails nudge models when they repeat failed or
+ # non-progressing tool calls. Soft warnings are always-on by default;
+ # hard stops are opt-in so interactive CLI/TUI sessions keep flowing.
+ "tool_loop_guardrails": {
+ "warnings_enabled": True,
+ "hard_stop_enabled": False,
+ "warn_after": {
+ "exact_failure": 2,
+ "same_tool_failure": 3,
+ "idempotent_no_progress": 2,
+ },
+ "hard_stop_after": {
+ "exact_failure": 5,
+ "same_tool_failure": 8,
+ "idempotent_no_progress": 5,
+ },
+ },
+
"compression": {
"enabled": True,
"threshold": 0.50, # compress when context usage exceeds this ratio
"target_ratio": 0.20, # fraction of threshold to preserve as recent tail
"protect_last_n": 20, # minimum recent messages to keep uncompressed
-
+ "hygiene_hard_message_limit": 400, # gateway session-hygiene force-compress threshold by message count
},
# Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
@@ -527,6 +675,18 @@ def _ensure_hermes_home_managed(home: Path):
"cache_ttl": "5m",
},
+ # OpenRouter-specific settings.
+ # response_cache: enable OpenRouter response caching (X-OpenRouter-Cache header).
+ # When enabled, identical requests return cached responses for free (zero billing).
+ # This is separate from Anthropic prompt caching and works alongside it.
+ # See: https://openrouter.ai/docs/guides/features/response-caching
+ # response_cache_ttl: how long cached responses remain valid, in seconds (1-86400).
+ # Default 300 (5 minutes). Only used when response_cache is enabled.
+ "openrouter": {
+ "response_cache": True,
+ "response_cache_ttl": 300,
+ },
+
# AWS Bedrock provider configuration.
# Only used when model.provider is "bedrock".
"bedrock": {
@@ -620,20 +780,51 @@ def _ensure_hermes_home_managed(home: Path):
"timeout": 30,
"extra_body": {},
},
+ # Curator — skill-usage review fork. Timeout is generous because the
+ # review pass can take several minutes on reasoning models (umbrella
+ # building over hundreds of candidate skills). "auto" = use main chat
+ # model; override via `hermes model` → auxiliary → Curator to route
+ # to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).
+ "curator": {
+ "provider": "auto",
+ "model": "",
+ "base_url": "",
+ "api_key": "",
+ "timeout": 600,
+ "extra_body": {},
+ },
},
"display": {
"compact": False,
"personality": "kawaii",
"resume_display": "full",
- "busy_input_mode": "interrupt",
+ "busy_input_mode": "interrupt", # interrupt | queue | steer
+ # When true, `hermes --tui` auto-resumes the most recent human-
+ # facing session on launch instead of forging a fresh one.
+ # Mirrors `hermes -c` muscle memory. Default off so existing
+ # users aren't surprised. HERMES_TUI_RESUME= always wins.
+ "tui_auto_resume_recent": False,
"bell_on_complete": False,
"show_reasoning": False,
"streaming": False,
"final_response_markdown": "strip", # render | strip | raw
+ # Preserve recent classic CLI output across Ctrl+L, /redraw, and
+ # terminal resize full-screen clears. Disable if a terminal emulator
+ # behaves badly with replayed scrollback.
+ "persistent_output": True,
+ "persistent_output_max_lines": 200,
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
"show_cost": False, # Show $ cost in the status bar (off by default)
"skin": "default",
+ # UI language for static user-facing messages (approval prompts, a
+ # handful of gateway slash-command replies). Does NOT affect agent
+ # responses, log lines, tool outputs, or slash-command descriptions.
+ # Supported: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.
+ "language": "en",
+ # TUI busy indicator style: kaomoji (default), emoji, unicode (braille
+ # spinner), or ascii. Live-swappable via `/indicator
+
+
+
+
+
+
+
+Aa Mondwest priming glyphs 0123456789
+
+
+
+