Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

This commit is contained in:
Brooklyn Nicholson
2026-05-04 12:47:53 -05:00
182 changed files with 9843 additions and 974 deletions
+33 -1
View File
@@ -5,11 +5,43 @@ Provides subcommands for:
- hermes chat - Interactive chat (same as ./hermes)
- hermes gateway - Run gateway in foreground
- hermes gateway start - Start gateway service
- hermes gateway stop - Stop gateway service
- hermes gateway stop - Stop gateway service
- hermes setup - Interactive setup wizard
- hermes status - Show status of all components
- hermes cron - Manage cron jobs
"""
import os
import sys
__version__ = "0.12.0"
__release_date__ = "2026.4.30"
def _ensure_utf8():
"""Force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError.
Windows services and terminals default to cp1252, which cannot encode
box-drawing characters used in CLI output. This causes unhandled
UnicodeEncodeError crashes on gateway startup.
"""
if sys.platform != "win32":
return
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is None:
continue
try:
if getattr(stream, "encoding", "").lower().replace("-", "") != "utf8":
new_stream = open(
stream.fileno(), "w", encoding="utf-8",
buffering=1, closefd=False,
)
setattr(sys, stream_name, new_stream)
except (AttributeError, OSError):
pass
_ensure_utf8()
+263 -13
View File
@@ -2589,6 +2589,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 <name> 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/<name>/) 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,
@@ -2991,6 +3193,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),
@@ -3059,6 +3267,11 @@ def resolve_nous_runtime_credentials(
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)
@@ -4283,7 +4496,8 @@ def _minimax_oauth_login(
print(f"Portal: {portal_base_url}")
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
headers={"Accept": "application/json"}) as client:
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,
@@ -4360,7 +4574,8 @@ def _refresh_minimax_oauth_state(
return state
portal_base_url = state["portal_base_url"]
with httpx.Client(timeout=httpx.Timeout(timeout_seconds)) as client:
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
follow_redirects=True) as client:
response = client.post(
f"{portal_base_url}/oauth/token",
data={
@@ -4598,17 +4813,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"]
@@ -4625,6 +4870,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}")
+41
View File
@@ -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 <name>
# 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),
+15 -2
View File
@@ -61,6 +61,9 @@ _EXCLUDED_NAMES = {
"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."""
@@ -381,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}")
@@ -788,9 +793,17 @@ def _prune_pre_update_backups(backup_dir: Path, keep: int) -> int:
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: <path>`` 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 < 0:
keep = 0
if keep < 1:
keep = 1
if not backup_dir.exists():
return 0
+30 -4
View File
@@ -64,7 +64,7 @@ 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("clear", "Clear screen and start a new session", "Session",
cli_only=True),
CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session",
@@ -399,6 +399,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()
@@ -455,7 +460,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.
@@ -465,10 +472,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))
@@ -1115,6 +1126,12 @@ class SlashCommandCompleter(Completer):
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.
@@ -1123,8 +1140,17 @@ class SlashCommandCompleter(Completer):
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:
+7 -2
View File
@@ -1292,7 +1292,10 @@ DEFAULT_CONFIG = {
# for a single update run.
"pre_update_backup": False,
# How many pre-update backup zips to retain. Older ones are pruned
# automatically after each successful backup.
# automatically after each successful backup. Values below 1 are
# floored to 1 — the backup just created is always preserved. To
# disable backups entirely, set ``pre_update_backup: false`` above
# rather than ``backup_keep: 0``.
"backup_keep": 5,
},
@@ -4682,7 +4685,9 @@ def set_config_value(key: str, value: str):
"terminal.vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"terminal.cwd": "TERMINAL_CWD",
# terminal.cwd intentionally excluded — CLI resolves at runtime,
# gateway bridges it in gateway/run.py. Persisting to .env causes
# stale values to poison child processes.
"terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
+6
View File
@@ -156,6 +156,8 @@ def curses_checklist(
flush_stdin()
return result_holder[0] if result_holder[0] is not None else cancel_returns
except KeyboardInterrupt:
return cancel_returns
except Exception:
return _numbered_fallback(title, items, selected, cancel_returns, status_fn)
@@ -278,6 +280,8 @@ def curses_radiolist(
flush_stdin()
return result_holder[0] if result_holder[0] is not None else cancel_returns
except KeyboardInterrupt:
return cancel_returns
except Exception:
return _radio_numbered_fallback(title, items, selected, cancel_returns)
@@ -401,6 +405,8 @@ def curses_single_select(
return None
return result_holder[0]
except KeyboardInterrupt:
return None
except Exception:
all_items = list(items) + [cancel_label]
cancel_idx = len(items)
+82 -7
View File
@@ -1,12 +1,19 @@
"""``hermes debug`` debug tools for Hermes Agent.
"""``hermes debug`` debug tools for Hermes Agent.
Currently supports:
hermes debug share Upload debug report (system info + logs) to a
paste service and print a shareable URL.
By default, log content is run through
``agent.redact.redact_sensitive_text`` with
``force=True`` before upload so credentials in
``~/.hermes/logs/*.log`` are not leaked into
the public paste service. Pass ``--no-redact``
to disable.
"""
import io
import json
import logging
import sys
import time
import urllib.error
@@ -19,6 +26,16 @@ from typing import Optional
from hermes_constants import get_hermes_home
from utils import atomic_replace
logger = logging.getLogger(__name__)
# Banner prepended to upload-bound log content when redaction is enabled.
# Visible in the public paste so reviewers know the content was sanitized.
# Kept short; the trailing newline guarantees the banner sits on its own line.
_REDACTION_BANNER = (
"[hermes debug share: log content redacted at upload time. "
"run with --no-redact to disable]\n"
)
# ---------------------------------------------------------------------------
# Paste services — try paste.rs first, dpaste.com as fallback.
@@ -368,17 +385,40 @@ def _resolve_log_path(log_name: str) -> Optional[Path]:
return None
def _redact_log_text(text: str) -> str:
"""Run ``redact_sensitive_text`` with ``force=True`` over upload-bound text.
Uses ``force=True`` so redaction fires regardless of the operator's
``security.redact_secrets`` setting. The local on-disk log file is
not modified; only the in-memory copy headed for the public paste
service is sanitized. Returns the redacted text (or the original
when empty / non-string).
"""
if not text:
return text
from agent.redact import redact_sensitive_text
return redact_sensitive_text(text, force=True)
def _capture_log_snapshot(
log_name: str,
*,
tail_lines: int,
max_bytes: int = _MAX_LOG_BYTES,
redact: bool = True,
) -> LogSnapshot:
"""Capture a log once and derive summary/full-log views from it.
The report tail and standalone log upload must come from the same file
snapshot. Otherwise a rotation/truncate between reads can make the report
look newer than the uploaded ``agent.log`` paste.
When ``redact`` is True (the default), both ``tail_text`` and
``full_text`` are run through ``_redact_log_text`` so the snapshot
returned is upload-safe. The on-disk log file is never modified.
Pass ``redact=False`` to capture original log content (used by
``hermes debug share --no-redact``).
"""
log_path = _resolve_log_path(log_name)
if log_path is None:
@@ -438,18 +478,34 @@ def _capture_log_snapshot(
if truncated:
full_text = f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{full_text}"
if redact:
tail_text = _redact_log_text(tail_text)
full_text = _redact_log_text(full_text)
return LogSnapshot(path=log_path, tail_text=tail_text, full_text=full_text)
except Exception as exc:
return LogSnapshot(path=log_path, tail_text=f"(error reading: {exc})", full_text=None)
def _capture_default_log_snapshots(log_lines: int) -> dict[str, LogSnapshot]:
"""Capture all logs used by debug-share exactly once."""
def _capture_default_log_snapshots(
log_lines: int, *, redact: bool = True
) -> dict[str, LogSnapshot]:
"""Capture all logs used by debug-share exactly once.
``redact`` is forwarded to each ``_capture_log_snapshot`` call so all
captured logs share the same redaction policy for a given run.
"""
errors_lines = min(log_lines, 100)
return {
"agent": _capture_log_snapshot("agent", tail_lines=log_lines),
"errors": _capture_log_snapshot("errors", tail_lines=errors_lines),
"gateway": _capture_log_snapshot("gateway", tail_lines=errors_lines),
"agent": _capture_log_snapshot(
"agent", tail_lines=log_lines, redact=redact
),
"errors": _capture_log_snapshot(
"errors", tail_lines=errors_lines, redact=redact
),
"gateway": _capture_log_snapshot(
"gateway", tail_lines=errors_lines, redact=redact
),
}
@@ -532,6 +588,7 @@ def run_debug_share(args):
log_lines = getattr(args, "lines", 200)
expiry = getattr(args, "expire", 7)
local_only = getattr(args, "local", False)
redact = not getattr(args, "no_redact", False)
if not local_only:
print(_PRIVACY_NOTICE)
@@ -539,8 +596,16 @@ def run_debug_share(args):
print("Collecting debug report...")
# Capture dump once — prepended to every paste for context.
# The dump is already redacted at extract time via dump.py:_redact;
# log_snapshots are redacted by _capture_default_log_snapshots when
# redact=True so credentials never reach the public paste service.
dump_text = _capture_dump()
log_snapshots = _capture_default_log_snapshots(log_lines)
log_snapshots = _capture_default_log_snapshots(log_lines, redact=redact)
if redact:
logger.info(
"hermes debug share: applied force-mode redaction to log snapshots before upload"
)
report = collect_debug_report(
log_lines=log_lines,
@@ -556,6 +621,15 @@ def run_debug_share(args):
if gateway_log:
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
# Visible banner so reviewers reading the public paste know redaction
# was applied at upload time. Banner is omitted under --no-redact.
if redact:
report = _REDACTION_BANNER + report
if agent_log:
agent_log = _REDACTION_BANNER + agent_log
if gateway_log:
gateway_log = _REDACTION_BANNER + gateway_log
if local_only:
print(report)
if agent_log:
@@ -666,6 +740,7 @@ def run_debug(args):
print(" --lines N Number of log lines to include (default: 200)")
print(" --expire N Paste expiry in days (default: 7)")
print(" --local Print report locally instead of uploading")
print(" --no-redact Disable upload-time secret redaction (default: redact)")
print()
print("Options (delete):")
print(" <url> ... One or more paste URLs to delete")
+5 -2
View File
@@ -935,6 +935,8 @@ def run_doctor(args):
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
if agent_browser_path.exists():
check_ok("agent-browser (Node.js)", "(browser automation)")
elif shutil.which("agent-browser"):
check_ok("agent-browser", "(browser automation)")
else:
if _is_termux():
check_info("agent-browser is not installed (expected in the tested Termux path)")
@@ -1096,9 +1098,10 @@ def run_doctor(args):
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
# MiniMax: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.
# MiniMax global: /v1 endpoint supports /models.
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", True),
# MiniMax CN: /v1 endpoint does NOT support /models (returns 404).
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False),
("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
+79
View File
@@ -237,6 +237,26 @@ def _graceful_restart_via_sigusr1(pid: int, drain_timeout: float) -> bool:
return False
def _get_ancestor_pids() -> set[int]:
"""Return the set of PIDs in the current process's ancestor chain.
Walks from the current PID up to PID 1 (init) so that process-table scans
never match the calling CLI process or any of its parents. This prevents
``hermes gateway status`` from falsely counting the ``hermes`` CLI that
invoked it as a running gateway instance (see #13242).
"""
ancestors: set[int] = set()
pid = os.getpid()
# Cap iterations to avoid infinite loops on exotic platforms.
for _ in range(64):
ancestors.add(pid)
parent = _get_parent_pid(pid)
if parent is None or parent <= 0 or parent in ancestors:
break
pid = parent
return ancestors
def _append_unique_pid(pids: list[int], pid: int | None, exclude_pids: set[int]) -> None:
if pid is None or pid <= 0:
return
@@ -252,6 +272,10 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
a live gateway when the PID file is stale/missing, and ``--all`` sweeps can
discover gateways outside the current profile.
"""
# Exclude the entire ancestor chain so the CLI process that invoked this
# scan (e.g. ``hermes gateway status``) is never mistaken for a running
# gateway. See #13242.
exclude_pids = exclude_pids | _get_ancestor_pids()
pids: list[int] = []
patterns = [
"hermes_cli.main gateway",
@@ -690,6 +714,32 @@ def _print_gateway_process_mismatch(snapshot: GatewayRuntimeSnapshot) -> None:
print(" can refuse to start another copy until this process stops.")
def _print_other_profiles_gateway_status() -> None:
"""Print a summary of gateway status across all profiles.
Shown at the bottom of ``hermes gateway status`` output so users with
multiple profiles can tell at a glance which gateways are running and
avoid confusing another profile's process with the current one.
"""
try:
from hermes_cli.profiles import get_active_profile_name
current = get_active_profile_name()
other_processes = [
p for p in find_profile_gateway_processes()
if p.profile != current
]
if not other_processes:
return
print()
print("Other profiles:")
for proc in other_processes:
print(f"{proc.profile:<16s} — PID {proc.pid}")
except Exception:
pass
def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None,
all_profiles: bool = False) -> int:
"""Kill any running gateway processes. Returns count killed.
@@ -1921,6 +1971,15 @@ def systemd_uninstall(system: bool = False):
print(f"{_service_scope_label(system).capitalize()} service uninstalled")
def _require_service_installed(action: str, system: bool = False) -> None:
unit_path = get_systemd_unit_path(system=system)
if not unit_path.exists():
scope_flag = " --system" if system else ""
print(f"✗ Gateway service is not installed")
print(f" Run: {'sudo ' if system else ''}hermes gateway install{scope_flag}")
sys.exit(1)
def systemd_start(system: bool = False):
system = _select_systemd_scope(system)
if system:
@@ -1930,6 +1989,7 @@ def systemd_start(system: bool = False):
# reachable (common on fresh RHEL/Debian SSH sessions without linger).
# Raises UserSystemdUnavailableError with a remediation message.
_preflight_user_systemd()
_require_service_installed("start", system=system)
refresh_systemd_unit_if_needed(system=system)
_run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30)
print(f"{_service_scope_label(system).capitalize()} service started")
@@ -1940,6 +2000,7 @@ def systemd_stop(system: bool = False):
system = _select_systemd_scope(system)
if system:
_require_root_for_system_service("stop")
_require_service_installed("stop", system=system)
_run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90)
print(f"{_service_scope_label(system).capitalize()} service stopped")
@@ -1951,6 +2012,7 @@ def systemd_restart(system: bool = False):
_require_root_for_system_service("restart")
else:
_preflight_user_systemd()
_require_service_installed("restart", system=system)
refresh_systemd_unit_if_needed(system=system)
from gateway.status import get_running_pid
@@ -2442,6 +2504,20 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
hasn't fully exited yet.
"""
sys.path.insert(0, str(PROJECT_ROOT))
# Refresh the systemd unit definition on every boot so that restart
# settings (RestartSec, StartLimitIntervalSec, etc.) stay current even
# when the process was respawned via exit-code-75 (stale-code or
# /restart) rather than through `hermes gateway restart` which already
# calls refresh_systemd_unit_if_needed(). Without this, a code update
# that ships new unit settings won't take effect until the next manual
# `hermes gateway start/restart` — leaving the gateway vulnerable to
# the exact failure mode the new settings were meant to prevent.
if supports_systemd_services():
try:
refresh_systemd_unit_if_needed(system=False)
except Exception:
pass # best-effort; don't block gateway startup
from gateway.run import start_gateway
@@ -4456,6 +4532,9 @@ def _gateway_command_inner(args):
print(" hermes gateway install # Install as user service")
print(" sudo hermes gateway install --system # Install as boot-time system service")
# Show other profiles' gateway status for multi-profile awareness
_print_other_profiles_gateway_status()
elif subcmd == "migrate-legacy":
# Stop, disable, and remove legacy Hermes gateway unit files from
# pre-rename installs (e.g. hermes.service). Profile units and
+309 -1
View File
@@ -169,11 +169,93 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
"or docs/hermes-kanban-v1-spec.pdf for the full design."
),
)
# --- global --board flag ---
# Applies to every subcommand below. When set, scopes all reads and
# writes to that board's DB. When omitted, resolves via the
# HERMES_KANBAN_BOARD env var, then the persisted current-board
# file, then "default". See kanban_db.get_current_board().
kanban_parser.add_argument(
"--board",
default=None,
metavar="<slug>",
help=(
"Board slug to operate on. Defaults to the current board "
"(set via `hermes kanban boards switch <slug>` or the "
"HERMES_KANBAN_BOARD env var). Use `hermes kanban boards list` "
"to see all boards."
),
)
sub = kanban_parser.add_subparsers(dest="kanban_action")
# --- init ---
sub.add_parser("init", help="Create kanban.db if missing (idempotent)")
# --- boards (new in v2: multi-project support) ---
p_boards = sub.add_parser(
"boards",
help="Manage kanban boards (one board per project / workstream)",
description=(
"Boards let you separate unrelated streams of work "
"(projects, repos, domains) into isolated queues. Each "
"board has its own DB, workspaces directory, and dispatcher "
"loop — tasks on one board cannot collide with tasks on "
"another. The first board is 'default' and always exists."
),
)
boards_sub = p_boards.add_subparsers(dest="boards_action")
b_list = boards_sub.add_parser(
"list", aliases=["ls"],
help="List all boards with task counts",
)
b_list.add_argument("--json", action="store_true")
b_list.add_argument("--all", action="store_true",
help="Include archived boards too")
b_create = boards_sub.add_parser(
"create", aliases=["new"],
help="Create a new board",
)
b_create.add_argument("slug",
help="Board slug (kebab-case, e.g. atm10-server)")
b_create.add_argument("--name", default=None,
help="Human-readable display name (defaults to Title Case of slug)")
b_create.add_argument("--description", default=None,
help="Optional description")
b_create.add_argument("--icon", default=None,
help="Optional emoji or single-character icon for the dashboard")
b_create.add_argument("--color", default=None,
help="Optional hex color (e.g. '#8b5cf6') for the dashboard")
b_create.add_argument("--switch", action="store_true",
help="Switch to the new board after creating it")
b_rm = boards_sub.add_parser(
"rm", aliases=["remove", "delete"],
help="Archive (default) or delete a board",
)
b_rm.add_argument("slug")
b_rm.add_argument("--delete", action="store_true",
help="Hard-delete the board directory instead of archiving it. "
"Default is to move it to boards/_archived/ so it's recoverable.")
b_switch = boards_sub.add_parser(
"switch", aliases=["use"],
help="Set the active board for subsequent CLI calls",
)
b_switch.add_argument("slug")
boards_sub.add_parser(
"show", aliases=["current"],
help="Print the currently-active board slug",
)
b_rename = boards_sub.add_parser(
"rename",
help="Change a board's human-readable display name (slug is immutable)",
)
b_rename.add_argument("slug")
b_rename.add_argument("name", help="New display name")
# --- create ---
p_create = sub.add_parser("create", help="Create a new task")
p_create.add_argument("title", help="Task title")
@@ -366,7 +448,7 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
# --- log ---
p_log = sub.add_parser(
"log",
help="Print the worker log for a task (from $HERMES_HOME/kanban/logs/)",
help="Print the worker log for a task (from <kanban-root>/kanban/logs/)",
)
p_log.add_argument("task_id")
p_log.add_argument("--tail", type=int, default=None,
@@ -442,6 +524,38 @@ def kanban_command(args: argparse.Namespace) -> int:
)
return 0
# `--board <slug>` applies to every subcommand below by way of an
# env-var pin for the duration of this call. Using HERMES_KANBAN_BOARD
# (rather than threading `board=` through 50+ kb.connect() sites)
# keeps the patch small and inherits the exact same resolution the
# dispatcher uses for workers — consistency is a feature here.
board_override = getattr(args, "board", None)
if board_override:
try:
normed = kb._normalize_board_slug(board_override)
except ValueError as exc:
print(f"kanban: {exc}", file=sys.stderr)
return 2
if not normed:
print("kanban: --board requires a slug", file=sys.stderr)
return 2
# Boards other than 'default' must already exist — typoed slugs
# would otherwise silently create an empty board.
if normed != kb.DEFAULT_BOARD and not kb.board_exists(normed):
print(
f"kanban: board {normed!r} does not exist. "
f"Create it with `hermes kanban boards create {normed}`.",
file=sys.stderr,
)
return 1
os.environ["HERMES_KANBAN_BOARD"] = normed
# Boards management doesn't touch the DB at all — dispatch early so
# fresh installs that haven't initialized any DB can still use
# `hermes kanban boards create …`.
if action == "boards":
return _dispatch_boards(args)
# Auto-initialize the DB before dispatching any subcommand. init_db
# is idempotent, so running it every invocation is cheap (one
# SELECT against sqlite_master when tables already exist) and
@@ -513,6 +627,185 @@ def _profile_author() -> str:
return "user"
# ---------------------------------------------------------------------------
# Boards management (hermes kanban boards …)
# ---------------------------------------------------------------------------
def _dispatch_boards(args: argparse.Namespace) -> int:
"""Handle ``hermes kanban boards <action>``.
Boards management is deliberately separate from the task-level
commands: it operates on the filesystem (board directories,
``current`` pointer, ``board.json``), not on the per-board SQLite
DB, so a fresh HERMES_HOME that has never called ``kanban init``
can still run ``boards create`` / ``boards list``.
"""
sub = getattr(args, "boards_action", None) or "list"
if sub in ("list", "ls"):
return _cmd_boards_list(args)
if sub in ("create", "new"):
return _cmd_boards_create(args)
if sub in ("rm", "remove", "delete"):
return _cmd_boards_rm(args)
if sub in ("switch", "use"):
return _cmd_boards_switch(args)
if sub in ("show", "current"):
return _cmd_boards_show(args)
if sub == "rename":
return _cmd_boards_rename(args)
print(f"kanban boards: unknown action {sub!r}", file=sys.stderr)
return 2
def _board_task_counts(slug: str) -> dict[str, int]:
"""Return ``{status: count}`` for a board. Safe to call on an empty DB."""
try:
path = kb.kanban_db_path(board=slug)
if not path.exists():
return {}
with kb.connect(board=slug) as conn:
rows = conn.execute(
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
).fetchall()
return {r["status"]: int(r["n"]) for r in rows}
except Exception:
return {}
def _cmd_boards_list(args: argparse.Namespace) -> int:
include_archived = bool(getattr(args, "all", False))
boards = kb.list_boards(include_archived=include_archived)
# Enrich each entry with task counts + whether it's the current board.
current = kb.get_current_board()
for b in boards:
b["is_current"] = (b["slug"] == current)
b["counts"] = _board_task_counts(b["slug"])
b["total"] = sum(b["counts"].values())
if getattr(args, "json", False):
print(json.dumps(boards, indent=2, ensure_ascii=False))
return 0
# Human table: marker (•) for current, slug, display name, counts.
if not boards:
print("(no boards — create one with `hermes kanban boards create <slug>`)")
return 0
print(f"{'':2s} {'SLUG':24s} {'NAME':28s} COUNTS")
for b in boards:
marker = "" if b["is_current"] else " "
counts = b["counts"] or {}
counts_str = (
", ".join(f"{k}={v}" for k, v in sorted(counts.items()))
or "(empty)"
)
name = b.get("name") or ""
if b.get("archived"):
name += " [archived]"
print(f"{marker:2s} {b['slug']:24s} {name:28s} {counts_str}")
print()
print(f"Current board: {current}")
if len(boards) > 1:
print("Switch boards with `hermes kanban boards switch <slug>`.")
return 0
def _cmd_boards_create(args: argparse.Namespace) -> int:
try:
normed = kb._normalize_board_slug(args.slug)
except ValueError as exc:
print(f"kanban boards create: {exc}", file=sys.stderr)
return 2
if not normed:
print("kanban boards create: slug is required", file=sys.stderr)
return 2
already = kb.board_exists(normed) and normed != kb.DEFAULT_BOARD
meta = kb.create_board(
normed,
name=args.name,
description=args.description,
icon=args.icon,
color=args.color,
)
verb = "already exists" if already else "created"
print(f"Board {meta['slug']!r} {verb}.")
print(f" Display name: {meta.get('name', '')}")
print(f" DB path: {meta['db_path']}")
if getattr(args, "switch", False):
kb.set_current_board(meta["slug"])
print(f" Switched to {meta['slug']!r}.")
else:
print(f" Use `hermes kanban boards switch {meta['slug']}` to make it current.")
return 0
def _cmd_boards_rm(args: argparse.Namespace) -> int:
try:
res = kb.remove_board(args.slug, archive=not getattr(args, "delete", False))
except ValueError as exc:
print(f"kanban boards rm: {exc}", file=sys.stderr)
return 1
if res["action"] == "archived":
print(f"Board {res['slug']!r} archived → {res['new_path']}")
print("Recover by moving the directory back to "
"<root>/kanban/boards/<slug>/.")
else:
print(f"Board {res['slug']!r} deleted.")
return 0
def _cmd_boards_switch(args: argparse.Namespace) -> int:
try:
normed = kb._normalize_board_slug(args.slug)
except ValueError as exc:
print(f"kanban boards switch: {exc}", file=sys.stderr)
return 2
if not normed:
print("kanban boards switch: slug is required", file=sys.stderr)
return 2
if not kb.board_exists(normed):
print(
f"kanban boards switch: board {normed!r} does not exist. "
f"Create it with `hermes kanban boards create {normed}`.",
file=sys.stderr,
)
return 1
kb.set_current_board(normed)
print(f"Active board is now {normed!r}.")
return 0
def _cmd_boards_show(args: argparse.Namespace) -> int:
current = kb.get_current_board()
meta = kb.read_board_metadata(current)
counts = _board_task_counts(current)
total = sum(counts.values())
print(f"Current board: {current}")
print(f" Display name: {meta.get('name', '')}")
if meta.get("description"):
print(f" Description: {meta['description']}")
print(f" DB path: {meta['db_path']}")
print(f" Tasks: {total} total"
+ (f" ({', '.join(f'{k}={v}' for k, v in sorted(counts.items()))})"
if counts else ""))
return 0
def _cmd_boards_rename(args: argparse.Namespace) -> int:
try:
normed = kb._normalize_board_slug(args.slug)
except ValueError as exc:
print(f"kanban boards rename: {exc}", file=sys.stderr)
return 2
if not normed or not kb.board_exists(normed):
print(f"kanban boards rename: board {args.slug!r} does not exist",
file=sys.stderr)
return 1
meta = kb.write_board_metadata(normed, name=args.name)
print(f"Board {normed!r} renamed to {meta['name']!r}.")
return 0
# ---------------------------------------------------------------------------
def _parse_duration(val) -> Optional[int]:
"""Parse ``30s`` / ``5m`` / ``2h`` / ``1d`` or a raw integer → seconds.
@@ -662,6 +955,21 @@ def _cmd_list(args: argparse.Namespace) -> int:
if getattr(args, "json", False):
print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False))
return 0
# Passive discoverability: when the user has multiple boards, surface
# which one they're looking at in the list header. Single-board users
# never see this — the feature stays invisible until you opt in.
try:
all_boards = kb.list_boards(include_archived=False)
except Exception:
all_boards = []
if len(all_boards) > 1:
current = kb.get_current_board()
other_count = len(all_boards) - 1
print(
f"Board: {current} "
f"({other_count} other board{'s' if other_count != 1 else ''}"
f"`hermes kanban boards list`)\n"
)
if not tasks:
print("(no matching tasks)")
return 0
+602 -41
View File
@@ -1,8 +1,56 @@
"""SQLite-backed Kanban board for multi-profile collaboration.
"""SQLite-backed Kanban board for multi-profile, multi-project collaboration.
The board lives at ``$HERMES_HOME/kanban.db`` (profile-agnostic on purpose:
multiple profiles on the same machine all see the same board, which IS the
coordination primitive).
In a fresh install the board lives at ``<root>/kanban.db`` where
``<root>`` is the **shared Hermes root** (the parent of any active
profile). Profiles intentionally collapse onto a shared board: it IS
the cross-profile coordination primitive. A worker spawned with
``hermes -p <profile>`` joins the same board as the dispatcher that
claimed the task. The same applies to ``<root>/kanban/workspaces/`` and
``<root>/kanban/logs/``.
**Multiple boards (projects):** users can create additional boards to
separate unrelated streams of work (e.g. one per project / repo / domain).
Each board is a directory under ``<root>/kanban/boards/<slug>/`` with
its own ``kanban.db``, ``workspaces/``, and ``logs/``. All boards share
the profile's Hermes home but are otherwise isolated: a worker spawned
for a task on board ``atm10-server`` sees only that board's tasks,
cannot enumerate other boards, and its dispatcher ticks don't touch
other boards' DBs.
The first (and for single-project users, only) board is ``default``.
For back-compat its on-disk DB is ``<root>/kanban.db`` (not
``boards/default/kanban.db``), so installs that predate the boards
feature keep working with zero migration. See :func:`kanban_db_path`.
Board resolution order (highest precedence first, all optional):
* ``board=`` argument passed directly to :func:`connect` / :func:`init_db`
(explicit used by the CLI ``--board`` flag and the dashboard
``?board=...`` query param).
* ``HERMES_KANBAN_BOARD`` env var (used by the dispatcher to pin workers
to the board their task lives on workers cannot see other boards).
* ``HERMES_KANBAN_DB`` env var (pins the DB file path directly legacy
override still honoured; highest precedence when the file path itself
is what the caller wants to force).
* ``<root>/kanban/current`` a one-line text file holding the slug of
the "currently selected" board. Written by ``hermes kanban boards
switch <slug>``. When absent, the active board is ``default``.
In standard installs ``<root>`` is ``~/.hermes``. In Docker / custom
deployments where ``HERMES_HOME`` points outside ``~/.hermes`` (e.g.
``/opt/hermes``), ``<root>`` is ``HERMES_HOME``. Legacy env-var
overrides still work:
* ``HERMES_KANBAN_DB`` pin the database file path directly.
* ``HERMES_KANBAN_WORKSPACES_ROOT`` pin the workspaces root directly.
* ``HERMES_KANBAN_HOME`` pin the umbrella root that anchors kanban
paths. Useful for tests and unusual deployments.
The dispatcher injects ``HERMES_KANBAN_DB``,
``HERMES_KANBAN_WORKSPACES_ROOT``, and ``HERMES_KANBAN_BOARD`` into
worker subprocess env so workers converge on the exact DB the
dispatcher used to claim their task even under unusual symlink or
Docker layouts.
Schema is intentionally small: tasks, task_links, task_comments,
task_events. The ``workspace_kind`` field decouples coordination from git
@@ -15,6 +63,9 @@ transactions + compare-and-swap (CAS) updates on ``tasks.status`` and
``tasks.claim_lock``. SQLite serializes writers via its WAL lock, so at
most one claimer can win any given task. Losers observe zero affected
rows and move on -- no retry loops, no distributed-lock machinery.
The CAS coordination is **per-board** each board is a separate DB,
so multi-board installs get the same atomicity guarantees without any
new locking.
"""
from __future__ import annotations
@@ -22,6 +73,7 @@ from __future__ import annotations
import contextlib
import json
import os
import re
import secrets
import sqlite3
import sys
@@ -61,16 +113,438 @@ _CTX_MAX_COMMENT_BYTES = 2 * 1024 # 2 KB per comment
# Paths
# ---------------------------------------------------------------------------
def kanban_db_path() -> Path:
"""Return the path to ``kanban.db`` inside the active HERMES_HOME."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "kanban.db"
DEFAULT_BOARD = "default"
# Slug validator: lowercase alphanumerics, digits, hyphens; 164 chars.
# Strict enough to stop traversal (`..`) and embedded path separators, loose
# enough that kebab-case names like ``atm10-server`` or ``hermes-agent``
# pass without fuss. Board names with display formatting (spaces, emoji)
# live in ``board.json``; the slug is just the directory name.
_BOARD_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$")
def workspaces_root() -> Path:
"""Return the directory under which ``scratch`` workspaces are created."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "kanban" / "workspaces"
def _normalize_board_slug(slug: Optional[str]) -> Optional[str]:
"""Lowercase + strip a slug; validate; return ``None`` for empty."""
if slug is None:
return None
s = str(slug).strip().lower()
if not s:
return None
if not _BOARD_SLUG_RE.match(s):
raise ValueError(
f"invalid board slug {slug!r}: must be 1-64 chars, lowercase "
f"alphanumerics / hyphens / underscores, not starting with '-' or '_'"
)
return s
def kanban_home() -> Path:
"""Return the shared Hermes root that anchors the kanban board.
Resolution order:
1. ``HERMES_KANBAN_HOME`` env var when set and non-empty (explicit
override for tests and unusual deployments).
2. ``get_default_hermes_root()``, which already returns ``<root>``
when ``HERMES_HOME`` is ``<root>/profiles/<name>``, and returns
``HERMES_HOME`` directly for Docker / custom deployments.
The kanban board is shared across profiles **by design** (see the
module docstring). Resolving the kanban paths through the active
profile's ``HERMES_HOME`` would silently fork the board per profile,
which breaks the dispatcher / worker handoff.
"""
override = os.environ.get("HERMES_KANBAN_HOME", "").strip()
if override:
return Path(override).expanduser()
from hermes_constants import get_default_hermes_root
return get_default_hermes_root()
def boards_root() -> Path:
"""Return ``<root>/kanban/boards`` — the parent of non-default board dirs.
``default`` is intentionally NOT under this directory its DB lives at
``<root>/kanban.db`` for back-compat with pre-boards installs. This
function returns the directory where *additional* named boards live,
used by :func:`list_boards` to enumerate them.
"""
return kanban_home() / "kanban" / "boards"
def current_board_path() -> Path:
"""Return the path to ``<root>/kanban/current``.
One-line text file written by ``hermes kanban boards switch <slug>``
to persist the user's board selection across CLI invocations. Absent
by default (meaning: active board is ``default``).
"""
return kanban_home() / "kanban" / "current"
def get_current_board() -> str:
"""Return the active board slug, honouring the resolution chain.
Order (highest precedence first):
1. ``HERMES_KANBAN_BOARD`` env var (set by the dispatcher on worker
spawn, or manually for ad-hoc overrides).
2. ``<root>/kanban/current`` on disk (set by ``hermes kanban boards
switch``).
3. ``DEFAULT_BOARD`` (``"default"``).
A malformed slug at any step falls through to the next layer with a
best-effort warning the dispatcher must never crash because a user
hand-edited a file.
"""
env = os.environ.get("HERMES_KANBAN_BOARD", "").strip()
if env:
try:
normed = _normalize_board_slug(env)
if normed:
return normed
except ValueError:
pass
try:
f = current_board_path()
if f.exists():
val = f.read_text(encoding="utf-8").strip()
if val:
try:
normed = _normalize_board_slug(val)
if normed:
return normed
except ValueError:
pass
except OSError:
pass
return DEFAULT_BOARD
def set_current_board(slug: str) -> Path:
"""Persist ``slug`` as the active board. Returns the file written.
Writes ``<root>/kanban/current``. The caller should validate the slug
exists first (via :func:`board_exists`) this function does not
so that ``hermes kanban boards switch <typo>`` returns an error
instead of silently pointing at nothing.
"""
normed = _normalize_board_slug(slug)
if not normed:
raise ValueError("board slug is required")
path = current_board_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(normed + "\n", encoding="utf-8")
return path
def clear_current_board() -> None:
"""Remove ``<root>/kanban/current`` so the active board reverts to ``default``."""
try:
current_board_path().unlink()
except FileNotFoundError:
pass
def board_dir(board: Optional[str] = None) -> Path:
"""Return the on-disk directory for ``board``.
``default`` is ``<root>/kanban/boards/default/`` **for metadata only**
(board.json + workspaces/ + logs/). Its DB file stays at
``<root>/kanban.db`` for back-compat see :func:`kanban_db_path`.
All other boards live at ``<root>/kanban/boards/<slug>/`` with
everything inside that directory including the ``kanban.db``.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
return boards_root() / slug
def board_exists(board: Optional[str] = None) -> bool:
"""Return True if the board has a DB or a metadata dir on disk.
``default`` is considered to always exist its DB is created
on first :func:`connect` and there's no way for it to be missing
in a configuration where the kanban feature is usable at all.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
if slug == DEFAULT_BOARD:
return True
d = board_dir(slug)
return d.is_dir() or (d / "kanban.db").exists()
def kanban_db_path(board: Optional[str] = None) -> Path:
"""Return the path to the ``kanban.db`` for ``board``.
Resolution (highest precedence first):
1. ``HERMES_KANBAN_DB`` env var pins the path directly. Honoured for
back-compat and for the dispatcherworker handoff (defense in
depth: dispatcher injects this into worker env so workers are
immune to any path-resolution disagreement).
2. When ``board`` arg is None, the active board from
:func:`get_current_board` is used.
3. Board ``default`` ``<root>/kanban.db`` (back-compat path).
Other boards ``<root>/kanban/boards/<slug>/kanban.db``.
"""
override = os.environ.get("HERMES_KANBAN_DB", "").strip()
if override:
return Path(override).expanduser()
slug = _normalize_board_slug(board)
if slug is None:
slug = get_current_board()
if slug == DEFAULT_BOARD:
return kanban_home() / "kanban.db"
return board_dir(slug) / "kanban.db"
def workspaces_root(board: Optional[str] = None) -> Path:
"""Return the directory under which ``scratch`` workspaces are created.
Anchored per-board so workspaces don't leak between projects.
``HERMES_KANBAN_WORKSPACES_ROOT`` pins the path directly (highest
precedence) the dispatcher injects this into worker env.
``default`` keeps the legacy path ``<root>/kanban/workspaces/`` so
that existing scratch workspaces from before the boards feature are
preserved. Other boards use ``<root>/kanban/boards/<slug>/workspaces/``.
"""
override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip()
if override:
return Path(override).expanduser()
slug = _normalize_board_slug(board)
if slug is None:
slug = get_current_board()
if slug == DEFAULT_BOARD:
return kanban_home() / "kanban" / "workspaces"
return board_dir(slug) / "workspaces"
def worker_logs_dir(board: Optional[str] = None) -> Path:
"""Return the directory under which per-task worker logs are written.
``default`` keeps the legacy path ``<root>/kanban/logs/``. Other
boards use ``<root>/kanban/boards/<slug>/logs/``. Logs follow the
board makes ``hermes kanban log`` unambiguous even when multiple
boards have tasks with the same id.
"""
slug = _normalize_board_slug(board)
if slug is None:
slug = get_current_board()
if slug == DEFAULT_BOARD:
return kanban_home() / "kanban" / "logs"
return board_dir(slug) / "logs"
def board_metadata_path(board: Optional[str] = None) -> Path:
"""Return the path to ``board.json`` for ``board``.
Stores display metadata (display name, description, icon, color,
created_at). The on-disk slug is the canonical identity; this file
is purely for presentation in the CLI / dashboard.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
return board_dir(slug) / "board.json"
def _default_board_display_name(slug: str) -> str:
"""Turn a slug into a reasonable default display name.
``atm10-server`` ``Atm10 Server``. Users can override via
``board.json`` but the default should look presentable in the
dashboard without any follow-up editing.
"""
return " ".join(part.capitalize() for part in slug.replace("_", "-").split("-") if part) or slug
def read_board_metadata(board: Optional[str] = None) -> dict:
"""Return ``board.json`` contents (or synthesized defaults).
Never raises a missing / malformed ``board.json`` falls back to a
synthesised entry so the dashboard always has something to render.
Includes the canonical ``slug`` and ``db_path`` so the caller
doesn't need to reconstruct them.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
meta: dict[str, Any] = {
"slug": slug,
"name": _default_board_display_name(slug),
"description": "",
"icon": "",
"color": "",
"created_at": None,
"archived": False,
}
try:
p = board_metadata_path(slug)
if p.exists():
raw = json.loads(p.read_text(encoding="utf-8"))
if isinstance(raw, dict):
# Never let the metadata file claim a different slug than
# its directory — trust the filesystem.
raw["slug"] = slug
meta.update(raw)
except (OSError, json.JSONDecodeError):
pass
meta["db_path"] = str(kanban_db_path(slug))
return meta
def write_board_metadata(
board: Optional[str],
*,
name: Optional[str] = None,
description: Optional[str] = None,
icon: Optional[str] = None,
color: Optional[str] = None,
archived: Optional[bool] = None,
) -> dict:
"""Create / update ``board.json`` for ``board``.
Preserves any existing fields not mentioned in the call. Sets
``created_at`` on first write. Returns the resulting metadata dict.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
meta = read_board_metadata(slug)
# Preserve existing DB-derived fields — they get re-computed each
# read but shouldn't be written into board.json.
meta.pop("db_path", None)
if name is not None:
meta["name"] = str(name).strip() or _default_board_display_name(slug)
if description is not None:
meta["description"] = str(description)
if icon is not None:
meta["icon"] = str(icon)
if color is not None:
meta["color"] = str(color)
if archived is not None:
meta["archived"] = bool(archived)
if not meta.get("created_at"):
meta["created_at"] = int(time.time())
path = board_metadata_path(slug)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(meta, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
meta["db_path"] = str(kanban_db_path(slug))
return meta
def create_board(
slug: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
icon: Optional[str] = None,
color: Optional[str] = None,
) -> dict:
"""Create a new board directory + DB + metadata. Idempotent.
Returns the resulting metadata. Raises :class:`ValueError` for a
malformed slug; returns the existing metadata (not an error) if the
board already exists matching ``mkdir -p`` semantics.
"""
normed = _normalize_board_slug(slug)
if not normed:
raise ValueError("board slug is required")
meta = write_board_metadata(
normed,
name=name,
description=description,
icon=icon,
color=color,
)
# Touch the DB so list_boards() sees it immediately.
init_db(board=normed)
return meta
def list_boards(*, include_archived: bool = True) -> list[dict]:
"""Enumerate all boards that exist on disk.
Always includes ``default`` (even when the ``boards/default/``
metadata dir doesn't exist, because its DB is at the legacy path).
Other boards are discovered by scanning ``boards/`` for subdirectories
that either contain a ``kanban.db`` or a ``board.json``.
Returns a list of metadata dicts, sorted with ``default`` first and
the rest alphabetically.
"""
entries: list[dict] = []
seen: set[str] = set()
# Default board is always first.
entries.append(read_board_metadata(DEFAULT_BOARD))
seen.add(DEFAULT_BOARD)
root = boards_root()
if root.is_dir():
for child in sorted(root.iterdir(), key=lambda p: p.name.lower()):
if not child.is_dir():
continue
slug = child.name
# Keep slug normalisation soft for discovery — but skip dirs
# that don't parse as valid slugs so we don't surface junk.
try:
normed = _normalize_board_slug(slug)
except ValueError:
continue
if not normed or normed in seen:
continue
has_db = (child / "kanban.db").exists()
has_meta = (child / "board.json").exists()
if not (has_db or has_meta):
continue
meta = read_board_metadata(normed)
if meta.get("archived") and not include_archived:
continue
entries.append(meta)
seen.add(normed)
return entries
def remove_board(slug: str, *, archive: bool = True) -> dict:
"""Remove or archive a board.
``archive=True`` (default) moves the board's directory to
``<root>/kanban/boards/_archived/<slug>-<timestamp>/`` so the data
is recoverable. ``archive=False`` deletes the directory outright.
The ``default`` board cannot be removed raises :class:`ValueError`.
Returns a summary dict describing what happened (``{"slug", "action",
"new_path"}``).
"""
normed = _normalize_board_slug(slug)
if not normed:
raise ValueError("board slug is required")
if normed == DEFAULT_BOARD:
raise ValueError("the 'default' board cannot be removed")
d = board_dir(normed)
if not d.exists():
raise ValueError(f"board {normed!r} does not exist")
# If the user removed the currently-active board, revert to default.
if get_current_board() == normed:
clear_current_board()
if archive:
archive_root = boards_root() / "_archived"
archive_root.mkdir(parents=True, exist_ok=True)
ts = int(time.time())
target = archive_root / f"{normed}-{ts}"
# Avoid collision on rapid double-archives.
suffix = 1
while target.exists():
target = archive_root / f"{normed}-{ts}-{suffix}"
suffix += 1
d.rename(target)
return {"slug": normed, "action": "archived", "new_path": str(target)}
else:
import shutil
shutil.rmtree(d)
return {"slug": normed, "action": "deleted", "new_path": ""}
# ---------------------------------------------------------------------------
@@ -368,7 +842,11 @@ CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_
_INITIALIZED_PATHS: set[str] = set()
def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
def connect(
db_path: Optional[Path] = None,
*,
board: Optional[str] = None,
) -> sqlite3.Connection:
"""Open (and initialize if needed) the kanban DB.
WAL mode is enabled on every connection; it's a no-op after the first
@@ -378,8 +856,19 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
fresh installs and test harnesses that construct `connect()`
directly don't have to remember a separate init step. Subsequent
connections skip the schema check via a module-level path cache.
Path resolution:
* ``db_path`` explicit used as-is (legacy callers, tests).
* ``board`` explicit resolves to that board's DB.
* Neither :func:`kanban_db_path` resolves via
``HERMES_KANBAN_DB`` env ``HERMES_KANBAN_BOARD`` env
``<root>/kanban/current`` ``default``.
"""
path = db_path or kanban_db_path()
if db_path is not None:
path = db_path
else:
path = kanban_db_path(board=board)
path.parent.mkdir(parents=True, exist_ok=True)
resolved = str(path.resolve())
needs_init = resolved not in _INITIALIZED_PATHS
@@ -398,7 +887,11 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
return conn
def init_db(db_path: Optional[Path] = None) -> Path:
def init_db(
db_path: Optional[Path] = None,
*,
board: Optional[str] = None,
) -> Path:
"""Create the schema if it doesn't exist; return the path used.
Kept as a public entry point so CLI ``hermes kanban init`` and the
@@ -409,7 +902,10 @@ def init_db(db_path: Optional[Path] = None) -> Path:
external tools that upgrade an old DB file can call this to
force re-migration.
"""
path = db_path or kanban_db_path()
if db_path is not None:
path = db_path
else:
path = kanban_db_path(board=board)
path.parent.mkdir(parents=True, exist_ok=True)
resolved = str(path.resolve())
# Clear the cache entry so the underlying connect() re-runs the
@@ -590,6 +1086,15 @@ def _claimer_id() -> str:
# Task creation / mutation
# ---------------------------------------------------------------------------
def _canonical_assignee(assignee: Optional[str]) -> Optional[str]:
"""Lowercase-assignee normalization for Kanban rows (dashboard/CLI parity)."""
if assignee is None:
return None
from hermes_cli.profiles import normalize_profile_name
return normalize_profile_name(assignee)
def create_task(
conn: sqlite3.Connection,
*,
@@ -631,6 +1136,7 @@ def create_task(
(e.g. ``skills=["translation"]`` so the worker loads the
translation skill regardless of the profile's default config).
"""
assignee = _canonical_assignee(assignee)
if not title or not title.strip():
raise ValueError("title is required")
if workspace_kind not in VALID_WORKSPACE_KINDS:
@@ -795,7 +1301,7 @@ def list_tasks(
params: list[Any] = []
if assignee is not None:
query += " AND assignee = ?"
params.append(assignee)
params.append(_canonical_assignee(assignee))
if status is not None:
if status not in VALID_STATUSES:
raise ValueError(f"status must be one of {sorted(VALID_STATUSES)}")
@@ -819,6 +1325,7 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str])
Refuses to reassign a task that's currently running (claim_lock set).
Reassign after the current run completes if needed.
"""
profile = _canonical_assignee(profile)
with write_txn(conn):
row = conn.execute(
"SELECT status, claim_lock FROM tasks WHERE id = ?", (task_id,)
@@ -1513,15 +2020,18 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool:
# Workspace resolution
# ---------------------------------------------------------------------------
def resolve_workspace(task: Task) -> Path:
def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
"""Resolve (and create if needed) the workspace for a task.
- ``scratch``: a fresh dir under ``$HERMES_HOME/kanban/workspaces/<id>/``.
- ``scratch``: a fresh dir under ``<board-root>/workspaces/<id>/``,
where ``<board-root>`` is the active board's root. The path is the
same for the dispatcher and every profile worker, so handoff is
path-stable.
- ``dir:<path>``: the path stored in ``workspace_path``. Created
if missing. MUST be absolute relative paths are rejected to
prevent confused-deputy traversal where ``../../../tmp/attacker``
resolves against the dispatcher's CWD instead of a meaningful
root. Users who want a HERMES_HOME-relative workspace should
root. Users who want a kanban-root-relative workspace should
compute the absolute path themselves.
- ``worktree``: a git worktree at ``workspace_path``. Not created
automatically in v1 -- the kanban-worker skill documents
@@ -1543,7 +2053,7 @@ def resolve_workspace(task: Task) -> Path:
f"{task.workspace_path!r}; workspace paths must be absolute"
)
else:
p = workspaces_root() / task.id
p = workspaces_root(board=board) / task.id
p.mkdir(parents=True, exist_ok=True)
return p
if kind == "dir":
@@ -1957,6 +2467,7 @@ def dispatch_once(
dry_run: bool = False,
max_spawn: Optional[int] = None,
failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT,
board: Optional[str] = None,
) -> DispatchResult:
"""Run one dispatcher tick.
@@ -1965,15 +2476,17 @@ def dispatch_once(
2. Reclaim crashed running tasks (host-local PID no longer alive).
3. Promote todo -> ready where all parents are done.
4. For each ready task with an assignee, atomically claim and call
``spawn_fn(task, workspace_path) -> Optional[int]``. The return
value (if any) is recorded as ``worker_pid`` so subsequent ticks
can detect crashes before the TTL expires.
``spawn_fn(task, workspace_path, board) -> Optional[int]``. The
return value (if any) is recorded as ``worker_pid`` so subsequent
ticks can detect crashes before the TTL expires.
Spawn failures are counted per-task. After ``failure_limit`` consecutive
failures the task is auto-blocked with the last error as its reason
prevents the dispatcher from thrashing forever on an unfixable task.
``spawn_fn`` defaults to ``_default_spawn``. Tests pass a stub.
``board`` pins workspace/log/db resolution for this tick to a specific
board. When omitted, the current-board resolution chain is used.
"""
result = DispatchResult()
result.reclaimed = release_stale_claims(conn)
@@ -2000,7 +2513,7 @@ def dispatch_once(
if claimed is None:
continue
try:
workspace = resolve_workspace(claimed)
workspace = resolve_workspace(claimed, board=board)
except Exception as exc:
auto = _record_spawn_failure(
conn, claimed.id, f"workspace: {exc}",
@@ -2013,7 +2526,18 @@ def dispatch_once(
set_workspace_path(conn, claimed.id, str(workspace))
_spawn = spawn_fn if spawn_fn is not None else _default_spawn
try:
pid = _spawn(claimed, str(workspace))
# Back-compat: older spawn_fn signatures accept only
# (task, workspace). Test stubs in the suite rely on that.
# Introspect the callable and pass `board` only when supported.
import inspect
try:
sig = inspect.signature(_spawn)
if "board" in sig.parameters:
pid = _spawn(claimed, str(workspace), board=board)
else:
pid = _spawn(claimed, str(workspace))
except (TypeError, ValueError):
pid = _spawn(claimed, str(workspace))
if pid:
_set_worker_pid(conn, claimed.id, int(pid))
_clear_spawn_failures(conn, claimed.id)
@@ -2052,33 +2576,60 @@ def _rotate_worker_log(log_path: Path, max_bytes: int) -> None:
pass
def _default_spawn(task: Task, workspace: str) -> Optional[int]:
def _default_spawn(
task: Task,
workspace: str,
*,
board: Optional[str] = None,
) -> Optional[int]:
"""Fire-and-forget ``hermes -p <profile> chat -q ...`` subprocess.
Returns the spawned child's PID so the dispatcher can detect crashes
before the claim TTL expires. The child's completion is still observed
via the ``complete`` / ``block`` transitions the worker writes itself;
the PID check is a safety net for crashes, OOM kills, and Ctrl+C.
``board`` pins the child's kanban context to that board: the child's
``HERMES_KANBAN_DB`` / ``HERMES_KANBAN_BOARD`` / workspaces_root env
vars all resolve to the same board the dispatcher claimed the task
from. Workers cannot accidentally see other boards.
"""
import subprocess
if not task.assignee:
raise ValueError(f"task {task.id} has no assignee")
from hermes_cli.profiles import normalize_profile_name
profile_arg = normalize_profile_name(task.assignee)
prompt = f"work kanban task {task.id}"
env = dict(os.environ)
if task.tenant:
env["HERMES_TENANT"] = task.tenant
env["HERMES_KANBAN_TASK"] = task.id
env["HERMES_KANBAN_WORKSPACE"] = workspace
# Pin the shared board + workspaces root the dispatcher resolved, so
# that even when the worker activates a profile (`hermes -p <name>`
# rewrites HERMES_HOME), its kanban paths still match the
# dispatcher's. Belt-and-braces with the `get_default_hermes_root()`
# resolution in `kanban_home()` — symmetric resolution is the norm,
# but unusual symlink / Docker layouts are caught here too.
env["HERMES_KANBAN_DB"] = str(kanban_db_path(board=board))
env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(workspaces_root(board=board))
# Board slug — the final defense-in-depth pin. If the worker ever
# resolves kanban paths without the DB / workspaces env vars, the
# board slug still forces it to the right directory.
resolved_board = _normalize_board_slug(board) or get_current_board()
env["HERMES_KANBAN_BOARD"] = resolved_board
# HERMES_PROFILE is the author the kanban_comment tool defaults to.
# `hermes -p <assignee>` activates the profile, but the env var is
# what the tool reads — set it explicitly here so comments are
# attributed correctly regardless of how the child loads config.
env["HERMES_PROFILE"] = task.assignee
env["HERMES_PROFILE"] = profile_arg
cmd = [
"hermes",
"-p", task.assignee,
"-p", profile_arg,
# Auto-load the kanban-worker skill so every dispatched worker
# has the pattern library (good summary/metadata shapes, retry
# diagnostics, block-reason examples) in its context, even if
@@ -2104,9 +2655,11 @@ def _default_spawn(task: Task, workspace: str) -> Optional[int]:
"chat",
"-q", prompt,
])
# Redirect output to a per-task log under HERMES_HOME/kanban/logs/.
from hermes_constants import get_hermes_home
log_dir = get_hermes_home() / "kanban" / "logs"
# Redirect output to a per-task log under <board-root>/logs/.
# Anchored at the board root (not the shared kanban root), so
# `hermes kanban log` on a specific board reads its own file and
# logs don't collide across boards that happen to share task ids.
log_dir = worker_logs_dir(board=board)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"{task.id}.log"
_rotate_worker_log(log_path, DEFAULT_LOG_ROTATE_BYTES)
@@ -2587,12 +3140,14 @@ def gc_events(
def gc_worker_logs(
*, older_than_seconds: int = 30 * 24 * 3600,
board: Optional[str] = None,
) -> int:
"""Delete worker log files older than ``older_than_seconds``. Returns
the number of files removed. Kept separate from ``gc_events`` because
log files live on disk, not in SQLite."""
from hermes_constants import get_hermes_home
log_dir = get_hermes_home() / "kanban" / "logs"
log files live on disk, not in SQLite. Scoped to ``board`` (defaults
to the active board) per-board isolation means deleting logs from
board A cannot touch board B's logs."""
log_dir = worker_logs_dir(board=board)
if not log_dir.exists():
return 0
cutoff = time.time() - older_than_seconds
@@ -2611,20 +3166,25 @@ def gc_worker_logs(
# Worker log accessor
# ---------------------------------------------------------------------------
def worker_log_path(task_id: str) -> Path:
def worker_log_path(task_id: str, *, board: Optional[str] = None) -> Path:
"""Return the path to a worker's log file. The file may not exist
(task never spawned, or log already GC'd)."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "kanban" / "logs" / f"{task_id}.log"
(task never spawned, or log already GC'd).
When ``board`` is None, resolves via the active board (env var
current-board file default). The dispatcher always passes the
board explicitly to avoid any resolution ambiguity when multiple
boards exist."""
return worker_logs_dir(board=board) / f"{task_id}.log"
def read_worker_log(
task_id: str, *, tail_bytes: Optional[int] = None,
board: Optional[str] = None,
) -> Optional[str]:
"""Read the worker log for ``task_id``. Returns None if the file
doesn't exist. If ``tail_bytes`` is set, only the last N bytes are
returned (useful for the dashboard drawer which shouldn't page megabytes)."""
path = worker_log_path(task_id)
path = worker_log_path(task_id, board=board)
if not path.exists():
return None
try:
@@ -2661,7 +3221,8 @@ def list_profiles_on_disk() -> list[str]:
``config.yaml`` a bare dir without config isn't a real profile.
"""
try:
home = Path.home() / ".hermes" / "profiles"
from hermes_constants import get_default_hermes_root
home = get_default_hermes_root() / "profiles"
except Exception:
return []
if not home.is_dir():
+64 -13
View File
@@ -114,6 +114,16 @@ def _apply_profile_override() -> None:
consume = 1
break
# 1b. Reject values that can't be valid profile names (e.g. pytest's
# "-p no:xdist" would be misread as profile "no:xdist" otherwise).
# Mirrors hermes_cli.profiles._PROFILE_ID_RE so we never call
# resolve_profile_env() with a value it must reject + sys.exit on.
if profile_name is not None and consume == 2:
import re as _re
if not _re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", profile_name):
profile_name = None
consume = 0
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it.
# This lets child processes (relaunch, subprocess) inherit the parent's
# profile choice without having to pass --profile again.
@@ -837,7 +847,17 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti
)
_NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert"})
_NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert", "peer"})
"""Lockfile fields npm writes non-deterministically at install time.
``ideallyInert`` is npm's runtime annotation for packages it skipped installing
(per-platform opt-outs). ``peer`` is dropped from the hidden ``.package-lock.json``
on dev-dependencies that are *also* declared as peers the canonical
``package-lock.json`` records the dual role, but npm 9's actualized tree strips
it. Neither key represents a real skew between what was declared and what was
installed, so we exclude them from the comparison in :func:`_tui_need_npm_install`
to avoid false-positive reinstalls on every launch.
"""
def _tui_need_npm_install(root: Path) -> bool:
@@ -1042,17 +1062,21 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
if _tui_need_npm_install(tui_dir):
if not os.environ.get("HERMES_QUIET"):
print("Installing TUI dependencies…")
# Capture stdout as well as stderr — some npm errors (notably EACCES on a
# root-owned node_modules in containers) are emitted on stdout, and a
# bare "npm install failed." with no preview defeats debugging. We keep
# the failure-only print path so a successful install stays silent.
result = subprocess.run(
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
cwd=str(tui_dir),
stdout=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={**os.environ, "CI": "1"},
)
if result.returncode != 0:
err = (result.stderr or "").strip()
preview = "\n".join(err.splitlines()[-30:])
combined = f"{result.stdout or ''}\n{result.stderr or ''}".strip()
preview = "\n".join(combined.splitlines()[-30:])
print("npm install failed.")
if preview:
print(preview)
@@ -3399,10 +3423,10 @@ def _model_flow_named_custom(config, provider_info):
print()
print("Fetching available models...")
models = fetch_api_models(
api_key, base_url, timeout=8.0,
api_mode=api_mode or None,
)
fetch_kwargs = {"timeout": 8.0}
if api_mode:
fetch_kwargs["api_mode"] = api_mode
models = fetch_api_models(api_key, base_url, **fetch_kwargs)
if models:
default_idx = 0
@@ -6477,13 +6501,29 @@ def _cmd_update_check():
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
print("→ Fetching from origin...")
# Fetch both origin and upstream; prefer upstream as the canonical reference
print("→ Fetching from upstream...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin"],
git_cmd + ["fetch", "upstream"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
)
if fetch_result.returncode != 0:
# Fallback to origin if upstream doesn't exist
print("→ Fetching from origin...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
)
upstream_exists = False
compare_branch = "origin/main"
else:
upstream_exists = True
compare_branch = "upstream/main"
if fetch_result.returncode != 0:
stderr = fetch_result.stderr.strip()
if "Could not resolve host" in stderr or "unable to access" in stderr:
@@ -6491,13 +6531,13 @@ def _cmd_update_check():
elif "Authentication failed" in stderr or "could not read Username" in stderr:
print("✗ Authentication failed — check your git credentials or SSH key.")
else:
print("✗ Failed to fetch from origin.")
print("✗ Failed to fetch.")
if stderr:
print(f" {stderr.splitlines()[0]}")
sys.exit(1)
rev_result = subprocess.run(
git_cmd + ["rev-list", "HEAD..origin/main", "--count"],
git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@@ -6509,7 +6549,7 @@ def _cmd_update_check():
print("✓ Already up to date.")
else:
commits_word = "commit" if behind == 1 else "commits"
print(f"⚕ Update available: {behind} {commits_word} behind origin/main.")
print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.")
from hermes_cli.config import recommended_update_command
print(f" Run '{recommended_update_command()}' to install.")
@@ -8897,6 +8937,7 @@ Examples:
hermes debug share --lines 500 Include more log lines
hermes debug share --expire 30 Keep paste for 30 days
hermes debug share --local Print report locally (no upload)
hermes debug share --no-redact Disable upload-time secret redaction
hermes debug delete <url> Delete a previously uploaded paste
""",
)
@@ -8922,6 +8963,16 @@ Examples:
action="store_true",
help="Print the report locally instead of uploading",
)
share_parser.add_argument(
"--no-redact",
action="store_true",
help=(
"Disable upload-time secret redaction (default: redact). Logs "
"are normally run through agent.redact.redact_sensitive_text "
"with force=True before upload so credentials are not leaked "
"into the public paste service."
),
)
delete_parser = debug_sub.add_parser(
"delete",
help="Delete a paste uploaded by 'hermes debug share'",
+22 -10
View File
@@ -904,6 +904,26 @@ def switch_model(
if any(m.get("name") == new_model for m in cfg_models if isinstance(m, dict)):
override = True
break
# Also check custom_providers list — models declared there should be accepted
# even if the remote /v1/models endpoint doesn't list them.
if not override and custom_providers and isinstance(custom_providers, list):
for entry in custom_providers:
if not isinstance(entry, dict):
continue
# Match by provider slug (custom:<name>) or by base_url
entry_name = entry.get("name", "")
entry_slug = f"custom:{entry_name}" if entry_name else ""
entry_url = entry.get("base_url", "")
if entry_slug == target_provider or entry_url == base_url:
# Check if the requested model matches the entry's model
entry_model = entry.get("model", "")
entry_models = entry.get("models", {})
if new_model == entry_model:
override = True
break
if isinstance(entry_models, dict) and new_model in entry_models:
override = True
break
if override:
validation = {"accepted": True, "persist": True, "recognized": False, "message": validation.get("message", "")}
else:
@@ -1244,11 +1264,7 @@ def list_authenticated_providers(
from hermes_cli.auth import _load_auth_store
store = _load_auth_store()
providers_store = store.get("providers", {})
pool_store = store.get("credential_pool", {})
if store and (
pid in providers_store or hermes_slug in providers_store
or pid in pool_store or hermes_slug in pool_store
):
if store and (pid in providers_store or hermes_slug in providers_store):
has_creds = True
except Exception as exc:
logger.debug("Auth store check failed for %s: %s", pid, exc)
@@ -1344,11 +1360,7 @@ def list_authenticated_providers(
from hermes_cli.auth import _load_auth_store
_cp_store = _load_auth_store()
_cp_providers_store = _cp_store.get("providers", {})
_cp_pool_store = _cp_store.get("credential_pool", {})
if _cp_store and (
_cp.slug in _cp_providers_store
or _cp.slug in _cp_pool_store
):
if _cp_store and _cp.slug in _cp_providers_store:
_cp_has_creds = True
except Exception:
pass
+17 -6
View File
@@ -1740,10 +1740,20 @@ def model_supports_fast_mode(model_id: Optional[str]) -> bool:
def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
"""Return True if the model is a Claude model eligible for Anthropic Fast Mode."""
"""Return True if the model is a Claude model eligible for Anthropic Fast Mode.
Fast mode is currently supported on Claude Opus 4.6 only. Per Anthropic's
docs (https://platform.claude.com/docs/en/build-with-claude/fast-mode):
"Fast mode is currently supported on Opus 4.6 only. Sending speed: fast
with an unsupported model returns an error." Opus 4.7 explicitly rejects
the ``speed`` parameter with HTTP 400.
"""
raw = _strip_vendor_prefix(str(model_id or ""))
base = raw.split(":")[0]
return base.startswith("claude-")
if not base.startswith("claude-"):
return False
# Only Opus 4.6 supports fast mode at present.
return "opus-4-6" in base or "opus-4.6" in base
def resolve_fast_mode_overrides(model_id: Optional[str]) -> dict[str, Any] | None:
@@ -3087,7 +3097,7 @@ def validate_requested_model(
"message": f"Model `{requested}` was not found in LM Studio's model listing.",
}
if normalized == "custom":
if normalized == "custom" or normalized.startswith("custom:"):
# Try probing with correct auth for the api_mode.
if api_mode == "anthropic_messages":
probe = probe_api_models(api_key, base_url, api_mode=api_mode)
@@ -3185,11 +3195,12 @@ def validate_requested_model(
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
return {
"accepted": False,
"persist": False,
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Model `{requested}` was not found in the OpenAI Codex model listing."
f"Note: `{requested}` was not found in the OpenAI Codex model listing. "
"It may still work if your ChatGPT/Codex account has access to a newer or hidden model ID."
f"{suggestion_text}"
),
}
+111 -73
View File
@@ -179,8 +179,33 @@ def _get_wrapper_dir() -> Path:
# Validation
# ---------------------------------------------------------------------------
def normalize_profile_name(name: str) -> str:
"""Return the canonical profile id used on disk and in CLI ``-p`` argv.
Named profiles are stored lowercase under ``profiles/<id>/``. The special
alias ``default`` is matched case-insensitively (``Default`` ``default``).
Dashboards and tools may pass title-cased display labels; normalize before
validation, assignment, and subprocess spawn (see issue #18498).
"""
if not isinstance(name, str):
name = str(name)
stripped = name.strip()
if not stripped:
raise ValueError("profile name cannot be empty")
if stripped.casefold() == "default":
return "default"
return stripped.lower()
def validate_profile_name(name: str) -> None:
"""Raise ``ValueError`` if *name* is not a valid profile identifier."""
"""Raise ``ValueError`` if *name* is not a valid profile identifier.
Validates the input as-given strict lowercase match. Callers that accept
mixed-case or title-cased input from users (dashboard UI, CLI args) should
call :func:`normalize_profile_name` first. This separation keeps validate
honest about what the on-disk directory name must look like, while
ingress-point normalization handles UX flexibility (see #18498).
"""
if name == "default":
return # special alias for ~/.hermes
if not _PROFILE_ID_RE.match(name):
@@ -192,16 +217,18 @@ def validate_profile_name(name: str) -> None:
def get_profile_dir(name: str) -> Path:
"""Resolve a profile name to its HERMES_HOME directory."""
if name == "default":
canon = normalize_profile_name(name)
if canon == "default":
return _get_default_hermes_home()
return _get_profiles_root() / name
return _get_profiles_root() / canon
def profile_exists(name: str) -> bool:
"""Check whether a profile directory exists."""
if name == "default":
canon = normalize_profile_name(name)
if canon == "default":
return True
return get_profile_dir(name).is_dir()
return get_profile_dir(canon).is_dir()
# ---------------------------------------------------------------------------
@@ -213,28 +240,29 @@ def check_alias_collision(name: str) -> Optional[str]:
Checks: reserved names, hermes subcommands, existing binaries in PATH.
"""
if name in _RESERVED_NAMES:
return f"'{name}' is a reserved name"
if name in _HERMES_SUBCOMMANDS:
return f"'{name}' conflicts with a hermes subcommand"
canon = normalize_profile_name(name)
if canon in _RESERVED_NAMES:
return f"'{canon}' is a reserved name"
if canon in _HERMES_SUBCOMMANDS:
return f"'{canon}' conflicts with a hermes subcommand"
# Check existing commands in PATH
wrapper_dir = _get_wrapper_dir()
try:
result = subprocess.run(
["which", name], capture_output=True, text=True, timeout=5,
["which", canon], capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
existing_path = result.stdout.strip()
# Allow overwriting our own wrappers
if existing_path == str(wrapper_dir / name):
if existing_path == str(wrapper_dir / canon):
try:
content = (wrapper_dir / name).read_text()
content = (wrapper_dir / canon).read_text()
if "hermes -p" in content:
return None # it's our wrapper, safe to overwrite
except Exception:
pass
return f"'{name}' conflicts with an existing command ({existing_path})"
return f"'{canon}' conflicts with an existing command ({existing_path})"
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
@@ -252,6 +280,7 @@ def create_wrapper_script(name: str) -> Optional[Path]:
Returns the path to the created wrapper, or None if creation failed.
"""
canon = normalize_profile_name(name)
wrapper_dir = _get_wrapper_dir()
try:
wrapper_dir.mkdir(parents=True, exist_ok=True)
@@ -259,9 +288,9 @@ def create_wrapper_script(name: str) -> Optional[Path]:
print(f"⚠ Could not create {wrapper_dir}: {e}")
return None
wrapper_path = wrapper_dir / name
wrapper_path = wrapper_dir / canon
try:
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n')
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {canon} "$@"\n')
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return wrapper_path
except OSError as e:
@@ -271,7 +300,7 @@ def create_wrapper_script(name: str) -> Optional[Path]:
def remove_wrapper_script(name: str) -> bool:
"""Remove the wrapper script for a profile. Returns True if removed."""
wrapper_path = _get_wrapper_dir() / name
wrapper_path = _get_wrapper_dir() / normalize_profile_name(name)
if wrapper_path.exists():
try:
# Verify it's our wrapper before removing
@@ -421,16 +450,17 @@ def create_profile(
Path
The newly created profile directory.
"""
validate_profile_name(name)
canon = normalize_profile_name(name)
validate_profile_name(canon)
if name == "default":
if canon == "default":
raise ValueError(
"Cannot create a profile named 'default' — it is the built-in profile (~/.hermes)."
)
profile_dir = get_profile_dir(name)
profile_dir = get_profile_dir(canon)
if profile_dir.exists():
raise FileExistsError(f"Profile '{name}' already exists at {profile_dir}")
raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}")
# Resolve clone source
source_dir = None
@@ -440,6 +470,7 @@ def create_profile(
from hermes_constants import get_hermes_home
source_dir = get_hermes_home()
else:
clone_from = normalize_profile_name(clone_from)
validate_profile_name(clone_from)
source_dir = get_profile_dir(clone_from)
if not source_dir.is_dir():
@@ -540,24 +571,25 @@ def delete_profile(name: str, yes: bool = False) -> Path:
Returns the path that was removed.
"""
validate_profile_name(name)
canon = normalize_profile_name(name)
validate_profile_name(canon)
if name == "default":
if canon == "default":
raise ValueError(
"Cannot delete the default profile (~/.hermes).\n"
"To remove everything, use: hermes uninstall"
)
profile_dir = get_profile_dir(name)
profile_dir = get_profile_dir(canon)
if not profile_dir.is_dir():
raise FileNotFoundError(f"Profile '{name}' does not exist.")
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
# Show what will be deleted
model, provider = _read_config_model(profile_dir)
gw_running = _check_gateway_running(profile_dir)
skill_count = _count_skills(profile_dir)
print(f"\nProfile: {name}")
print(f"\nProfile: {canon}")
print(f"Path: {profile_dir}")
if model:
print(f"Model: {model}" + (f" ({provider})" if provider else ""))
@@ -569,7 +601,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
]
# Check for service
wrapper_path = _get_wrapper_dir() / name
wrapper_path = _get_wrapper_dir() / canon
has_wrapper = wrapper_path.exists()
if has_wrapper:
items.append(f"Command alias ({wrapper_path})")
@@ -584,16 +616,16 @@ def delete_profile(name: str, yes: bool = False) -> Path:
if not yes:
print()
try:
confirm = input(f"Type '{name}' to confirm: ").strip()
confirm = input(f"Type '{canon}' to confirm: ").strip()
except (KeyboardInterrupt, EOFError):
print("\nCancelled.")
return profile_dir
if confirm != name:
if confirm != canon:
print("Cancelled.")
return profile_dir
# 1. Disable service (prevents auto-restart)
_cleanup_gateway_service(name, profile_dir)
_cleanup_gateway_service(canon, profile_dir)
# 2. Stop running gateway
if gw_running:
@@ -601,7 +633,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
# 3. Remove wrapper script
if has_wrapper:
if remove_wrapper_script(name):
if remove_wrapper_script(canon):
print(f"✓ Removed {wrapper_path}")
# 4. Remove profile directory
@@ -614,13 +646,13 @@ def delete_profile(name: str, yes: bool = False) -> Path:
# 5. Clear active_profile if it pointed to this profile
try:
active = get_active_profile()
if active == name:
if active == canon:
set_active_profile("default")
print("✓ Active profile reset to default")
except Exception:
pass
print(f"\nProfile '{name}' deleted.")
print(f"\nProfile '{canon}' deleted.")
return profile_dir
@@ -730,22 +762,23 @@ def set_active_profile(name: str) -> None:
Writes to ``~/.hermes/active_profile``. Use ``"default"`` to clear.
"""
validate_profile_name(name)
if name != "default" and not profile_exists(name):
canon = normalize_profile_name(name)
validate_profile_name(canon)
if canon != "default" and not profile_exists(canon):
raise FileNotFoundError(
f"Profile '{name}' does not exist. "
f"Create it with: hermes profile create {name}"
f"Profile '{canon}' does not exist. "
f"Create it with: hermes profile create {canon}"
)
path = _get_active_profile_path()
path.parent.mkdir(parents=True, exist_ok=True)
if name == "default":
if canon == "default":
# Remove the file to indicate default
path.unlink(missing_ok=True)
else:
# Atomic write
tmp = path.with_suffix(".tmp")
tmp.write_text(name + "\n")
tmp.write_text(canon + "\n")
tmp.replace(path)
@@ -811,16 +844,17 @@ def export_profile(name: str, output_path: str) -> Path:
"""
import tempfile
validate_profile_name(name)
profile_dir = get_profile_dir(name)
canon = normalize_profile_name(name)
validate_profile_name(canon)
profile_dir = get_profile_dir(canon)
if not profile_dir.is_dir():
raise FileNotFoundError(f"Profile '{name}' does not exist.")
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
output = Path(output_path)
# shutil.make_archive wants the base name without extension
base = str(output).removesuffix(".tar.gz").removesuffix(".tgz")
if name == "default":
if canon == "default":
# The default profile IS ~/.hermes itself — its parent is ~/ and its
# directory name is ".hermes", not "default". We stage a clean copy
# under a temp dir so the archive contains ``default/...``.
@@ -836,14 +870,14 @@ def export_profile(name: str, output_path: str) -> Path:
# Named profiles — stage a filtered copy to exclude credentials
with tempfile.TemporaryDirectory() as tmpdir:
staged = Path(tmpdir) / name
staged = Path(tmpdir) / canon
_CREDENTIAL_FILES = {"auth.json", ".env"}
shutil.copytree(
profile_dir,
staged,
ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents),
)
result = shutil.make_archive(base, "gztar", tmpdir, name)
result = shutil.make_archive(base, "gztar", tmpdir, canon)
return Path(result)
@@ -952,16 +986,17 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
# Archives exported from the default profile have "default/" as top-level
# dir. Importing as "default" would target ~/.hermes itself — disallow
# that and guide the user toward a named profile.
if inferred_name == "default":
canon = normalize_profile_name(inferred_name)
validate_profile_name(canon)
if canon == "default":
raise ValueError(
"Cannot import as 'default' — that is the built-in root profile (~/.hermes). "
"Specify a different name: hermes profile import <archive> --name <name>"
)
validate_profile_name(inferred_name)
profile_dir = get_profile_dir(inferred_name)
profile_dir = get_profile_dir(canon)
if profile_dir.exists():
raise FileExistsError(f"Profile '{inferred_name}' already exists at {profile_dir}")
raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}")
profiles_root = _get_profiles_root()
profiles_root.mkdir(parents=True, exist_ok=True)
@@ -977,8 +1012,8 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
)
final_source = extracted
if archive_root != inferred_name:
final_source = staging_root / inferred_name
if archive_root != canon:
final_source = staging_root / canon
extracted.rename(final_source)
shutil.move(str(final_source), str(profile_dir))
@@ -1048,25 +1083,27 @@ def rename_profile(old_name: str, new_name: str) -> Path:
Returns the new profile directory.
"""
validate_profile_name(old_name)
validate_profile_name(new_name)
old_canon = normalize_profile_name(old_name)
new_canon = normalize_profile_name(new_name)
validate_profile_name(old_canon)
validate_profile_name(new_canon)
if old_name == "default":
if old_canon == "default":
raise ValueError("Cannot rename the default profile.")
if new_name == "default":
if new_canon == "default":
raise ValueError("Cannot rename to 'default' — it is reserved.")
old_dir = get_profile_dir(old_name)
new_dir = get_profile_dir(new_name)
old_dir = get_profile_dir(old_canon)
new_dir = get_profile_dir(new_canon)
if not old_dir.is_dir():
raise FileNotFoundError(f"Profile '{old_name}' does not exist.")
raise FileNotFoundError(f"Profile '{old_canon}' does not exist.")
if new_dir.exists():
raise FileExistsError(f"Profile '{new_name}' already exists.")
raise FileExistsError(f"Profile '{new_canon}' already exists.")
# 1. Stop gateway if running
if _check_gateway_running(old_dir):
_cleanup_gateway_service(old_name, old_dir)
_cleanup_gateway_service(old_canon, old_dir)
_stop_gateway_process(old_dir)
# 2. Rename directory
@@ -1074,22 +1111,22 @@ def rename_profile(old_name: str, new_name: str) -> Path:
print(f"✓ Renamed {old_dir.name}{new_dir.name}")
# 3. Update profile-scoped Honcho host blocks, preserving aiPeer identity
_migrate_honcho_profile_host(old_name, new_name, new_dir)
_migrate_honcho_profile_host(old_canon, new_canon, new_dir)
# 4. Update wrapper script
remove_wrapper_script(old_name)
collision = check_alias_collision(new_name)
remove_wrapper_script(old_canon)
collision = check_alias_collision(new_canon)
if not collision:
create_wrapper_script(new_name)
print(f"✓ Alias updated: {new_name}")
create_wrapper_script(new_canon)
print(f"✓ Alias updated: {new_canon}")
else:
print(f"⚠ Cannot create alias '{new_name}'{collision}")
print(f"⚠ Cannot create alias '{new_canon}'{collision}")
# 5. Update active_profile if it pointed to old name
try:
if get_active_profile() == old_name:
set_active_profile(new_name)
print(f"✓ Active profile updated: {new_name}")
if get_active_profile() == old_canon:
set_active_profile(new_canon)
print(f"✓ Active profile updated: {new_canon}")
except Exception:
pass
@@ -1191,13 +1228,14 @@ def resolve_profile_env(profile_name: str) -> str:
Called early in the CLI entry point, before any hermes modules
are imported, to set the HERMES_HOME environment variable.
"""
validate_profile_name(profile_name)
profile_dir = get_profile_dir(profile_name)
canon = normalize_profile_name(profile_name)
validate_profile_name(canon)
profile_dir = get_profile_dir(canon)
if profile_name != "default" and not profile_dir.is_dir():
if canon != "default" and not profile_dir.is_dir():
raise FileNotFoundError(
f"Profile '{profile_name}' does not exist. "
f"Create it with: hermes profile create {profile_name}"
f"Profile '{canon}' does not exist. "
f"Create it with: hermes profile create {canon}"
)
return str(profile_dir)
+8 -3
View File
@@ -108,9 +108,14 @@ class PtyBridge:
"(or pip install -e '.[pty]')."
)
raise PtyUnavailableError("Pseudo-terminals are unavailable.")
# Let caller-supplied env fully override inheritance; if they pass
# None we inherit the server's env (same semantics as subprocess).
spawn_env = os.environ.copy() if env is None else env
# PTY-hosted programs expect TERM to describe the terminal type.
# CI often runs without TERM in the parent process, which makes
# simple terminal probes like `tput cols` fail before winsize reads.
# Preserve explicit caller overrides, but backfill a sensible default
# when TERM is missing or blank.
spawn_env = (os.environ.copy() if env is None else env.copy())
if not spawn_env.get("TERM"):
spawn_env["TERM"] = "xterm-256color"
proc = ptyprocess.PtyProcess.spawn( # type: ignore[union-attr]
list(argv),
cwd=cwd,
+36 -8
View File
@@ -964,7 +964,8 @@ def setup_model_provider(config: dict, *, quick: bool = False):
)
else:
_selected_vision_model = prompt(" Vision model (blank = use main/custom default)").strip()
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
if _selected_vision_model:
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
print_success(
f"Vision configured with {_base_url}"
+ (f" ({_selected_vision_model})" if _selected_vision_model else "")
@@ -1328,15 +1329,13 @@ def setup_terminal_backend(config: dict):
print_success("Terminal backend: Local")
print_info("Commands run directly on this machine.")
# CWD for messaging
# Gateway/cron working directory
print()
print_info("Working directory for messaging sessions:")
print_info(" When using Hermes via Telegram/Discord, this is where")
print_info(
" the agent starts. CLI mode always starts in the current directory."
)
print_info("Gateway working directory:")
print_info(" Used by Telegram/Discord/cron sessions.")
print_info(" CLI/TUI always uses your launch directory instead.")
current_cwd = cfg_get(config, "terminal", "cwd", default="")
cwd = prompt(" Messaging working directory", current_cwd or str(Path.home()))
cwd = prompt(" Gateway working directory", current_cwd or str(Path.home()))
if cwd:
config["terminal"]["cwd"] = cwd
@@ -2049,6 +2048,16 @@ def _setup_slack():
print_warning("⚠️ No Slack allowlist set - unpaired users will be denied by default.")
print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.")
print()
print_info("📬 Home Channel: where Hermes delivers cron job results,")
print_info(" cross-platform messages, and notifications.")
print_info(" To get a channel ID: open the channel in Slack, then right-click")
print_info(" the channel name → Copy link — the ID starts with C (e.g. C01ABC2DE3F).")
print_info(" You can also set this later by typing /set-home in a Slack channel.")
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
if home_channel:
save_env_value("SLACK_HOME_CHANNEL", home_channel.strip())
def _write_slack_manifest_and_instruct():
"""Generate the Slack manifest, write it under HERMES_HOME, and print
@@ -2995,6 +3004,21 @@ def run_setup_wizard(args):
config = load_config()
hermes_home = get_hermes_home()
# Back up existing config before setup modifies it (#3522)
config_path = get_config_path()
if config_path.exists():
from datetime import datetime as _dt
_backup_path = config_path.with_suffix(
f".yaml.bak.{_dt.now().strftime('%Y%m%d_%H%M%S')}"
)
try:
import shutil
shutil.copy2(config_path, _backup_path)
except Exception:
_backup_path = None
else:
_backup_path = None
# Detect non-interactive environments (headless SSH, Docker, CI/CD)
non_interactive = getattr(args, 'non_interactive', False)
if not non_interactive and not is_interactive_stdin():
@@ -3164,6 +3188,10 @@ def run_setup_wizard(args):
# Save and show summary
save_config(config)
if _backup_path and _backup_path.exists():
print_info(f"Previous config backed up to: {_backup_path}")
print_info("If setup changed a value you customized, restore it with:")
print_info(f" cp {_backup_path} {config_path}")
_print_setup_summary(config, hermes_home)
_offer_launch_chat()
+25 -5
View File
@@ -122,11 +122,16 @@ def show_status(args):
print()
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
keys = {
# Values may be a single env var name (str) or a tuple of alternates (first found wins).
keys: dict[str, str | tuple[str, ...]] = {
"OpenRouter": "OPENROUTER_API_KEY",
"OpenAI": "OPENAI_API_KEY",
"NVIDIA": "NVIDIA_API_KEY",
"Z.AI/GLM": "GLM_API_KEY",
"Anthropic": ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN"),
"Google / Gemini": ("GOOGLE_API_KEY", "GEMINI_API_KEY"),
"DeepSeek": "DEEPSEEK_API_KEY",
"xAI / Grok": "XAI_API_KEY",
"NVIDIA NIM": "NVIDIA_API_KEY",
"Z.AI / GLM": "GLM_API_KEY",
"Kimi": "KIMI_API_KEY",
"StepFun Step Plan": "STEPFUN_API_KEY",
"MiniMax": "MINIMAX_API_KEY",
@@ -142,8 +147,23 @@ def show_status(args):
"GitHub": "GITHUB_TOKEN",
}
for name, env_var in keys.items():
value = get_env_value(env_var) or ""
def _resolve_env(env_ref) -> str:
"""Return first non-empty env var value from a str or tuple of names."""
if isinstance(env_ref, tuple):
for candidate in env_ref:
v = get_env_value(candidate) or ""
if v:
return v
return ""
return get_env_value(env_ref) or ""
for name, env_ref in keys.items():
# Anthropic already has a dedicated lookup below; keep that as the
# single source of truth (it also resolves OAuth tokens), skip here
# so we don't print two "Anthropic" rows.
if name == "Anthropic":
continue
value = _resolve_env(env_ref)
has_key = bool(value)
display = redact_key(value) if not show_all else value
print(f" {name:<12} {check_mark(has_key)} {display}")
+12 -5
View File
@@ -56,6 +56,7 @@ CONFIGURABLE_TOOLSETS = [
("file", "📁 File Operations", "read, write, patch, search"),
("code_execution", "⚡ Code Execution", "execute_code"),
("vision", "👁️ Vision / Image Analysis", "vision_analyze"),
("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"),
("image_gen", "🎨 Image Generation", "image_generate"),
("moa", "🧠 Mixture of Agents", "mixture_of_agents"),
("tts", "🔊 Text-to-Speech", "text_to_speech"),
@@ -78,7 +79,7 @@ CONFIGURABLE_TOOLSETS = [
# Toolsets that are OFF by default for new installs.
# They're still in _HERMES_CORE_TOOLS (available at runtime if enabled),
# but the setup checklist won't pre-select them for first-time users.
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord", "discord_admin"}
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord", "discord_admin", "video"}
# Platform-scoped toolsets: only appear in the `hermes tools` checklist for
# these platforms, and only resolve/save for these platforms. A toolset
@@ -1919,21 +1920,27 @@ def _reconfigure_provider(provider: dict, config: dict):
return
if provider.get("tts_provider"):
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
tts_cfg = config.setdefault("tts", {})
tts_cfg["provider"] = provider["tts_provider"]
tts_cfg["use_gateway"] = bool(managed_feature)
_print_success(f" TTS provider set to: {provider['tts_provider']}")
if "browser_provider" in provider:
bp = provider["browser_provider"]
browser_cfg = config.setdefault("browser", {})
if bp == "local":
config.setdefault("browser", {})["cloud_provider"] = "local"
browser_cfg["cloud_provider"] = "local"
_print_success(" Browser set to local mode")
elif bp:
config.setdefault("browser", {})["cloud_provider"] = bp
browser_cfg["cloud_provider"] = bp
_print_success(f" Browser cloud provider set to: {bp}")
browser_cfg["use_gateway"] = bool(managed_feature)
# Set web search backend in config if applicable
if provider.get("web_backend"):
config.setdefault("web", {})["backend"] = provider["web_backend"]
web_cfg = config.setdefault("web", {})
web_cfg["backend"] = provider["web_backend"]
web_cfg["use_gateway"] = bool(managed_feature)
_print_success(f" Web backend set to: {provider['web_backend']}")
if managed_feature and managed_feature not in ("web", "tts", "browser"):
+13
View File
@@ -510,10 +510,23 @@ except (ValueError, TypeError):
)
_GATEWAY_HEALTH_TIMEOUT = 3.0
# DEPRECATED (scheduled for removal): GATEWAY_HEALTH_URL / GATEWAY_HEALTH_TIMEOUT.
# Cross-container / cross-host gateway liveness detection will be folded into a
# first-class dashboard config key so it's no longer Docker-adjacent lore buried
# in env vars. The env vars still work for now so existing Compose deployments
# don't break. Do not add new callers — wire new uses through the planned
# config surface.
def _probe_gateway_health() -> tuple[bool, dict | None]:
"""Probe the gateway via its HTTP health endpoint (cross-container).
.. deprecated::
Driven by the deprecated ``GATEWAY_HEALTH_URL`` /
``GATEWAY_HEALTH_TIMEOUT`` env vars. Scheduled for removal alongside
a move to a first-class dashboard config key. See
:data:`_GATEWAY_HEALTH_URL` for context.
Uses ``/health/detailed`` first (returns full state), falling back to
the simpler ``/health`` endpoint. Returns ``(is_alive, body_dict)``.