Merge branch 'simp/r3-17-g6-misc' into simp/r3-17
This commit is contained in:
+300
-483
@@ -1,24 +1,19 @@
|
||||
"""hermes claw — OpenClaw migration commands."""
|
||||
|
||||
import importlib.util
|
||||
import itertools
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from types import SimpleNamespace
|
||||
from typing import Callable, Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config
|
||||
from hermes_constants import get_optional_skills_dir
|
||||
from hermes_cli.setup import (
|
||||
Colors,
|
||||
color,
|
||||
print_header,
|
||||
print_info,
|
||||
print_success,
|
||||
print_error,
|
||||
prompt_yes_no,
|
||||
)
|
||||
from hermes_cli.setup import (Colors, color, print_header, print_info, print_success, print_error,
|
||||
prompt_yes_no)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,146 +27,148 @@ _OPENCLAW_SCRIPT_INSTALLED = get_hermes_home() / "skills" / _SCRIPT_REL
|
||||
# Known OpenClaw directory names (current + legacy)
|
||||
_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot")
|
||||
|
||||
# `hermes claw migrate` flags/defaults. Secrets are never included implicitly: --migrate-secrets
|
||||
# is required even under --preset full (OpenClaw's two-phase posture); no silent API-key import.
|
||||
_MIGRATE_ARG_DEFAULTS = (
|
||||
("source", None), ("dry_run", False), ("preset", "full"), ("overwrite", False),
|
||||
("migrate_secrets", False), ("workspace_target", None), ("skill_conflict", "skip"),
|
||||
("no_backup", False), ("yes", False),
|
||||
)
|
||||
|
||||
# (status, heading, color, default reason) — printed in this order after migrated items.
|
||||
_REPORT_REASON_GROUPS = (
|
||||
("conflict", " ⚠ Conflicts (skipped — use --overwrite to force):", Colors.YELLOW,
|
||||
"already exists"),
|
||||
("skipped", " ─ Skipped:", Colors.DIM, ""),
|
||||
("error", " ✗ Errors:", Colors.RED, "unknown error"),
|
||||
)
|
||||
# Summary-line counters after the migrated count: (summary key, label).
|
||||
_SUMMARY_COUNT_LABELS = (("conflict", "conflict(s)"), ("skipped", "skipped"), ("error", "error(s)"))
|
||||
|
||||
# A subdir with any of these files is a workspace; the labelled subset is listed by cleanup.
|
||||
_WORKSPACE_MARKERS = ("todo.json", "SOUL.md", "MEMORY.md", "USER.md")
|
||||
_WORKSPACE_ITEM_LABELS = (
|
||||
("todo.json", "todo.json", Path.exists), ("sessions", "sessions/", Path.is_dir),
|
||||
("SOUL.md", "SOUL.md", Path.exists), ("MEMORY.md", "MEMORY.md", Path.exists),
|
||||
)
|
||||
|
||||
|
||||
def _print_banner(title: str) -> None:
|
||||
"""Print the magenta boxed banner shared by the claw subcommands."""
|
||||
print()
|
||||
for line in (
|
||||
"┌─────────────────────────────────────────────────────────┐",
|
||||
f"│ ⚕ Hermes — {title:<35s}│",
|
||||
"└─────────────────────────────────────────────────────────┘",
|
||||
):
|
||||
rule = "─" * 57
|
||||
for line in (f"┌{rule}┐", f"│ ⚕ Hermes — {title:<35s}│", f"└{rule}┘"):
|
||||
print(color(line, Colors.MAGENTA))
|
||||
|
||||
|
||||
def _warn_running(running: list[str], headline: str, *lines: str) -> None:
|
||||
"""Print the 'OpenClaw is running' warning block."""
|
||||
def _info(*lines: str) -> None:
|
||||
"""print_info each line; an empty string prints a bare blank line instead."""
|
||||
for line in lines:
|
||||
print_info(line) if line else print()
|
||||
|
||||
|
||||
def _error_block(headline: str, *lines: str, debug: Optional[str] = None) -> None:
|
||||
"""Print a blank line, an error headline, then info lines; ``debug`` logs the traceback."""
|
||||
print()
|
||||
print_error(headline)
|
||||
for detail in running:
|
||||
print_info(f" * {detail}")
|
||||
for line in lines:
|
||||
print_info(line)
|
||||
_info(*lines)
|
||||
if debug:
|
||||
logger.debug(debug, exc_info=True)
|
||||
|
||||
|
||||
def _confirm(auto_yes: bool, question: str, *, default: bool, declined: str,
|
||||
non_tty: Optional[tuple[str, ...]] = None) -> Optional[bool]:
|
||||
"""Ask to proceed unless --yes; print ``declined`` and return False on an explicit no. With
|
||||
``non_tty`` set, a non-interactive stdin prints those lines and returns None (not a refusal)."""
|
||||
if auto_yes:
|
||||
return True
|
||||
if non_tty is not None and not sys.stdin.isatty():
|
||||
_info(*non_tty)
|
||||
return None
|
||||
answer = prompt_yes_no(question, default=default)
|
||||
if not answer:
|
||||
print_info(declined)
|
||||
return answer
|
||||
|
||||
|
||||
def _warn_running(auto_yes: bool, headline: str, running: list[str], lines: tuple[str, ...],
|
||||
question: str, declined: str, non_tty: Optional[tuple[str, ...]] = None):
|
||||
"""Print the 'still running' block (headline, `* detail` per process, advice) then _confirm."""
|
||||
_error_block(headline, *(f" * {detail}" for detail in running), *lines)
|
||||
print()
|
||||
return _confirm(auto_yes, question, default=False, declined=declined, non_tty=non_tty)
|
||||
|
||||
|
||||
def _detect_openclaw_processes() -> list[str]:
|
||||
"""Detect running OpenClaw processes and services."""
|
||||
found: list[str] = []
|
||||
|
||||
def _posix_probe(cmd: list[str], timeout: int):
|
||||
try:
|
||||
return subprocess.run(
|
||||
cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
# -- systemd service (Linux) ------------------------------------------
|
||||
if sys.platform != "win32":
|
||||
result = _posix_probe(["systemctl", "--user", "is-active", "openclaw-gateway.service"], 5)
|
||||
if result is not None and result.stdout.strip() == "active":
|
||||
found.append("systemd service: openclaw-gateway.service")
|
||||
|
||||
# -- process scan ------------------------------------------------------
|
||||
if sys.platform == "win32":
|
||||
# bounded_probe_run: a plain subprocess.run(timeout=...) can hang
|
||||
# forever on Windows in post-timeout cleanup when a conhost.exe
|
||||
# descendant holds duplicated pipe handles (#87134) — and a hang is
|
||||
# not an exception, so the try/except here can't save the caller.
|
||||
# bounded_probe_run: plain subprocess.run(timeout=...) can hang forever on Windows when a
|
||||
# conhost.exe descendant holds duplicated pipe handles — a hang is not an exception.
|
||||
from hermes_cli._subprocess_compat import bounded_probe_run
|
||||
|
||||
try:
|
||||
for exe in ("openclaw.exe", "clawd.exe"):
|
||||
result = bounded_probe_run(
|
||||
["tasklist", "/FI", f"IMAGENAME eq {exe}"],
|
||||
timeout=5,
|
||||
)
|
||||
result = bounded_probe_run(["tasklist", "/FI", f"IMAGENAME eq {exe}"], timeout=5)
|
||||
if result is not None and exe in (result.stdout or "").lower():
|
||||
found.append(f"process: {exe}")
|
||||
|
||||
# Node.js-hosted OpenClaw — tasklist doesn't show command lines,
|
||||
# so fall back to PowerShell.
|
||||
# Node.js-hosted OpenClaw — tasklist doesn't show command lines, so use PowerShell.
|
||||
ps_cmd = (
|
||||
'Get-CimInstance Win32_Process -Filter "Name = \'node.exe\'" | '
|
||||
'Where-Object { $_.CommandLine -match "openclaw|clawd" } | '
|
||||
'Select-Object -First 1 ProcessId'
|
||||
)
|
||||
result = bounded_probe_run(
|
||||
["powershell", "-NoProfile", "-Command", ps_cmd],
|
||||
timeout=5,
|
||||
)
|
||||
if result is not None and (result.stdout or "").strip():
|
||||
found.append(f"node.exe process with openclaw in command line (PID {result.stdout.strip()})")
|
||||
'Select-Object -First 1 ProcessId')
|
||||
result = bounded_probe_run(["powershell", "-NoProfile", "-Command", ps_cmd], timeout=5)
|
||||
pid = (result.stdout or "").strip() if result is not None else ""
|
||||
if pid:
|
||||
found.append(f"node.exe process with openclaw in command line (PID {pid})")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
result = _posix_probe(["pgrep", "-f", "openclaw"], 3)
|
||||
if result is not None and result.returncode == 0:
|
||||
found.append(f"openclaw process(es) (PIDs: {', '.join(result.stdout.strip().split())})")
|
||||
|
||||
return found
|
||||
result = _posix_probe(["systemctl", "--user", "is-active", "openclaw-gateway.service"], 5)
|
||||
if result is not None and result.stdout.strip() == "active":
|
||||
found.append("systemd service: openclaw-gateway.service")
|
||||
result = _posix_probe(["pgrep", "-f", "openclaw"], 3)
|
||||
if result is not None and result.returncode == 0:
|
||||
found.append(f"openclaw process(es) (PIDs: {', '.join(result.stdout.strip().split())})")
|
||||
return found
|
||||
|
||||
|
||||
def _posix_probe(cmd: list[str], timeout: int):
|
||||
try:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8',
|
||||
errors='replace', timeout=timeout)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
def _warn_if_openclaw_running(auto_yes: bool) -> None:
|
||||
"""Warn if OpenClaw is still running before migration.
|
||||
|
||||
Telegram, Discord, and Slack only allow one active connection per bot token. Migrating while
|
||||
OpenClaw is running causes both to fight for the same token.
|
||||
"""
|
||||
"""Warn if OpenClaw is still running: Telegram/Discord/Slack allow one session per bot token."""
|
||||
running = _detect_openclaw_processes()
|
||||
if not running:
|
||||
return
|
||||
|
||||
_warn_running(
|
||||
running,
|
||||
"OpenClaw appears to be running:",
|
||||
"Messaging platforms (Telegram, Discord, Slack) only allow one "
|
||||
"active session per bot token. If you continue, both OpenClaw and "
|
||||
"Hermes may try to use the same token, causing disconnects.",
|
||||
"Recommendation: stop OpenClaw before migrating.",
|
||||
)
|
||||
if auto_yes:
|
||||
return
|
||||
if not sys.stdin.isatty():
|
||||
print_info("Non-interactive session — continuing to preview only.")
|
||||
return
|
||||
if not prompt_yes_no("Continue anyway?", default=False):
|
||||
print_info("Migration cancelled. Stop OpenClaw and try again.")
|
||||
if running and _warn_running(
|
||||
auto_yes, "OpenClaw appears to be running:", running,
|
||||
("Messaging platforms (Telegram, Discord, Slack) only allow one "
|
||||
"active session per bot token. If you continue, both OpenClaw and "
|
||||
"Hermes may try to use the same token, causing disconnects.",
|
||||
"Recommendation: stop OpenClaw before migrating."),
|
||||
"Continue anyway?", declined="Migration cancelled. Stop OpenClaw and try again.",
|
||||
non_tty=("Non-interactive session — continuing to preview only.",),
|
||||
) is False:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def _warn_if_gateway_running(auto_yes: bool) -> None:
|
||||
"""Check if a Hermes gateway is running with connected platforms.
|
||||
|
||||
Migrating bot tokens while the gateway is polling causes conflicts (e.g. Telegram 409
|
||||
"terminated by other getUpdates request"); warn and let the user decide.
|
||||
"""
|
||||
"""Warn if a Hermes gateway has connected platforms (token conflicts, e.g. Telegram 409)."""
|
||||
from gateway.status import get_running_pid, read_runtime_status
|
||||
|
||||
if not get_running_pid():
|
||||
return
|
||||
|
||||
data = read_runtime_status() or {}
|
||||
platforms = data.get("platforms") or {}
|
||||
platforms = ((read_runtime_status() or {}).get("platforms") or {}) if get_running_pid() else {}
|
||||
connected = [name for name, info in platforms.items()
|
||||
if isinstance(info, dict) and info.get("state") == "connected"]
|
||||
if not connected:
|
||||
return
|
||||
|
||||
print()
|
||||
print_error(
|
||||
"Hermes gateway is running with active connections: "
|
||||
+ ", ".join(connected)
|
||||
)
|
||||
print_info(
|
||||
"Migrating bot tokens while the gateway is active will cause "
|
||||
"conflicts (Telegram, Discord, and Slack only allow one active "
|
||||
"session per token)."
|
||||
)
|
||||
print_info("Recommendation: stop the gateway first with 'hermes gateway stop'.")
|
||||
print()
|
||||
if not auto_yes and not prompt_yes_no("Continue anyway?", default=False):
|
||||
print_info("Migration cancelled. Stop the gateway and try again.")
|
||||
if connected and _warn_running(
|
||||
auto_yes, "Hermes gateway is running with active connections: " + ", ".join(connected), [],
|
||||
("Migrating bot tokens while the gateway is active will cause "
|
||||
"conflicts (Telegram, Discord, and Slack only allow one active "
|
||||
"session per token).",
|
||||
"Recommendation: stop the gateway first with 'hermes gateway stop'."),
|
||||
"Continue anyway?", declined="Migration cancelled. Stop the gateway and try again.",
|
||||
) is False:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
@@ -185,10 +182,8 @@ def _load_migration_module(script_path: Path):
|
||||
spec = importlib.util.spec_from_file_location("openclaw_to_hermes", script_path)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
# Register in sys.modules so @dataclass can resolve the module
|
||||
# (Python 3.11+ requires this for dynamically loaded modules)
|
||||
sys.modules[spec.name] = mod
|
||||
# Register in sys.modules so @dataclass can resolve the module (Python 3.11+ requires this).
|
||||
mod = sys.modules[spec.name] = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception:
|
||||
@@ -203,360 +198,202 @@ def _find_openclaw_dirs() -> list[Path]:
|
||||
|
||||
|
||||
def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
|
||||
"""Scan an OpenClaw directory for workspace state files."""
|
||||
findings: list[tuple[Path, str]] = []
|
||||
|
||||
if not source_dir.exists():
|
||||
return findings
|
||||
|
||||
def _add(path: Path, scope: str) -> None:
|
||||
if path.exists():
|
||||
kind = "directory" if path.is_dir() else "file"
|
||||
findings.append((path, f"{scope} {kind}: {path.relative_to(source_dir).as_posix()}"))
|
||||
|
||||
# Direct state files in the root
|
||||
for name in ("todo.json", "sessions", "logs"):
|
||||
_add(source_dir / name, "Root")
|
||||
|
||||
# State files inside workspace-like subdirectories
|
||||
"""List state files in an OpenClaw directory root, then in its workspace-like subdirs."""
|
||||
candidates = [(source_dir / name, "Root") for name in ("todo.json", "sessions", "logs")]
|
||||
try:
|
||||
children = sorted(source_dir.iterdir())
|
||||
except OSError:
|
||||
return findings
|
||||
|
||||
for child in children:
|
||||
if child.is_dir() and not child.name.startswith("."):
|
||||
for state_name in ("todo.json", "sessions", "logs", "memory"):
|
||||
_add(child / state_name, "Workspace")
|
||||
|
||||
return findings
|
||||
children = []
|
||||
candidates += [(child / name, "Workspace") for child in children
|
||||
if child.is_dir() and not child.name.startswith(".")
|
||||
for name in ("todo.json", "sessions", "logs", "memory")]
|
||||
return [(p, f"{scope} {'directory' if p.is_dir() else 'file'}: "
|
||||
f"{p.relative_to(source_dir).as_posix()}") for p, scope in candidates if p.exists()]
|
||||
|
||||
|
||||
def _archive_directory(source_dir: Path, dry_run: bool = False) -> Path:
|
||||
"""Rename an OpenClaw directory to .pre-migration."""
|
||||
"""Rename an OpenClaw directory to .pre-migration (date-stamped, then numbered, if taken)."""
|
||||
base = f"{source_dir.name}.pre-migration"
|
||||
archive_path = source_dir.parent / base
|
||||
|
||||
# If archive already exists, add timestamp; if it still exists (multiple
|
||||
# runs same day), add a counter.
|
||||
if archive_path.exists():
|
||||
timestamp = datetime.now().strftime("%Y%m%d")
|
||||
archive_path = source_dir.parent / f"{base}-{timestamp}"
|
||||
counter = 2
|
||||
while archive_path.exists():
|
||||
archive_path = source_dir.parent / f"{base}-{timestamp}-{counter}"
|
||||
counter += 1
|
||||
|
||||
stamped = f"{base}-{datetime.now().strftime('%Y%m%d')}"
|
||||
names = itertools.chain((base, stamped), (f"{stamped}-{n}" for n in itertools.count(2)))
|
||||
archive_path = next(p for p in (source_dir.parent / n for n in names) if not p.exists())
|
||||
if not dry_run:
|
||||
source_dir.rename(archive_path)
|
||||
|
||||
return archive_path
|
||||
|
||||
|
||||
def claw_command(args):
|
||||
"""Route hermes claw subcommands."""
|
||||
action = getattr(args, "claw_action", None)
|
||||
|
||||
if action == "migrate":
|
||||
_cmd_migrate(args)
|
||||
elif action in {"cleanup", "clean"}:
|
||||
_cmd_cleanup(args)
|
||||
else:
|
||||
print("Usage: hermes claw <command> [options]")
|
||||
print()
|
||||
print("Commands:")
|
||||
print(" migrate Migrate settings from OpenClaw to Hermes")
|
||||
print(" cleanup Archive leftover OpenClaw directories after migration")
|
||||
print()
|
||||
print("Run 'hermes claw <command> --help' for options.")
|
||||
print("Usage: hermes claw <command> [options]\n\nCommands:\n"
|
||||
" migrate Migrate settings from OpenClaw to Hermes\n"
|
||||
" cleanup Archive leftover OpenClaw directories after migration\n\n"
|
||||
"Run 'hermes claw <command> --help' for options.")
|
||||
|
||||
|
||||
def _cmd_migrate(args):
|
||||
"""Run the OpenClaw → Hermes migration."""
|
||||
"""Run the OpenClaw → Hermes migration: preflight, preview, confirm, back up, apply."""
|
||||
opts = SimpleNamespace(**{k: getattr(args, k, d) for k, d in _MIGRATE_ARG_DEFAULTS})
|
||||
# Explicit --source, else first existing of current + legacy names; default to ~/.openclaw.
|
||||
explicit_source = getattr(args, "source", None)
|
||||
source_dir = Path(explicit_source) if explicit_source else next(iter(_find_openclaw_dirs()), Path.home() / ".openclaw")
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
preset = getattr(args, "preset", "full")
|
||||
overwrite = getattr(args, "overwrite", False)
|
||||
migrate_secrets = getattr(args, "migrate_secrets", False)
|
||||
workspace_target = getattr(args, "workspace_target", None)
|
||||
skill_conflict = getattr(args, "skill_conflict", "skip")
|
||||
no_backup = getattr(args, "no_backup", False)
|
||||
|
||||
# Secrets are never included implicitly — they must be explicitly requested
|
||||
# via --migrate-secrets, even under --preset full. This mirrors OpenClaw's
|
||||
# migrate-hermes posture (two-phase: run once without secrets, rerun with
|
||||
# --include-secrets) and prevents a --preset full invocation from silently
|
||||
# importing API keys that the user may not have intended to copy.
|
||||
|
||||
opts.source_dir = (Path(opts.source) if opts.source
|
||||
else next(iter(_find_openclaw_dirs()), Path.home() / ".openclaw"))
|
||||
_print_banner("OpenClaw Migration")
|
||||
|
||||
# Check source directory
|
||||
if not source_dir.is_dir():
|
||||
print()
|
||||
print_error(f"OpenClaw directory not found: {source_dir}")
|
||||
print_info("Make sure your OpenClaw installation is at the expected path.")
|
||||
print_info("You can specify a custom path: hermes claw migrate --source /path/to/.openclaw")
|
||||
return
|
||||
|
||||
# Find the migration script
|
||||
if not opts.source_dir.is_dir():
|
||||
return _error_block(
|
||||
f"OpenClaw directory not found: {opts.source_dir}",
|
||||
"Make sure your OpenClaw installation is at the expected path.",
|
||||
"You can specify a custom path: hermes claw migrate --source /path/to/.openclaw")
|
||||
script_path = _find_migration_script()
|
||||
if not script_path:
|
||||
print()
|
||||
print_error("Migration script not found.")
|
||||
print_info("Expected at one of:")
|
||||
print_info(f" {_OPENCLAW_SCRIPT}")
|
||||
print_info(f" {_OPENCLAW_SCRIPT_INSTALLED}")
|
||||
print_info("Make sure the openclaw-migration skill is installed.")
|
||||
return
|
||||
|
||||
# Show what we're doing
|
||||
hermes_home = get_hermes_home()
|
||||
auto_yes = getattr(args, "yes", False)
|
||||
return _error_block(
|
||||
"Migration script not found.", "Expected at one of:", f" {_OPENCLAW_SCRIPT}",
|
||||
f" {_OPENCLAW_SCRIPT_INSTALLED}",
|
||||
"Make sure the openclaw-migration skill is installed.")
|
||||
opts.hermes_home = get_hermes_home()
|
||||
print()
|
||||
print_header("Migration Settings")
|
||||
print_info(f"Source: {source_dir}")
|
||||
print_info(f"Target: {hermes_home}")
|
||||
print_info(f"Preset: {preset}")
|
||||
print_info(f"Overwrite: {'yes' if overwrite else 'no (skip conflicts)'}")
|
||||
print_info(f"Secrets: {'yes (allowlisted only)' if migrate_secrets else 'no'}")
|
||||
if skill_conflict != "skip":
|
||||
print_info(f"Skill conflicts: {skill_conflict}")
|
||||
if workspace_target:
|
||||
print_info(f"Workspace: {workspace_target}")
|
||||
_info(f"Source: {opts.source_dir}", f"Target: {opts.hermes_home}",
|
||||
f"Preset: {opts.preset}",
|
||||
f"Overwrite: {'yes' if opts.overwrite else 'no (skip conflicts)'}",
|
||||
f"Secrets: {'yes (allowlisted only)' if opts.migrate_secrets else 'no'}",
|
||||
*([f"Skill conflicts: {opts.skill_conflict}"] if opts.skill_conflict != "skip" else []),
|
||||
*([f"Workspace: {opts.workspace_target}"] if opts.workspace_target else []))
|
||||
print()
|
||||
|
||||
# Check if OpenClaw is still running — migrating tokens while both are
|
||||
# active will cause conflicts (e.g. Telegram 409).
|
||||
_warn_if_openclaw_running(auto_yes)
|
||||
|
||||
# Check if a Hermes gateway is running with connected platforms.
|
||||
_warn_if_gateway_running(auto_yes)
|
||||
|
||||
# Migrating tokens while OpenClaw or the gateway is active causes conflicts (e.g. Telegram 409).
|
||||
_warn_if_openclaw_running(opts.yes)
|
||||
_warn_if_gateway_running(opts.yes)
|
||||
# Ensure config.yaml exists before migration tries to read it
|
||||
config_path = get_config_path()
|
||||
if not config_path.exists():
|
||||
if not get_config_path().exists():
|
||||
save_config(load_config())
|
||||
run_migrator = _load_migrator(script_path, opts)
|
||||
if run_migrator is None or not _preview_migration(run_migrator, opts):
|
||||
return
|
||||
print()
|
||||
if _confirm(opts.yes, "Proceed with migration?", default=True, declined="Migration cancelled.",
|
||||
non_tty=("Non-interactive session — preview only.",
|
||||
"To execute, re-run with: hermes claw migrate --yes")):
|
||||
_apply_migration(run_migrator, opts)
|
||||
# Source directory is left untouched — archiving is `hermes claw cleanup`'s job.
|
||||
|
||||
# Load the migration module
|
||||
|
||||
def _load_migrator(script_path: Path, opts: SimpleNamespace) -> Optional[Callable[[bool], dict]]:
|
||||
"""Load the migration script; return ``run(execute) -> report`` or None (error printed)."""
|
||||
try:
|
||||
mod = _load_migration_module(script_path)
|
||||
except Exception as e:
|
||||
print()
|
||||
print_error(f"Could not load migration script: {e}")
|
||||
logger.debug("OpenClaw migration error", exc_info=True)
|
||||
return
|
||||
return _error_block(f"Could not load migration script: {e}",
|
||||
debug="OpenClaw migration error")
|
||||
if mod is None:
|
||||
print_error("Could not load migration script.")
|
||||
return
|
||||
return print_error("Could not load migration script.")
|
||||
selected = mod.resolve_selected_options(None, None, preset=opts.preset)
|
||||
ws_target = Path(opts.workspace_target).resolve() if opts.workspace_target else None
|
||||
return lambda execute: mod.Migrator(
|
||||
source_root=opts.source_dir.resolve(), target_root=opts.hermes_home.resolve(),
|
||||
execute=execute, workspace_target=ws_target, overwrite=opts.overwrite,
|
||||
migrate_secrets=opts.migrate_secrets, output_dir=None, selected_options=selected,
|
||||
preset_name=opts.preset, skill_conflict_mode=opts.skill_conflict).migrate()
|
||||
|
||||
selected = mod.resolve_selected_options(None, None, preset=preset)
|
||||
ws_target = Path(workspace_target).resolve() if workspace_target else None
|
||||
|
||||
def _run_migrator(execute: bool) -> dict:
|
||||
return mod.Migrator(
|
||||
source_root=source_dir.resolve(),
|
||||
target_root=hermes_home.resolve(),
|
||||
execute=execute,
|
||||
workspace_target=ws_target,
|
||||
overwrite=overwrite,
|
||||
migrate_secrets=migrate_secrets,
|
||||
output_dir=None,
|
||||
selected_options=selected,
|
||||
preset_name=preset,
|
||||
skill_conflict_mode=skill_conflict,
|
||||
).migrate()
|
||||
|
||||
# ── Phase 1: Always preview first ──────────────────────────
|
||||
def _preview_migration(run_migrator: Callable[[bool], dict], opts: SimpleNamespace) -> bool:
|
||||
"""Always preview first; return True only if the run should proceed to apply."""
|
||||
try:
|
||||
preview_report = _run_migrator(execute=False)
|
||||
preview_report = run_migrator(False)
|
||||
except Exception as e:
|
||||
print()
|
||||
print_error(f"Migration preview failed: {e}")
|
||||
logger.debug("OpenClaw migration preview error", exc_info=True)
|
||||
return
|
||||
|
||||
preview_summary = preview_report.get("summary", {})
|
||||
preview_count = preview_summary.get("migrated", 0)
|
||||
preview_conflicts = preview_summary.get("conflict", 0)
|
||||
|
||||
# "Nothing to migrate" means nothing migrated AND nothing blocked by
|
||||
# conflicts. If there are conflicts, we still want to show the plan and
|
||||
# surface the refusal/--overwrite guidance instead of silently bailing.
|
||||
if preview_count == 0 and preview_conflicts == 0:
|
||||
print()
|
||||
print_info("Nothing to migrate from OpenClaw.")
|
||||
_print_migration_report(preview_report, dry_run=True)
|
||||
return
|
||||
|
||||
_error_block(f"Migration preview failed: {e}", debug="OpenClaw migration preview error")
|
||||
return False
|
||||
summary = preview_report.get("summary", {})
|
||||
count, conflicts = summary.get("migrated", 0), summary.get("conflict", 0)
|
||||
# "Nothing to migrate" means nothing migrated AND nothing blocked by conflicts. With
|
||||
# conflicts, still show the plan and surface the --overwrite guidance instead of bailing.
|
||||
print()
|
||||
print_header(
|
||||
f"Migration Preview — {preview_count} item(s) would be imported"
|
||||
if preview_count > 0
|
||||
else f"Migration Preview — {preview_conflicts} conflict(s), nothing would be imported"
|
||||
)
|
||||
print_info("No changes have been made yet. Review the list below:")
|
||||
if count == 0 and conflicts == 0:
|
||||
print_info("Nothing to migrate from OpenClaw.")
|
||||
else:
|
||||
what = (f"{count} item(s) would be imported" if count > 0
|
||||
else f"{conflicts} conflict(s), nothing would be imported")
|
||||
print_header(f"Migration Preview — {what}")
|
||||
print_info("No changes have been made yet. Review the list below:")
|
||||
_print_migration_report(preview_report, dry_run=True)
|
||||
|
||||
# If --dry-run, stop here
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
# ── Phase 1b: Refuse if the plan has conflicts and --overwrite is not set ─
|
||||
# Modelled on OpenClaw's assertConflictFreePlan() — apply is a safe no-op
|
||||
# on conflicts unless the user explicitly opts in to overwriting. Without
|
||||
# this guard, the user would answer "yes, proceed" and silently end up
|
||||
# with a migration that skipped every conflicting item.
|
||||
if preview_conflicts > 0 and not overwrite:
|
||||
print()
|
||||
print_error(
|
||||
f"Plan has {preview_conflicts} conflict(s). Refusing to apply."
|
||||
)
|
||||
print_info(
|
||||
if opts.dry_run or (count == 0 and conflicts == 0):
|
||||
return False
|
||||
# Modelled on OpenClaw's assertConflictFreePlan(): apply is a safe no-op on conflicts unless
|
||||
# the user opts in to overwriting — otherwise "yes, proceed" would silently skip every
|
||||
# conflicting item.
|
||||
if conflicts > 0 and not opts.overwrite:
|
||||
_error_block(
|
||||
f"Plan has {conflicts} conflict(s). Refusing to apply.",
|
||||
"Each conflict is an item whose target already exists in ~/.hermes/. "
|
||||
"Re-run with --overwrite to replace conflicting targets (item-level "
|
||||
"backups are written to the migration report directory)."
|
||||
)
|
||||
print_info("Or re-run with --dry-run to review the full plan.")
|
||||
return
|
||||
"backups are written to the migration report directory).",
|
||||
"Or re-run with --dry-run to review the full plan.")
|
||||
return False
|
||||
return True
|
||||
|
||||
# ── Phase 2: Confirm and execute ───────────────────────────
|
||||
print()
|
||||
if not auto_yes:
|
||||
if not sys.stdin.isatty():
|
||||
print_info("Non-interactive session — preview only.")
|
||||
print_info("To execute, re-run with: hermes claw migrate --yes")
|
||||
return
|
||||
if not prompt_yes_no("Proceed with migration?", default=True):
|
||||
print_info("Migration cancelled.")
|
||||
return
|
||||
|
||||
# ── Phase 2b: Pre-apply backup of the Hermes home ─────────
|
||||
# Delegates to hermes_cli.backup.create_pre_migration_backup(), which
|
||||
# shares implementation with the pre-update backup (same exclusion
|
||||
# rules, same SQLite safe-copy, zip format) so the archive is
|
||||
# restorable with `hermes import`. Mirrors OpenClaw's
|
||||
# createPreMigrationBackup posture — one atomic restore point before
|
||||
# any mutation, auto-pruned to the last 5 pre-migration zips.
|
||||
def _apply_migration(run_migrator: Callable[[bool], dict], opts: SimpleNamespace) -> None:
|
||||
"""Take a pre-migration backup (unless --no-backup), execute, and print the report. The backup
|
||||
shares the pre-update backup's implementation (exclusions, SQLite safe-copy, zip) so it is
|
||||
restorable with `hermes import`: one restore point before any mutation, pruned to the last 5."""
|
||||
backup_archive: Optional[Path] = None
|
||||
if not no_backup:
|
||||
if not opts.no_backup:
|
||||
try:
|
||||
from hermes_cli.backup import create_pre_migration_backup, _format_size
|
||||
backup_archive = create_pre_migration_backup(hermes_home=hermes_home)
|
||||
backup_archive = create_pre_migration_backup(hermes_home=opts.hermes_home)
|
||||
if backup_archive:
|
||||
size_str = _format_size(backup_archive.stat().st_size)
|
||||
print()
|
||||
print_success(f"Pre-migration backup: {backup_archive} ({size_str})")
|
||||
print_success(f"Pre-migration backup: {backup_archive} "
|
||||
f"({_format_size(backup_archive.stat().st_size)})")
|
||||
print_info(f"Restore with: hermes import {backup_archive.name}")
|
||||
except Exception as e:
|
||||
print()
|
||||
print_error(f"Could not create pre-migration backup: {e}")
|
||||
print_info(
|
||||
"Re-run with --no-backup to skip, or free up disk space under the Hermes home."
|
||||
)
|
||||
logger.debug("Pre-migration backup error", exc_info=True)
|
||||
return
|
||||
|
||||
return _error_block(
|
||||
f"Could not create pre-migration backup: {e}",
|
||||
"Re-run with --no-backup to skip, or free up disk space under the Hermes home.",
|
||||
debug="Pre-migration backup error")
|
||||
try:
|
||||
report = _run_migrator(execute=True)
|
||||
report = run_migrator(True)
|
||||
except Exception as e:
|
||||
print()
|
||||
print_error(f"Migration failed: {e}")
|
||||
logger.debug("OpenClaw migration error", exc_info=True)
|
||||
_error_block(f"Migration failed: {e}", debug="OpenClaw migration error")
|
||||
if backup_archive:
|
||||
print_info(f"A pre-migration backup is available at: {backup_archive}")
|
||||
print_info(f"Restore with: hermes import {backup_archive.name}")
|
||||
_info(f"A pre-migration backup is available at: {backup_archive}",
|
||||
f"Restore with: hermes import {backup_archive.name}")
|
||||
return
|
||||
|
||||
# Print results
|
||||
_print_migration_report(report, dry_run=False)
|
||||
|
||||
# Source directory is left untouched — archiving is not the migration
|
||||
# tool's responsibility. Users who want to clean up can run
|
||||
# 'hermes claw cleanup' separately.
|
||||
|
||||
|
||||
def _cmd_cleanup(args):
|
||||
"""Archive leftover OpenClaw directories after migration.
|
||||
|
||||
Scans for OpenClaw directories that still exist after migration and offers to rename them to
|
||||
.pre-migration to free disk space.
|
||||
"""
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
auto_yes = getattr(args, "yes", False)
|
||||
explicit_source = getattr(args, "source", None)
|
||||
|
||||
"""Offer to rename leftover OpenClaw directories to .pre-migration to free disk space."""
|
||||
dry_run, auto_yes = getattr(args, "dry_run", False), getattr(args, "yes", False)
|
||||
_print_banner("OpenClaw Cleanup")
|
||||
|
||||
dirs_to_check = [Path(explicit_source)] if explicit_source else _find_openclaw_dirs()
|
||||
|
||||
source = getattr(args, "source", None)
|
||||
dirs_to_check = [Path(source)] if source else _find_openclaw_dirs()
|
||||
if not dirs_to_check:
|
||||
print()
|
||||
print_success("No OpenClaw directories found. Nothing to clean up.")
|
||||
return
|
||||
|
||||
# Warn if OpenClaw is still running — archiving while the service is
|
||||
# active causes it to recreate an empty skeleton directory (#8502).
|
||||
return print_success("No OpenClaw directories found. Nothing to clean up.")
|
||||
# Archiving while the service is active makes it recreate an empty skeleton directory.
|
||||
running = _detect_openclaw_processes()
|
||||
if running:
|
||||
_warn_running(
|
||||
running,
|
||||
"OpenClaw appears to be still running:",
|
||||
"Archiving .openclaw/ while the service is active may cause it to "
|
||||
"immediately recreate an empty skeleton directory, destroying your config.",
|
||||
"Stop OpenClaw first: systemctl --user stop openclaw-gateway.service",
|
||||
)
|
||||
if not auto_yes:
|
||||
if not sys.stdin.isatty():
|
||||
print_info("Non-interactive session — aborting. Stop OpenClaw and re-run.")
|
||||
return
|
||||
if not prompt_yes_no("Proceed anyway?", default=False):
|
||||
print_info("Aborted. Stop OpenClaw first, then re-run: hermes claw cleanup")
|
||||
return
|
||||
|
||||
if running and not _warn_running(
|
||||
auto_yes, "OpenClaw appears to be still running:", running,
|
||||
("Archiving .openclaw/ while the service is active may cause it to "
|
||||
"immediately recreate an empty skeleton directory, destroying your config.",
|
||||
"Stop OpenClaw first: systemctl --user stop openclaw-gateway.service"),
|
||||
"Proceed anyway?",
|
||||
declined="Aborted. Stop OpenClaw first, then re-run: hermes claw cleanup",
|
||||
non_tty=("Non-interactive session — aborting. Stop OpenClaw and re-run.",)):
|
||||
return
|
||||
total_archived = 0
|
||||
|
||||
for source_dir in dirs_to_check:
|
||||
print()
|
||||
print_header(f"Found: {source_dir}")
|
||||
|
||||
# Scan for state files
|
||||
state_files = _scan_workspace_state(source_dir)
|
||||
|
||||
# Show directory stats
|
||||
try:
|
||||
workspace_dirs = [
|
||||
d for d in source_dir.iterdir()
|
||||
if d.is_dir() and not d.name.startswith(".")
|
||||
and any((d / name).exists() for name in ("todo.json", "SOUL.md", "MEMORY.md", "USER.md"))
|
||||
]
|
||||
except OSError:
|
||||
workspace_dirs = []
|
||||
|
||||
if workspace_dirs:
|
||||
print_info(f"Workspace directories: {len(workspace_dirs)}")
|
||||
for ws in workspace_dirs[:5]:
|
||||
items = [label for name, label, check in _WORKSPACE_ITEM_LABELS if check(ws / name)]
|
||||
print(f" {ws.name}/ ({', '.join(items) or 'empty'})")
|
||||
_print_more(workspace_dirs, 5)
|
||||
|
||||
if state_files:
|
||||
print()
|
||||
print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW))
|
||||
for _path, desc in state_files[:8]:
|
||||
print(f" {desc}")
|
||||
_print_more(state_files, 8)
|
||||
|
||||
print()
|
||||
|
||||
_describe_openclaw_dir(source_dir)
|
||||
if dry_run:
|
||||
archive_path = _archive_directory(source_dir, dry_run=True)
|
||||
print_info(f"Would archive: {source_dir} → {archive_path}")
|
||||
elif not auto_yes and not sys.stdin.isatty():
|
||||
print_info(f"Non-interactive session — would archive: {source_dir}")
|
||||
print_info("To execute, re-run with: hermes claw cleanup --yes")
|
||||
elif auto_yes or prompt_yes_no(f"Archive {source_dir}?", default=True):
|
||||
elif _confirm(auto_yes, f"Archive {source_dir}?", default=True, declined="Skipped.",
|
||||
non_tty=(f"Non-interactive session — would archive: {source_dir}",
|
||||
"To execute, re-run with: hermes claw cleanup --yes")):
|
||||
try:
|
||||
archive_path = _archive_directory(source_dir)
|
||||
print_success(f"Archived: {source_dir} → {archive_path}")
|
||||
@@ -564,115 +401,95 @@ def _cmd_cleanup(args):
|
||||
except OSError as e:
|
||||
print_error(f"Could not archive: {e}")
|
||||
print_info(f"Try manually: mv {source_dir} {source_dir}.pre-migration")
|
||||
else:
|
||||
print_info("Skipped.")
|
||||
|
||||
# Summary
|
||||
print()
|
||||
word = "directory" if (len(dirs_to_check) if dry_run else total_archived) == 1 else "directories"
|
||||
if dry_run:
|
||||
_n_dirs = len(dirs_to_check)
|
||||
print_info(f"Dry run complete. {_n_dirs} {_dirs_word(_n_dirs)} would be archived.")
|
||||
print_info("Run without --dry-run to archive them.")
|
||||
_info(f"Dry run complete. {len(dirs_to_check)} {word} would be archived.",
|
||||
"Run without --dry-run to archive them.")
|
||||
elif total_archived:
|
||||
print_success(f"Cleaned up {total_archived} OpenClaw {_dirs_word(total_archived)}.")
|
||||
print_success(f"Cleaned up {total_archived} OpenClaw {word}.")
|
||||
print_info("Directories were renamed, not deleted. You can undo by renaming them back.")
|
||||
else:
|
||||
print_info("No directories were archived.")
|
||||
|
||||
|
||||
def _dirs_word(n: int) -> str:
|
||||
return "directory" if n == 1 else "directories"
|
||||
def _describe_openclaw_dir(source_dir: Path) -> None:
|
||||
"""Print the workspace directories (first 5) and state files (first 8) of one OpenClaw dir."""
|
||||
print()
|
||||
print_header(f"Found: {source_dir}")
|
||||
state_files = _scan_workspace_state(source_dir)
|
||||
try:
|
||||
workspace_dirs = [d for d in source_dir.iterdir() if d.is_dir()
|
||||
and not d.name.startswith(".")
|
||||
and any((d / n).exists() for n in _WORKSPACE_MARKERS)]
|
||||
except OSError:
|
||||
workspace_dirs = []
|
||||
if workspace_dirs:
|
||||
print_info(f"Workspace directories: {len(workspace_dirs)}")
|
||||
_print_rows([f"{ws.name}/ ({', '.join(_workspace_items(ws)) or 'empty'})"
|
||||
for ws in workspace_dirs], 5)
|
||||
if state_files:
|
||||
print()
|
||||
print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW))
|
||||
_print_rows([desc for _path, desc in state_files], 8)
|
||||
print()
|
||||
|
||||
|
||||
def _print_more(seq, shown: int) -> None:
|
||||
if len(seq) > shown:
|
||||
print(f" ... and {len(seq) - shown} more")
|
||||
def _workspace_items(ws: Path) -> list[str]:
|
||||
return [lbl for n, lbl, chk in _WORKSPACE_ITEM_LABELS if chk(ws / n)]
|
||||
|
||||
|
||||
# (status, heading, color, default reason) — printed in this order after migrated items.
|
||||
_REPORT_REASON_GROUPS = (
|
||||
("conflict", " ⚠ Conflicts (skipped — use --overwrite to force):", Colors.YELLOW, "already exists"),
|
||||
("skipped", " ─ Skipped:", Colors.DIM, ""),
|
||||
("error", " ✗ Errors:", Colors.RED, "unknown error"),
|
||||
)
|
||||
|
||||
# Workspace marker files listed by `hermes claw cleanup` (name, display label, presence check).
|
||||
_WORKSPACE_ITEM_LABELS = (
|
||||
("todo.json", "todo.json", Path.exists),
|
||||
("sessions", "sessions/", Path.is_dir),
|
||||
("SOUL.md", "SOUL.md", Path.exists),
|
||||
("MEMORY.md", "MEMORY.md", Path.exists),
|
||||
)
|
||||
def _print_rows(rows: list[str], shown: int) -> None:
|
||||
"""Print the first ``shown`` rows indented, then a '... and N more' trailer if truncated."""
|
||||
for row in rows[:shown]:
|
||||
print(f" {row}")
|
||||
if len(rows) > shown:
|
||||
print(f" ... and {len(rows) - shown} more")
|
||||
|
||||
|
||||
def _print_migration_report(report: dict, dry_run: bool):
|
||||
"""Print a formatted migration report."""
|
||||
summary = report.get("summary", {})
|
||||
summary, items = report.get("summary", {}), report.get("items", [])
|
||||
migrated = summary.get("migrated", 0)
|
||||
|
||||
print()
|
||||
print_header("Dry Run Results" if dry_run else "Migration Results")
|
||||
if dry_run:
|
||||
print_header("Dry Run Results")
|
||||
print_info("No files were modified. This is a preview of what would happen.")
|
||||
else:
|
||||
print_header("Migration Results")
|
||||
|
||||
print()
|
||||
|
||||
# Detailed items
|
||||
items = report.get("items", [])
|
||||
migrated_items = [i for i in items if i.get("status") == "migrated"]
|
||||
if migrated_items:
|
||||
label = "Would migrate" if dry_run else "Migrated"
|
||||
print(color(f" ✓ {label}:", Colors.GREEN))
|
||||
for item in migrated_items:
|
||||
kind = item.get("kind", "unknown")
|
||||
dest = item.get("destination", "")
|
||||
print(f" {kind:<22s} → {str(dest).replace(str(Path.home()), '~')}" if dest else f" {kind}")
|
||||
print()
|
||||
|
||||
for status, heading, heading_color, default_reason in _REPORT_REASON_GROUPS:
|
||||
group = [i for i in items if i.get("status") == status]
|
||||
if not group:
|
||||
continue
|
||||
print(color(heading, heading_color))
|
||||
for item in group:
|
||||
print(f" {item.get('kind', 'unknown'):<22s} {item.get('reason', default_reason)}")
|
||||
print()
|
||||
|
||||
# Summary line
|
||||
parts = [
|
||||
f"{count} {label}"
|
||||
for count, label in (
|
||||
(migrated, "would migrate" if dry_run else "migrated"),
|
||||
(summary.get("conflict", 0), "conflict(s)"),
|
||||
(summary.get("skipped", 0), "skipped"),
|
||||
(summary.get("error", 0), "error(s)"),
|
||||
)
|
||||
if count
|
||||
]
|
||||
migrated_heading = f" ✓ {'Would migrate' if dry_run else 'Migrated'}:"
|
||||
for status, heading, heading_color, default_reason in (
|
||||
("migrated", migrated_heading, Colors.GREEN, None), *_REPORT_REASON_GROUPS):
|
||||
rows = []
|
||||
for item in (i for i in items if i.get("status") == status):
|
||||
kind, dest = item.get("kind", "unknown"), item.get("destination", "")
|
||||
if default_reason is not None: # conflict / skipped / error rows show a reason
|
||||
rows.append(f"{kind:<22s} {item.get('reason', default_reason)}")
|
||||
else:
|
||||
rows.append(f"{kind:<22s} → {str(dest).replace(str(Path.home()), '~')}" if dest
|
||||
else kind)
|
||||
if rows:
|
||||
print(color(heading, heading_color))
|
||||
_print_rows(rows, len(rows))
|
||||
print()
|
||||
counts = [(migrated, "would migrate" if dry_run else "migrated")] + [
|
||||
(summary.get(k, 0), label) for k, label in _SUMMARY_COUNT_LABELS]
|
||||
parts = [f"{count} {label}" for count, label in counts if count]
|
||||
print_info(f"Summary: {', '.join(parts)}" if parts else "Nothing to migrate.")
|
||||
|
||||
# Output directory
|
||||
output_dir = report.get("output_dir")
|
||||
if output_dir:
|
||||
print_info(f"Full report saved to: {output_dir}")
|
||||
|
||||
if report.get("output_dir"):
|
||||
print_info(f"Full report saved to: {report['output_dir']}")
|
||||
if dry_run:
|
||||
print()
|
||||
print_info("To execute the migration, run without --dry-run:")
|
||||
print_info(f" hermes claw migrate --preset {report.get('preset', 'full')}")
|
||||
_info("", "To execute the migration, run without --dry-run:",
|
||||
f" hermes claw migrate --preset {report.get('preset', 'full')}")
|
||||
elif migrated:
|
||||
print()
|
||||
print_success("Migration complete!")
|
||||
# Warn if API keys were skipped (migrate_secrets not enabled)
|
||||
if any(i.get("kind") == "provider-keys" and i.get("status") == "skipped" for i in items):
|
||||
print()
|
||||
print(color(" ⚠ API keys were NOT migrated (secrets migration is disabled by default).", Colors.YELLOW))
|
||||
print(color(" Your OPENROUTER_API_KEY and other provider keys must be added manually.", Colors.YELLOW))
|
||||
print()
|
||||
print_info("To migrate API keys, re-run with:")
|
||||
print_info(" hermes claw migrate --migrate-secrets")
|
||||
print()
|
||||
print_info("Or add your key manually:")
|
||||
print_info(" hermes config set OPENROUTER_API_KEY sk-or-v1-...")
|
||||
for line in (
|
||||
" ⚠ API keys were NOT migrated (secrets migration is disabled by default).",
|
||||
" Your OPENROUTER_API_KEY and other provider keys must be added manually."):
|
||||
print(color(line, Colors.YELLOW))
|
||||
_info("", "To migrate API keys, re-run with:",
|
||||
" hermes claw migrate --migrate-secrets", "", "Or add your key manually:",
|
||||
" hermes config set OPENROUTER_API_KEY sk-or-v1-...")
|
||||
|
||||
+250
-341
@@ -1,11 +1,6 @@
|
||||
"""Agent-construction and session-resume display methods for ``HermesCLI``.
|
||||
|
||||
Holds the agent lifecycle cluster lifted from ``cli.py``: runtime-credential
|
||||
resolution, per-turn agent config, first-use agent construction, and resumed-session
|
||||
preload + history recap. ``cli.py``-internal helpers are imported lazily inside each
|
||||
method (``from cli import ...`` resolves once ``cli`` is fully loaded) so this module
|
||||
never imports ``cli`` at import time -> no import cycle.
|
||||
"""
|
||||
"""Agent construction + session-resume display for ``HermesCLI``: credential resolution,
|
||||
per-turn agent config, first-use build, resume preload + recap. ``cli.py`` helpers are
|
||||
imported lazily inside each method (import cycle)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -42,21 +37,23 @@ def _current_runtime(cli) -> dict:
|
||||
"api_mode": cli.api_mode,
|
||||
"command": cli.acp_command,
|
||||
"args": list(cli.acp_args or []),
|
||||
"credential_pool": getattr(cli, "_credential_pool", None),
|
||||
}
|
||||
"credential_pool": getattr(cli, "_credential_pool", None)}
|
||||
|
||||
|
||||
def _route_signature(model, runtime: dict) -> tuple:
|
||||
"""Hashable identity of (model, routing) used to detect when the agent must be rebuilt."""
|
||||
return (
|
||||
model,
|
||||
runtime.get("provider"),
|
||||
runtime.get("requested_provider"),
|
||||
runtime.get("base_url"),
|
||||
runtime.get("api_mode"),
|
||||
runtime.get("command"),
|
||||
tuple(runtime.get("args") or ()),
|
||||
)
|
||||
model, runtime.get("provider"), runtime.get("requested_provider"), runtime.get("base_url"),
|
||||
runtime.get("api_mode"), runtime.get("command"), tuple(runtime.get("args") or ()))
|
||||
|
||||
|
||||
def _keyless_custom_base(base_url) -> bool:
|
||||
"""Custom/local endpoints (llama.cpp, ollama, vLLM) often need no auth; only a
|
||||
non-OpenRouter base_url qualifies."""
|
||||
return bool(
|
||||
isinstance(base_url, str)
|
||||
and base_url
|
||||
and not base_url_host_matches(base_url, "openrouter.ai"))
|
||||
|
||||
|
||||
def _compression_descendant(session_db, session_id):
|
||||
@@ -69,6 +66,29 @@ def _compression_descendant(session_db, session_id):
|
||||
return resolved_id if resolved_id and resolved_id != session_id else None
|
||||
|
||||
|
||||
def _user_display_text(content) -> str:
|
||||
"""Recap text for a user row; multimodal lists become text parts + ``[image]`` markers."""
|
||||
if isinstance(content, list):
|
||||
return " ".join(
|
||||
part.get("text", "") if part.get("type") == "text" else "[image]"
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") in ("text", "image_url"))
|
||||
return "" if content is None else str(content)
|
||||
|
||||
|
||||
def _tool_calls_summary(tool_calls) -> str:
|
||||
"""``[N tool call(s): name, ...]`` with up to 4 distinct names."""
|
||||
names = []
|
||||
for tc in tool_calls:
|
||||
fn = tc.get("function", {})
|
||||
name = fn.get("name", "unknown") if isinstance(fn, dict) else "unknown"
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
names_str = ", ".join(names[:4]) + (", ..." if len(names) > 4 else "")
|
||||
noun = "call" if len(tool_calls) == 1 else "calls"
|
||||
return f"[{len(tool_calls)} tool {noun}: {names_str}]"
|
||||
|
||||
|
||||
# display_kind -> recap event line; ``hidden`` rows are skipped before this lookup.
|
||||
_RESUME_EVENT_TEXT = {
|
||||
"model_switch": "model changed",
|
||||
@@ -76,97 +96,117 @@ _RESUME_EVENT_TEXT = {
|
||||
"auto_continue": "resumed interrupted turn",
|
||||
}
|
||||
|
||||
def _collect_resume_entries(display_history, disp: dict, clean_assistant):
|
||||
"""Displayable ``(role, text)`` recap entries from stored history, truncated per the
|
||||
``display.resume_*`` config; system and tool-result rows are skipped. Returns
|
||||
``(entries, index of last assistant entry, its un-truncated text)``.
|
||||
|
||||
Stored history is untrusted for display: text is sanitized so replay can't clear the
|
||||
screen, retitle the window or restyle the panel. Pure-reasoning assistant rows with no
|
||||
visible output are skipped, as are tool-call-only rows when ``resume_skip_tool_only``.
|
||||
"""
|
||||
from tools.ansi_strip import sanitize_display_text as _sanitize_display_text
|
||||
max_user_len = int(disp.get("resume_max_user_chars", 300))
|
||||
max_asst_len = int(disp.get("resume_max_assistant_chars", 200))
|
||||
max_asst_lines = int(disp.get("resume_max_assistant_lines", 3))
|
||||
skip_tool_only = disp.get("resume_skip_tool_only", True)
|
||||
entries: list = []
|
||||
last_asst_idx = None
|
||||
last_asst_full = None
|
||||
for msg in display_history:
|
||||
role = msg.get("role", "")
|
||||
display_kind = msg.get("display_kind")
|
||||
content = msg.get("content")
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
if display_kind == "hidden":
|
||||
continue
|
||||
if display_kind in _RESUME_EVENT_TEXT:
|
||||
entries.append(("event", _RESUME_EVENT_TEXT[display_kind]))
|
||||
continue
|
||||
if role == "user":
|
||||
text = _sanitize_display_text(_user_display_text(content))
|
||||
if len(text) > max_user_len:
|
||||
text = text[:max_user_len] + "..."
|
||||
entries.append(("user", text))
|
||||
elif role == "assistant":
|
||||
text = clean_assistant("" if content is None else str(content))
|
||||
parts, full_parts = [], []
|
||||
if text:
|
||||
full_parts.append(text)
|
||||
lines = text.splitlines()
|
||||
if len(lines) > max_asst_lines:
|
||||
text = "\n".join(lines[:max_asst_lines]) + " ..."
|
||||
if len(text) > max_asst_len:
|
||||
text = text[:max_asst_len] + "..."
|
||||
parts.append(text)
|
||||
if tool_calls:
|
||||
parts.append(_tool_calls_summary(tool_calls))
|
||||
full_parts.append(parts[-1])
|
||||
if not text and (skip_tool_only or not tool_calls):
|
||||
continue
|
||||
entries.append(("assistant", " ".join(parts)))
|
||||
last_asst_idx = len(entries) - 1
|
||||
last_asst_full = " ".join(full_parts)
|
||||
return entries, last_asst_idx, last_asst_full
|
||||
|
||||
|
||||
# (skin key, fallback) for recap panel colors: body text, session label, border, assistant label.
|
||||
_RESUME_SKIN_COLORS = (
|
||||
("banner_text", "#FFF8DC"),
|
||||
("session_label", "#DAA520"),
|
||||
("session_border", "#8B8682"),
|
||||
("banner_text", "#FFF8DC"), ("session_label", "#DAA520"), ("session_border", "#8B8682"),
|
||||
("ui_ok", "#8FBC8F"),
|
||||
)
|
||||
|
||||
|
||||
def _resume_panel_colors() -> tuple:
|
||||
"""Active-skin colors for ``_RESUME_SKIN_COLORS`` (fallbacks when no skin loads)."""
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_skin = get_active_skin()
|
||||
return tuple(_skin.get_color(key, default) for key, default in _RESUME_SKIN_COLORS)
|
||||
except Exception:
|
||||
return tuple(default for _, default in _RESUME_SKIN_COLORS)
|
||||
|
||||
|
||||
class CLIAgentSetupMixin:
|
||||
"""Agent construction + session-resume display methods for ``HermesCLI``."""
|
||||
|
||||
def _ensure_runtime_credentials(self) -> bool:
|
||||
"""Re-resolve provider credentials before agent use so key rotation / token
|
||||
refresh are picked up without restarting the CLI. False on auth failure."""
|
||||
from cli import ChatConsole, _cprint, logger
|
||||
from cli import ChatConsole, logger
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider, format_runtime_provider_error
|
||||
|
||||
_primary_exc = None
|
||||
runtime = None
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=self.requested_provider,
|
||||
explicit_api_key=self._explicit_api_key,
|
||||
explicit_base_url=self._explicit_base_url,
|
||||
)
|
||||
requested=self.requested_provider, explicit_api_key=self._explicit_api_key,
|
||||
explicit_base_url=self._explicit_base_url)
|
||||
except Exception as exc:
|
||||
_primary_exc = exc
|
||||
|
||||
# Primary provider auth failed — try fallback providers before giving up.
|
||||
if runtime is None and _primary_exc is not None:
|
||||
from hermes_cli.auth import AuthError
|
||||
if isinstance(_primary_exc, AuthError):
|
||||
_fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else []
|
||||
for _fb in _fb_chain:
|
||||
_fb_provider = (_fb.get("provider") or "").strip().lower()
|
||||
_fb_model = (_fb.get("model") or "").strip()
|
||||
if not _fb_provider or not _fb_model:
|
||||
continue
|
||||
try:
|
||||
from hermes_cli.fallback_config import resolve_entry_api_key
|
||||
|
||||
_fb_kwargs = {"requested": _fb_provider}
|
||||
if _fb.get("base_url"):
|
||||
_fb_kwargs["explicit_base_url"] = _fb["base_url"]
|
||||
_fb_api_key = resolve_entry_api_key(_fb)
|
||||
if _fb_api_key:
|
||||
_fb_kwargs["explicit_api_key"] = _fb_api_key
|
||||
runtime = resolve_runtime_provider(**_fb_kwargs)
|
||||
logger.warning(
|
||||
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
|
||||
_primary_exc, _fb_provider, _fb_model,
|
||||
)
|
||||
_cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}")
|
||||
self.requested_provider = _fb_provider
|
||||
self.model = _fb_model
|
||||
_primary_exc = None
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if _primary_exc is not None:
|
||||
runtime = self._resolve_fallback_runtime(_primary_exc)
|
||||
if runtime is not None:
|
||||
_primary_exc = None
|
||||
if runtime is None:
|
||||
message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed."
|
||||
ChatConsole().print(f"[bold red]{message}[/]")
|
||||
return False
|
||||
|
||||
api_key = runtime.get("api_key")
|
||||
base_url = runtime.get("base_url")
|
||||
resolved_provider = runtime.get("provider", "openrouter")
|
||||
resolved_api_mode = runtime.get("api_mode", self.api_mode)
|
||||
resolved_acp_command = runtime.get("command")
|
||||
resolved_acp_args = list(runtime.get("args") or [])
|
||||
resolved_routing = (
|
||||
resolved_provider, runtime.get("api_mode", self.api_mode), runtime.get("command"),
|
||||
list(runtime.get("args") or []))
|
||||
# A callable api_key is a bearer-token provider (Azure Entra ID): the OpenAI SDK
|
||||
# invokes it per request, so skip string validation / placeholder substitution.
|
||||
_is_callable_provider = callable(api_key) and not isinstance(api_key, str)
|
||||
if not _is_callable_provider and (not isinstance(api_key, str) or not api_key):
|
||||
# Custom/local endpoints (llama.cpp, ollama, vLLM) often need no auth: with a
|
||||
# non-OpenRouter base_url use a placeholder key so the SDK doesn't reject it.
|
||||
_source = runtime.get("source", "")
|
||||
_has_custom_base = (
|
||||
isinstance(base_url, str)
|
||||
and base_url
|
||||
and not base_url_host_matches(base_url, "openrouter.ai")
|
||||
)
|
||||
if _has_custom_base:
|
||||
if not callable(api_key) and not (isinstance(api_key, str) and api_key):
|
||||
if _keyless_custom_base(base_url):
|
||||
# Placeholder key so the SDK doesn't reject the keyless local endpoint.
|
||||
api_key = "no-key-required"
|
||||
logger.debug(
|
||||
"No API key for custom endpoint %s (source=%s), "
|
||||
"using placeholder — local servers typically ignore auth",
|
||||
base_url, _source,
|
||||
)
|
||||
base_url, runtime.get("source", ""))
|
||||
else:
|
||||
_prov = (resolved_provider or self.requested_provider or "").strip()
|
||||
if _prov and _prov != "auto":
|
||||
@@ -180,18 +220,9 @@ class CLIAgentSetupMixin:
|
||||
print("\n⚠️ Provider resolver returned an empty base URL. "
|
||||
"Check your provider config or run: hermes setup")
|
||||
return False
|
||||
|
||||
credentials_changed = api_key != self.api_key or base_url != self.base_url
|
||||
routing_changed = (
|
||||
resolved_provider != self.provider
|
||||
or resolved_api_mode != self.api_mode
|
||||
or resolved_acp_command != self.acp_command
|
||||
or resolved_acp_args != self.acp_args
|
||||
)
|
||||
self.provider = resolved_provider
|
||||
self.api_mode = resolved_api_mode
|
||||
self.acp_command = resolved_acp_command
|
||||
self.acp_args = resolved_acp_args
|
||||
routing_changed = resolved_routing != (self.provider, self.api_mode, self.acp_command, self.acp_args)
|
||||
self.provider, self.api_mode, self.acp_command, self.acp_args = resolved_routing
|
||||
self._credential_pool = runtime.get("credential_pool")
|
||||
self._provider_source = runtime.get("source")
|
||||
self.api_key = api_key
|
||||
@@ -202,8 +233,7 @@ class CLIAgentSetupMixin:
|
||||
# would otherwise send the provider name as the model string -> 400).
|
||||
runtime_model = runtime.get("model")
|
||||
if runtime_model and isinstance(runtime_model, str) and (
|
||||
not self.model or self.model == self.provider or self.model == runtime.get("name")
|
||||
):
|
||||
not self.model or self.model == self.provider or self.model == runtime.get("name")):
|
||||
self.model = runtime_model
|
||||
|
||||
# Still empty (e.g. `hermes auth add` without `hermes model`): fall back to the
|
||||
@@ -216,8 +246,7 @@ class CLIAgentSetupMixin:
|
||||
self.model = _default
|
||||
logger.info(
|
||||
"No model configured — defaulting to %s for provider %s",
|
||||
_default, resolved_provider,
|
||||
)
|
||||
_default, resolved_provider)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -228,43 +257,68 @@ class CLIAgentSetupMixin:
|
||||
if (credentials_changed or routing_changed or model_changed) and self.agent is not None:
|
||||
self.agent = None
|
||||
self._active_agent_route_signature = None
|
||||
|
||||
return True
|
||||
|
||||
def _resolve_fallback_runtime(self, primary_exc):
|
||||
"""Primary provider resolution failed: on an AuthError try each fallback entry in
|
||||
order and switch the CLI's requested_provider/model to the first that resolves.
|
||||
None when the error is not auth-related or no fallback resolves."""
|
||||
from cli import _cprint, logger
|
||||
from hermes_cli.auth import AuthError
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
if not isinstance(primary_exc, AuthError):
|
||||
return None
|
||||
_fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else []
|
||||
for _fb in _fb_chain:
|
||||
_fb_provider = (_fb.get("provider") or "").strip().lower()
|
||||
_fb_model = (_fb.get("model") or "").strip()
|
||||
if not _fb_provider or not _fb_model:
|
||||
continue
|
||||
try:
|
||||
from hermes_cli.fallback_config import resolve_entry_api_key
|
||||
_fb_kwargs = {"requested": _fb_provider}
|
||||
if _fb.get("base_url"):
|
||||
_fb_kwargs["explicit_base_url"] = _fb["base_url"]
|
||||
_fb_api_key = resolve_entry_api_key(_fb)
|
||||
if _fb_api_key:
|
||||
_fb_kwargs["explicit_api_key"] = _fb_api_key
|
||||
runtime = resolve_runtime_provider(**_fb_kwargs)
|
||||
logger.warning(
|
||||
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
|
||||
primary_exc, _fb_provider, _fb_model)
|
||||
_cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}")
|
||||
self.requested_provider = _fb_provider
|
||||
self.model = _fb_model
|
||||
return runtime
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _runtime_credentials_ready(self) -> bool:
|
||||
"""Silently probe whether any inference provider can be resolved.
|
||||
|
||||
Never prints or mutates CLI state, so the interactive first-run path can route a
|
||||
keyless install into onboarding before the user types into a chat that can't work."""
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=self.requested_provider,
|
||||
explicit_api_key=self._explicit_api_key,
|
||||
explicit_base_url=self._explicit_base_url,
|
||||
)
|
||||
requested=self.requested_provider, explicit_api_key=self._explicit_api_key,
|
||||
explicit_base_url=self._explicit_base_url)
|
||||
except Exception:
|
||||
return False
|
||||
if not isinstance(runtime, dict):
|
||||
return False
|
||||
api_key = runtime.get("api_key")
|
||||
base_url = runtime.get("base_url")
|
||||
if (callable(api_key) and not isinstance(api_key, str)) or (isinstance(api_key, str) and api_key):
|
||||
if callable(api_key) or (isinstance(api_key, str) and api_key):
|
||||
return bool(base_url)
|
||||
# Keyless custom/local endpoints (ollama, llama.cpp, vLLM…) are fine.
|
||||
return bool(
|
||||
isinstance(base_url, str)
|
||||
and base_url
|
||||
and not base_url_host_matches(base_url, "openrouter.ai")
|
||||
)
|
||||
return _keyless_custom_base(base_url)
|
||||
|
||||
def _offer_first_run_setup(self) -> bool:
|
||||
"""Offer the provider picker when no provider is configured at all (interactive
|
||||
startup, TTY). Runs the same flow as ``hermes model`` so onboarding has a single
|
||||
source of truth. True when a provider was configured."""
|
||||
from cli import _cprint, logger
|
||||
|
||||
_cprint("")
|
||||
_cprint("⚕ No inference provider is configured yet — let's fix that.")
|
||||
_cprint(" You'll pick a provider (Nous Portal OAuth is the fastest; "
|
||||
@@ -277,7 +331,6 @@ class CLIAgentSetupMixin:
|
||||
if answer in {"n", "no"}:
|
||||
_cprint(" Skipped. Run 'hermes model' or 'hermes setup' any time.")
|
||||
return False
|
||||
|
||||
try:
|
||||
from hermes_cli.main import select_provider_and_model
|
||||
select_provider_and_model()
|
||||
@@ -296,18 +349,14 @@ class CLIAgentSetupMixin:
|
||||
from hermes_cli.config import load_config
|
||||
_model_cfg = (load_config().get("model") or {})
|
||||
if isinstance(_model_cfg, dict):
|
||||
_new_provider = (_model_cfg.get("provider") or "").strip()
|
||||
if _new_provider:
|
||||
self.requested_provider = _new_provider
|
||||
self.requested_provider = (_model_cfg.get("provider") or "").strip() or self.requested_provider
|
||||
_new_model = (_model_cfg.get("default") or _model_cfg.get("model") or "").strip()
|
||||
if _new_model:
|
||||
self.model = _new_model
|
||||
self.model = _new_model or self.model
|
||||
except Exception as exc:
|
||||
logger.debug("first-run config re-sync failed: %s", exc)
|
||||
# Force credential re-resolution + agent rebuild on next use.
|
||||
self.agent = None
|
||||
self._active_agent_route_signature = None
|
||||
|
||||
if self._runtime_credentials_ready():
|
||||
_cprint(" ✓ Provider configured — you're ready to chat.")
|
||||
return True
|
||||
@@ -319,23 +368,41 @@ class CLIAgentSetupMixin:
|
||||
provider. With `/fast` on (service_tier == "priority") attach request_overrides;
|
||||
auto/cold tiers are applied per request by agent.fast_mode instead."""
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
runtime = _current_runtime(self)
|
||||
route = {"model": self.model, "runtime": runtime, "signature": _route_signature(self.model, runtime)}
|
||||
|
||||
if getattr(self, "service_tier", None) != "priority":
|
||||
route["request_overrides"] = None
|
||||
return route
|
||||
|
||||
try:
|
||||
overrides = resolve_fast_mode_overrides(
|
||||
route["model"], provider=runtime["provider"], base_url=runtime["base_url"],
|
||||
)
|
||||
except Exception:
|
||||
overrides = None
|
||||
overrides = None
|
||||
if getattr(self, "service_tier", None) == "priority":
|
||||
try:
|
||||
overrides = resolve_fast_mode_overrides(
|
||||
route["model"], provider=runtime["provider"], base_url=runtime["base_url"])
|
||||
except Exception:
|
||||
pass
|
||||
route["request_overrides"] = overrides
|
||||
return route
|
||||
|
||||
def _follow_compression_chain(self, session_meta, announce):
|
||||
"""If the resumed id is an empty compression-chain head, announce and switch to
|
||||
the descendant holding the messages; returns the (possibly refreshed) meta."""
|
||||
resolved_id = _compression_descendant(self._session_db, self.session_id)
|
||||
if resolved_id:
|
||||
announce(resolved_id)
|
||||
self.session_id = resolved_id
|
||||
session_meta = self._session_db.get_session(self.session_id) or session_meta
|
||||
return session_meta
|
||||
|
||||
def _restore_session_state(self, session_meta, *, quiet: bool = False) -> None:
|
||||
"""Restore cwd / yolo / model from the resumed session's metadata."""
|
||||
self._restore_session_cwd(session_meta, quiet=quiet)
|
||||
self._restore_session_yolo(session_meta, quiet=quiet)
|
||||
self._restore_session_model(session_meta, quiet=quiet)
|
||||
|
||||
def _reopen_session(self) -> None:
|
||||
"""Clear ended_at so the resumed session is active again (best effort)."""
|
||||
try:
|
||||
self._session_db.reopen_session(self.session_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_resumed_history_late(self) -> bool:
|
||||
"""Late resume path: validate the session and load its history from the DB when
|
||||
_preload_resumed_session() (called from run()) did not already populate it.
|
||||
@@ -351,24 +418,21 @@ class CLIAgentSetupMixin:
|
||||
print(plain, file=sys.stderr)
|
||||
else:
|
||||
ChatConsole().print(rich)
|
||||
|
||||
if not session_meta:
|
||||
hint = "Use a session ID from a previous CLI run (hermes sessions list)."
|
||||
if _quiet_mode:
|
||||
print(f"Session not found: {self.session_id}", file=sys.stderr)
|
||||
print("Use a session ID from a previous CLI run (hermes sessions list).", file=sys.stderr)
|
||||
print(hint, file=sys.stderr)
|
||||
else:
|
||||
_cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
|
||||
_cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}")
|
||||
_cprint(f"{_DIM}{hint}{_RST}")
|
||||
return False
|
||||
resolved_id = _compression_descendant(self._session_db, self.session_id)
|
||||
if resolved_id:
|
||||
ChatConsole().print(
|
||||
session_meta = self._follow_compression_chain(
|
||||
session_meta,
|
||||
lambda rid: ChatConsole().print(
|
||||
f"[dim]Session {_escape(self.session_id)} was compressed into "
|
||||
f"{_escape(resolved_id)}; resuming the descendant with your "
|
||||
f"transcript.[/dim]"
|
||||
)
|
||||
self.session_id = resolved_id
|
||||
session_meta = self._session_db.get_session(self.session_id) or session_meta
|
||||
f"{_escape(rid)}; resuming the descendant with your "
|
||||
f"transcript.[/dim]"))
|
||||
if getattr(self, "_resume_history_error", None):
|
||||
return False
|
||||
# Only the TIP session's rows are loaded here (no ancestors), so use the
|
||||
@@ -378,8 +442,7 @@ class CLIAgentSetupMixin:
|
||||
self._resume_history_error = resume_limit_error
|
||||
_say(
|
||||
f"Cannot resume session: {resume_limit_error}",
|
||||
f"[bold red]Cannot resume session:[/] {_escape(resume_limit_error)}",
|
||||
)
|
||||
f"[bold red]Cannot resume session:[/] {_escape(resume_limit_error)}")
|
||||
return False
|
||||
restored = self._session_db.get_messages_as_conversation(self.session_id, repair_alternation=True)
|
||||
if restored:
|
||||
@@ -391,21 +454,14 @@ class CLIAgentSetupMixin:
|
||||
_say(
|
||||
f"↻ Resumed session {self.session_id}{title_part} {counts}",
|
||||
f"[bold {_accent_hex()}]↻ Resumed session[/] [bold]{_escape(self.session_id)}[/]"
|
||||
f"[bold {_accent_hex()}]{_escape(title_part)}[/] {counts}",
|
||||
)
|
||||
self._restore_session_cwd(session_meta, quiet=_quiet_mode)
|
||||
self._restore_session_yolo(session_meta, quiet=_quiet_mode)
|
||||
self._restore_session_model(session_meta, quiet=_quiet_mode)
|
||||
f"[bold {_accent_hex()}]{_escape(title_part)}[/] {counts}")
|
||||
self._restore_session_state(session_meta, quiet=_quiet_mode)
|
||||
else:
|
||||
_say(
|
||||
f"Session {self.session_id} found but has no messages. Starting fresh.",
|
||||
f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]",
|
||||
)
|
||||
# Re-open the session (clear ended_at so it's active again)
|
||||
try:
|
||||
self._session_db.reopen_session(self.session_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._reopen_session()
|
||||
return True
|
||||
|
||||
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
|
||||
@@ -418,96 +474,69 @@ class CLIAgentSetupMixin:
|
||||
# Join the background preloaded-skills load (--skills/-s) BEFORE the agent
|
||||
# snapshots self.system_prompt below. No-op when nothing was requested.
|
||||
self.finalize_preloaded_skills()
|
||||
|
||||
_prepare_deferred_agent_startup()
|
||||
self._install_tool_callbacks()
|
||||
self._ensure_tirith_security()
|
||||
|
||||
if not self._ensure_runtime_credentials():
|
||||
return False
|
||||
|
||||
from hermes_cli.mcp_startup import ensure_mcp_discovery_before_agent_build
|
||||
|
||||
ensure_mcp_discovery_before_agent_build(
|
||||
logger=logger,
|
||||
single_query=getattr(self, "_single_query_mode", False),
|
||||
)
|
||||
|
||||
logger=logger, single_query=getattr(self, "_single_query_mode", False))
|
||||
if self._session_db is None:
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
self._session_db = SessionDB()
|
||||
except Exception as e:
|
||||
logger.warning("SQLite session store not available — session will NOT be indexed: %s", e)
|
||||
|
||||
if (
|
||||
self._resumed and self._session_db and not self.conversation_history
|
||||
and not self._load_resumed_history_late()
|
||||
):
|
||||
and not self._load_resumed_history_late()):
|
||||
return False
|
||||
|
||||
try:
|
||||
runtime = runtime_override or _current_runtime(self)
|
||||
effective_model = model_override or self.model
|
||||
# -q never builds the prompt_toolkit app, so the clarify modal can't be
|
||||
# answered — answer headless instead of polling until clarify_timeout.
|
||||
clarify_callback = (
|
||||
_single_query_clarify_callback
|
||||
if getattr(self, "_single_query_mode", False)
|
||||
else self._clarify_callback)
|
||||
self.agent = AIAgent(
|
||||
model=effective_model,
|
||||
api_key=runtime.get("api_key"),
|
||||
base_url=runtime.get("base_url"),
|
||||
provider=runtime.get("provider"),
|
||||
model=effective_model, api_key=runtime.get("api_key"),
|
||||
base_url=runtime.get("base_url"), provider=runtime.get("provider"),
|
||||
requested_provider=runtime.get("requested_provider"),
|
||||
api_mode=runtime.get("api_mode"),
|
||||
acp_command=runtime.get("command"),
|
||||
acp_args=runtime.get("args"),
|
||||
credential_pool=runtime.get("credential_pool"),
|
||||
max_tokens=self.max_tokens,
|
||||
max_iterations=self.max_turns,
|
||||
api_mode=runtime.get("api_mode"), acp_command=runtime.get("command"),
|
||||
acp_args=runtime.get("args"), credential_pool=runtime.get("credential_pool"),
|
||||
max_tokens=self.max_tokens, max_iterations=self.max_turns,
|
||||
run_budget_seconds=getattr(self, "run_budget_seconds", None),
|
||||
enabled_toolsets=self.enabled_toolsets,
|
||||
disabled_toolsets=self.disabled_toolsets,
|
||||
verbose_logging=self.verbose,
|
||||
quiet_mode=not self.verbose,
|
||||
enabled_toolsets=self.enabled_toolsets, disabled_toolsets=self.disabled_toolsets,
|
||||
verbose_logging=self.verbose, quiet_mode=not self.verbose,
|
||||
tool_progress_mode=getattr(self, "tool_progress_mode", "all"),
|
||||
ephemeral_system_prompt=self.system_prompt if self.system_prompt else None,
|
||||
prefill_messages=self.prefill_messages or None,
|
||||
reasoning_config=self.reasoning_config,
|
||||
service_tier=self.service_tier,
|
||||
request_overrides=request_overrides,
|
||||
providers_allowed=self._providers_only,
|
||||
providers_ignored=self._providers_ignore,
|
||||
providers_order=self._providers_order,
|
||||
reasoning_config=self.reasoning_config, service_tier=self.service_tier,
|
||||
request_overrides=request_overrides, providers_allowed=self._providers_only,
|
||||
providers_ignored=self._providers_ignore, providers_order=self._providers_order,
|
||||
provider_sort=self._provider_sort,
|
||||
provider_require_parameters=self._provider_require_params,
|
||||
provider_data_collection=self._provider_data_collection,
|
||||
openrouter_min_coding_score=self._openrouter_min_coding_score,
|
||||
session_id=self.session_id,
|
||||
platform="cli",
|
||||
session_db=self._session_db,
|
||||
# -q never builds the prompt_toolkit app, so the clarify modal can't be
|
||||
# answered — answer headless instead of polling until clarify_timeout.
|
||||
clarify_callback=(
|
||||
_single_query_clarify_callback
|
||||
if getattr(self, "_single_query_mode", False)
|
||||
else self._clarify_callback
|
||||
),
|
||||
session_id=self.session_id, platform="cli", session_db=self._session_db,
|
||||
clarify_callback=clarify_callback,
|
||||
reasoning_callback=self._current_reasoning_callback(),
|
||||
fallback_model=self._fallback_model,
|
||||
thinking_callback=self._on_thinking,
|
||||
fallback_model=self._fallback_model, thinking_callback=self._on_thinking,
|
||||
checkpoints_enabled=self.checkpoints_enabled,
|
||||
checkpoint_max_snapshots=self.checkpoint_max_snapshots,
|
||||
checkpoint_max_total_size_mb=self.checkpoint_max_total_size_mb,
|
||||
checkpoint_max_file_size_mb=self.checkpoint_max_file_size_mb,
|
||||
pass_session_id=self.pass_session_id,
|
||||
skip_context_files=self.ignore_rules,
|
||||
skip_memory=self.ignore_rules,
|
||||
tool_progress_callback=self._on_tool_progress,
|
||||
pass_session_id=self.pass_session_id, skip_context_files=self.ignore_rules,
|
||||
skip_memory=self.ignore_rules, tool_progress_callback=self._on_tool_progress,
|
||||
tool_start_callback=self._on_tool_start if self._inline_diffs_enabled else None,
|
||||
tool_complete_callback=self._on_tool_complete if self._inline_diffs_enabled else None,
|
||||
stream_delta_callback=self._stream_delta if self.streaming_enabled else None,
|
||||
tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None,
|
||||
notice_callback=self._on_notice,
|
||||
notice_clear_callback=self._on_notice_clear,
|
||||
reaction_callback=self._on_reaction,
|
||||
)
|
||||
notice_callback=self._on_notice, notice_clear_callback=self._on_notice_clear,
|
||||
reaction_callback=self._on_reaction)
|
||||
# Reference for atexit memory-provider shutdown: ``_run_cleanup`` in cli.py
|
||||
# reads ``cli._active_agent_ref``, so this MUST write the ``cli`` module's
|
||||
# global — a ``global`` statement here would bind this module's namespace.
|
||||
@@ -520,14 +549,13 @@ class CLIAgentSetupMixin:
|
||||
# warning shows before the first message. Idempotent + fail-open in the helper.
|
||||
try:
|
||||
from agent.credits_tracker import seed_credits_at_session_start
|
||||
|
||||
seed_credits_at_session_start(self.agent)
|
||||
except Exception:
|
||||
pass
|
||||
self._active_agent_route_signature = _route_signature(effective_model, runtime)
|
||||
|
||||
# Force-create DB row on /title intent, then apply title.
|
||||
if self._pending_title and self._session_db and self.agent:
|
||||
if self._pending_title and self._session_db:
|
||||
try:
|
||||
self.agent._ensure_db_session()
|
||||
if self.agent._session_db_created:
|
||||
@@ -535,7 +563,7 @@ class CLIAgentSetupMixin:
|
||||
_cprint(f" Session title applied: {self._pending_title}")
|
||||
self._pending_title = None
|
||||
# else: row creation failed transiently — keep _pending_title for retry
|
||||
except (ValueError, Exception) as e:
|
||||
except Exception as e:
|
||||
_cprint(f" Could not apply pending title: {e}")
|
||||
# Keep _pending_title so it can be retried after row creation succeeds
|
||||
return True
|
||||
@@ -543,7 +571,6 @@ class CLIAgentSetupMixin:
|
||||
console = ChatConsole()
|
||||
console.print(f"[bold red]Failed to initialize agent: {e}[/]")
|
||||
from hermes_constants import partial_update_hint
|
||||
|
||||
for line in partial_update_hint(e):
|
||||
console.print(line)
|
||||
return False
|
||||
@@ -558,23 +585,17 @@ class CLIAgentSetupMixin:
|
||||
return None
|
||||
from cli import logger
|
||||
from hermes_state import SessionResumeTooLargeError
|
||||
|
||||
try:
|
||||
safety_check = getattr(self._session_db, "assert_resume_safe", None)
|
||||
if not callable(safety_check):
|
||||
return None
|
||||
if tip_only:
|
||||
safety_check(self.session_id, tip_only=True)
|
||||
else:
|
||||
safety_check(self.session_id)
|
||||
safety_check(self.session_id, **({"tip_only": True} if tip_only else {}))
|
||||
except SessionResumeTooLargeError as exc:
|
||||
return str(exc)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Resume safety check failed for %s (proceeding without guard): %s",
|
||||
self.session_id, exc,
|
||||
)
|
||||
return None
|
||||
self.session_id, exc)
|
||||
return None
|
||||
|
||||
def _preload_resumed_session(self) -> bool:
|
||||
@@ -584,43 +605,32 @@ class CLIAgentSetupMixin:
|
||||
from cli import _accent_hex
|
||||
if not self._resumed or not self._session_db:
|
||||
return False
|
||||
|
||||
session_meta = self._session_db.get_session(self.session_id)
|
||||
if not session_meta:
|
||||
self._console_print(f"[bold red]Session not found: {self.session_id}[/]")
|
||||
self._console_print("[dim]Use a session ID from a previous CLI run (hermes sessions list).[/]")
|
||||
return False
|
||||
|
||||
resolved_id = _compression_descendant(self._session_db, self.session_id)
|
||||
if resolved_id:
|
||||
self._console_print(
|
||||
session_meta = self._follow_compression_chain(
|
||||
session_meta,
|
||||
lambda rid: self._console_print(
|
||||
f"[dim]Session {self.session_id} was compressed into "
|
||||
f"{resolved_id}; resuming the descendant with your transcript.[/]"
|
||||
)
|
||||
self.session_id = resolved_id
|
||||
session_meta = self._session_db.get_session(self.session_id) or session_meta
|
||||
|
||||
f"{rid}; resuming the descendant with your transcript.[/]"))
|
||||
resume_limit_error = self._resume_history_limit_error()
|
||||
if resume_limit_error:
|
||||
self._resume_history_error = resume_limit_error
|
||||
self._console_print(f"[bold red]Cannot resume session:[/] {resume_limit_error}")
|
||||
return False
|
||||
|
||||
model_history, display_history = self._session_db.get_resume_conversations(self.session_id)
|
||||
restored = model_history
|
||||
restored, display_history = self._session_db.get_resume_conversations(self.session_id)
|
||||
accent_color = _accent_hex()
|
||||
if not restored:
|
||||
self._console_print(
|
||||
f"[{accent_color}]Session {self.session_id} found but has no "
|
||||
f"messages. Starting fresh.[/]"
|
||||
)
|
||||
f"messages. Starting fresh.[/]")
|
||||
return False
|
||||
|
||||
restored = [m for m in restored if m.get("role") != "session_meta"]
|
||||
self.conversation_history = restored
|
||||
self._resume_display_history = [m for m in display_history if m.get("role") != "session_meta"]
|
||||
from agent.context_compressor import is_user_originated_turn
|
||||
|
||||
# Count only user-originated turns: legacy compaction handoffs are durable
|
||||
# role=user rows without display_kind.
|
||||
msg_count = len([m for m in self._resume_display_history if is_user_originated_turn(m)])
|
||||
@@ -629,18 +639,9 @@ class CLIAgentSetupMixin:
|
||||
f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]"
|
||||
f"{title_part} "
|
||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
||||
f"{len(restored)} total messages)[/]"
|
||||
)
|
||||
self._restore_session_cwd(session_meta)
|
||||
self._restore_session_yolo(session_meta)
|
||||
self._restore_session_model(session_meta)
|
||||
|
||||
# Re-open the session (clear ended_at so it's active again)
|
||||
try:
|
||||
self._session_db.reopen_session(self.session_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
f"{len(restored)} total messages)[/]")
|
||||
self._restore_session_state(session_meta)
|
||||
self._reopen_session()
|
||||
return True
|
||||
|
||||
def _display_resumed_history(self):
|
||||
@@ -651,107 +652,22 @@ class CLIAgentSetupMixin:
|
||||
display_history = getattr(self, "_resume_display_history", self.conversation_history)
|
||||
if not display_history or self.resume_display == "minimal":
|
||||
return
|
||||
|
||||
_disp = CLI_CONFIG.get("display", {})
|
||||
MAX_DISPLAY_EXCHANGES = int(_disp.get("resume_exchanges", 10))
|
||||
MAX_USER_LEN = int(_disp.get("resume_max_user_chars", 300))
|
||||
MAX_ASST_LEN = int(_disp.get("resume_max_assistant_chars", 200))
|
||||
MAX_ASST_LINES = int(_disp.get("resume_max_assistant_lines", 3))
|
||||
SKIP_TOOL_ONLY = _disp.get("resume_skip_tool_only", True)
|
||||
|
||||
# Collect displayable (role, text) entries; system and tool-result rows are skipped.
|
||||
entries = []
|
||||
_last_asst_idx = None # index of last assistant entry
|
||||
_last_asst_full = None # un-truncated display text for last assistant
|
||||
for msg in display_history:
|
||||
role = msg.get("role", "")
|
||||
display_kind = msg.get("display_kind")
|
||||
content = msg.get("content")
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
|
||||
if display_kind == "hidden":
|
||||
continue
|
||||
if display_kind in _RESUME_EVENT_TEXT:
|
||||
entries.append(("event", _RESUME_EVENT_TEXT[display_kind]))
|
||||
continue
|
||||
if role in ("system", "tool"):
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
text = "" if content is None else str(content)
|
||||
if isinstance(content, list): # multimodal: text parts + [image] markers
|
||||
text = " ".join(
|
||||
part.get("text", "") if part.get("type") == "text" else "[image]"
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") in ("text", "image_url")
|
||||
)
|
||||
# Stored history is untrusted for display: strip escape sequences/control
|
||||
# chars so replay can't clear the screen, retitle the window or restyle the panel.
|
||||
text = _sanitize_display_text(text)
|
||||
if len(text) > MAX_USER_LEN:
|
||||
text = text[:MAX_USER_LEN] + "..."
|
||||
entries.append(("user", text))
|
||||
|
||||
elif role == "assistant":
|
||||
text = "" if content is None else str(content)
|
||||
text = _sanitize_display_text(_strip_reasoning_tags(text))
|
||||
parts = []
|
||||
full_parts = [] # un-truncated version
|
||||
if text:
|
||||
full_parts.append(text)
|
||||
lines = text.splitlines()
|
||||
if len(lines) > MAX_ASST_LINES:
|
||||
text = "\n".join(lines[:MAX_ASST_LINES]) + " ..."
|
||||
if len(text) > MAX_ASST_LEN:
|
||||
text = text[:MAX_ASST_LEN] + "..."
|
||||
parts.append(text)
|
||||
if tool_calls:
|
||||
names = []
|
||||
for tc in tool_calls:
|
||||
fn = tc.get("function", {})
|
||||
name = fn.get("name", "unknown") if isinstance(fn, dict) else "unknown"
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
names_str = ", ".join(names[:4]) + (", ..." if len(names) > 4 else "")
|
||||
noun = "call" if len(tool_calls) == 1 else "calls"
|
||||
tc_summary = f"[{len(tool_calls)} tool {noun}: {names_str}]"
|
||||
parts.append(tc_summary)
|
||||
full_parts.append(tc_summary)
|
||||
# Skip pure-reasoning messages with no visible output, and tool-call-only
|
||||
# entries when SKIP_TOOL_ONLY is enabled.
|
||||
if not text and (SKIP_TOOL_ONLY or not tool_calls):
|
||||
continue
|
||||
entries.append(("assistant", " ".join(parts)))
|
||||
_last_asst_idx = len(entries) - 1
|
||||
_last_asst_full = " ".join(full_parts)
|
||||
|
||||
entries, _last_asst_idx, _last_asst_full = _collect_resume_entries(
|
||||
display_history, _disp, lambda t: _sanitize_display_text(_strip_reasoning_tags(t)))
|
||||
if not entries:
|
||||
return
|
||||
|
||||
skipped = 0
|
||||
if len(entries) > MAX_DISPLAY_EXCHANGES * 2:
|
||||
skipped = len(entries) - MAX_DISPLAY_EXCHANGES * 2
|
||||
entries = entries[skipped:]
|
||||
|
||||
skipped = max(0, len(entries) - int(_disp.get("resume_exchanges", 10)) * 2)
|
||||
entries = entries[skipped:]
|
||||
# Show the last assistant entry in full so the user sees where they left off.
|
||||
if _last_asst_idx is not None and _last_asst_full:
|
||||
adj_idx = _last_asst_idx - skipped
|
||||
if 0 <= adj_idx < len(entries):
|
||||
entries[adj_idx] = ("assistant_last", _last_asst_full)
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_skin = get_active_skin()
|
||||
_history_text_c, _session_label_c, _session_border_c, _assistant_label_c = (
|
||||
_skin.get_color(key, default) for key, default in _RESUME_SKIN_COLORS
|
||||
)
|
||||
except Exception:
|
||||
_history_text_c, _session_label_c, _session_border_c, _assistant_label_c = (
|
||||
default for _, default in _RESUME_SKIN_COLORS
|
||||
)
|
||||
_history_text_c, _session_label_c, _session_border_c, _assistant_label_c = (
|
||||
_resume_panel_colors())
|
||||
|
||||
# role -> (label, label style, body style, continuation indent)
|
||||
role_styles = {
|
||||
@@ -759,11 +675,9 @@ class CLIAgentSetupMixin:
|
||||
"assistant": (" ◆ Hermes: ", f"dim bold {_assistant_label_c}", "dim", " " * 12),
|
||||
"assistant_last": (" ◆ Hermes: ", f"bold {_assistant_label_c}", "", " " * 12), # full, non-dim
|
||||
}
|
||||
|
||||
lines = Text()
|
||||
if skipped:
|
||||
lines.append(f" ... {skipped} earlier messages ...\n\n", style="dim italic")
|
||||
|
||||
for i, (role, text) in enumerate(entries):
|
||||
if role == "event":
|
||||
lines.append(f" ◈ {text}\n", style="dim italic")
|
||||
@@ -776,14 +690,9 @@ class CLIAgentSetupMixin:
|
||||
lines.append(f"{indent}{ml}\n", style=body_style)
|
||||
if i < len(entries) - 1:
|
||||
lines.append("") # small gap
|
||||
|
||||
panel = Panel(
|
||||
lines,
|
||||
title=f"[dim {_session_label_c}]Previous Conversation[/]",
|
||||
border_style=f"dim {_session_border_c}",
|
||||
padding=(0, 1),
|
||||
style=_history_text_c,
|
||||
)
|
||||
lines, title=f"[dim {_session_label_c}]Previous Conversation[/]",
|
||||
border_style=f"dim {_session_border_c}", padding=(0, 1), style=_history_text_c)
|
||||
_record_output_history_entry(lambda: self._render_resume_history_panel_lines(panel))
|
||||
with _suspend_output_history():
|
||||
self._console_print(panel)
|
||||
|
||||
+218
-380
@@ -1,9 +1,5 @@
|
||||
"""Simple slash-command wrappers plus goal/heartbeat/loop manager hooks for the interactive CLI
|
||||
|
||||
Mixin split out of ``cli.py``; bound onto ``HermesCLI`` via the MRO. cli.py-internal
|
||||
symbols are imported LAZILY inside each method (``from cli import ...``) — the mixin
|
||||
never imports ``cli`` at module load time (import cycle).
|
||||
"""
|
||||
"""Simple slash-command wrappers plus goal/heartbeat/loop manager hooks for the interactive CLI.
|
||||
Mixin on ``HermesCLI``; cli.py symbols are imported lazily inside methods (import cycle)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,6 +11,21 @@ import time
|
||||
|
||||
from rich.markup import escape as _escape
|
||||
|
||||
_FRESH_START = " ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n"
|
||||
|
||||
|
||||
def _preview(payload: str) -> str:
|
||||
return f"{payload[:80]}{'...' if len(payload) > 80 else ''}"
|
||||
|
||||
|
||||
def _print_decision_message(decision: dict) -> bool:
|
||||
"""Print a manager decision's ``message`` (if any) via _cprint; True when one was printed."""
|
||||
from cli import _cprint
|
||||
msg = decision.get("message") or ""
|
||||
if msg:
|
||||
_cprint(f" {msg}")
|
||||
return bool(msg)
|
||||
|
||||
|
||||
class CLILoopsMixin:
|
||||
"""Simple slash-command wrappers plus goal/heartbeat/loop manager hooks for the interactive CLI"""
|
||||
@@ -35,22 +46,16 @@ class CLILoopsMixin:
|
||||
self.show_help(_slash_args(cmd_original))
|
||||
|
||||
def _cmd_redraw(self, cmd_original: str):
|
||||
# Manual recovery for terminal buffer drift from multiplexer
|
||||
# tab switches, subshell ``clear``, SSH window restores, etc.
|
||||
# See issue #8688 (cmux). Ctrl+L is bound to the same helper.
|
||||
# Manual recovery for terminal buffer drift (mux tab switches, subshell ``clear``,
|
||||
# SSH restores; #8688). Ctrl+L is bound to the same helper.
|
||||
from cli import _DIM, _RST, _cprint
|
||||
self._force_full_redraw()
|
||||
_cprint(f" {_DIM}✓ UI redrawn{_RST}")
|
||||
|
||||
def _cmd_clear(self, cmd_original: str):
|
||||
from cli import (
|
||||
ChatConsole,
|
||||
_build_compact_banner,
|
||||
_clear_output_history,
|
||||
_cprint,
|
||||
build_welcome_banner,
|
||||
get_tool_definitions,
|
||||
)
|
||||
ChatConsole, _build_compact_banner, _clear_output_history, _cprint,
|
||||
build_welcome_banner, get_tool_definitions)
|
||||
if self._confirm_destructive_slash(
|
||||
"clear",
|
||||
"This clears the screen and starts a new session.\n"
|
||||
@@ -60,95 +65,44 @@ class CLILoopsMixin:
|
||||
return True # confirmation cancelled — command handled, keep REPL alive
|
||||
self.new_session(silent=True)
|
||||
_clear_output_history()
|
||||
# Clear terminal screen. Inside the TUI, Rich's console.clear()
|
||||
# goes through patch_stdout's StdoutProxy which swallows the
|
||||
# screen-clear escape sequences. Use prompt_toolkit's output
|
||||
# object directly to actually clear the terminal.
|
||||
if self._app:
|
||||
out = self._app.output
|
||||
out.erase_screen()
|
||||
out.cursor_goto(0, 0)
|
||||
out.flush()
|
||||
else:
|
||||
if not self._app:
|
||||
self.console.clear()
|
||||
# Show fresh banner. Inside the TUI we must route Rich output
|
||||
# through ChatConsole (which uses prompt_toolkit's native ANSI
|
||||
# renderer) instead of self.console (which writes raw to stdout
|
||||
# and gets mangled by patch_stdout).
|
||||
if self._app:
|
||||
cc = ChatConsole()
|
||||
term_w = shutil.get_terminal_size().columns
|
||||
if self.compact or term_w < 80:
|
||||
cc.print(_build_compact_banner())
|
||||
else:
|
||||
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
|
||||
cwd = os.getenv("TERMINAL_CWD", os.getcwd())
|
||||
ctx_len = None
|
||||
if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'):
|
||||
ctx_len = self.agent.context_compressor.context_length
|
||||
build_welcome_banner(
|
||||
console=cc,
|
||||
model=self.model,
|
||||
cwd=cwd,
|
||||
tools=tools,
|
||||
enabled_toolsets=self.enabled_toolsets,
|
||||
session_id=self.session_id,
|
||||
context_length=ctx_len,
|
||||
provider=self.provider,
|
||||
)
|
||||
_cprint(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
|
||||
self._print_random_tip()
|
||||
else:
|
||||
self.show_banner()
|
||||
print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
|
||||
print(_FRESH_START)
|
||||
self._print_random_tip()
|
||||
return
|
||||
# Inside the TUI, Rich's console.clear() and self.console both go through
|
||||
# patch_stdout's StdoutProxy, which swallows the clear escapes / mangles raw
|
||||
# output: clear via prompt_toolkit's output and print through ChatConsole.
|
||||
out = self._app.output
|
||||
out.erase_screen()
|
||||
out.cursor_goto(0, 0)
|
||||
out.flush()
|
||||
cc = ChatConsole()
|
||||
if self.compact or shutil.get_terminal_size().columns < 80:
|
||||
cc.print(_build_compact_banner())
|
||||
else:
|
||||
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
|
||||
agent = getattr(self, "agent", None)
|
||||
ctx_len = None
|
||||
if agent and hasattr(agent, "context_compressor"):
|
||||
ctx_len = agent.context_compressor.context_length
|
||||
build_welcome_banner(
|
||||
console=cc, model=self.model, cwd=os.getenv("TERMINAL_CWD", os.getcwd()),
|
||||
tools=tools, enabled_toolsets=self.enabled_toolsets, session_id=self.session_id,
|
||||
context_length=ctx_len, provider=self.provider)
|
||||
_cprint(_FRESH_START)
|
||||
self._print_random_tip()
|
||||
|
||||
def _cmd_title(self, cmd_original: str):
|
||||
from cli import _cprint
|
||||
from hermes_state import format_session_db_unavailable
|
||||
parts = cmd_original.split(maxsplit=1)
|
||||
if len(parts) > 1:
|
||||
raw_title = parts[1].strip()
|
||||
if raw_title:
|
||||
if self._session_db:
|
||||
# Sanitize the title early so feedback matches what gets stored
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
new_title = SessionDB.sanitize_title(raw_title)
|
||||
except ValueError as e:
|
||||
# sanitize_title rejected the input (e.g. too long).
|
||||
# Print that one reason and stop — don't fall
|
||||
# through to the "empty after cleanup" branch and
|
||||
# print a second, contradictory error (SC-05).
|
||||
_cprint(f" {e}")
|
||||
return True
|
||||
if not new_title:
|
||||
_cprint(" Title is empty after cleanup. Please use printable characters.")
|
||||
elif self._session_db.get_session(self.session_id):
|
||||
# Session exists in DB — set title directly
|
||||
try:
|
||||
if self._session_db.set_session_title(self.session_id, new_title):
|
||||
self._status_bar_title_checked_at = 0.0
|
||||
_cprint(f" Session title set: {new_title}")
|
||||
else:
|
||||
_cprint(" Session not found in database.")
|
||||
except ValueError as e:
|
||||
_cprint(f" {e}")
|
||||
else:
|
||||
# Session not created yet — defer the title
|
||||
# Check uniqueness proactively with the sanitized title
|
||||
existing = self._session_db.get_session_by_title(new_title)
|
||||
if existing:
|
||||
_cprint(f" Title '{new_title}' is already in use by session {existing['id']}")
|
||||
else:
|
||||
self._pending_title = new_title
|
||||
_cprint(f" Session title queued: {new_title} (will be saved on first message)")
|
||||
else:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
_cprint(f" {format_session_db_unavailable()}")
|
||||
else:
|
||||
_cprint(" Usage: /title <your session title>")
|
||||
# Show current title and session ID if no argument given
|
||||
elif self._session_db:
|
||||
if len(parts) == 1:
|
||||
# No argument: show current title and session ID.
|
||||
if not self._session_db:
|
||||
_cprint(f" {format_session_db_unavailable()}")
|
||||
return
|
||||
_cprint(f" Session ID: {self.session_id}")
|
||||
session = self._session_db.get_session(self.session_id)
|
||||
if session and session.get("title"):
|
||||
@@ -157,14 +111,46 @@ class CLILoopsMixin:
|
||||
_cprint(f" Title (pending): {self._pending_title}")
|
||||
else:
|
||||
_cprint(" No title set. Usage: /title <your session title>")
|
||||
else:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
return
|
||||
raw_title = parts[1].strip()
|
||||
if not raw_title:
|
||||
_cprint(" Usage: /title <your session title>")
|
||||
return
|
||||
if not self._session_db:
|
||||
_cprint(f" {format_session_db_unavailable()}")
|
||||
return
|
||||
# Sanitize early so feedback matches what gets stored. A rejection (e.g. too
|
||||
# long) prints that one reason and stops — never a second, contradictory
|
||||
# "empty after cleanup" error (SC-05).
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
new_title = SessionDB.sanitize_title(raw_title)
|
||||
except ValueError as e:
|
||||
_cprint(f" {e}")
|
||||
return True
|
||||
if not new_title:
|
||||
_cprint(" Title is empty after cleanup. Please use printable characters.")
|
||||
elif self._session_db.get_session(self.session_id):
|
||||
try:
|
||||
if self._session_db.set_session_title(self.session_id, new_title):
|
||||
self._status_bar_title_checked_at = 0.0
|
||||
_cprint(f" Session title set: {new_title}")
|
||||
else:
|
||||
_cprint(" Session not found in database.")
|
||||
except ValueError as e:
|
||||
_cprint(f" {e}")
|
||||
else:
|
||||
# Session not created yet — check uniqueness now, defer the title.
|
||||
existing = self._session_db.get_session_by_title(new_title)
|
||||
if existing:
|
||||
_cprint(f" Title '{new_title}' is already in use by session {existing['id']}")
|
||||
else:
|
||||
self._pending_title = new_title
|
||||
_cprint(f" Session title queued: {new_title} (will be saved on first message)")
|
||||
|
||||
def _cmd_new(self, cmd_original: str):
|
||||
# Strip inline-skip tokens (now/--yes/-y) before deriving the title
|
||||
# so "/new now My Session" yields title="My Session" instead of
|
||||
# title="now My Session". See _split_destructive_skip.
|
||||
# Strip inline-skip tokens (now/--yes/-y) before deriving the title so
|
||||
# "/new now My Session" yields title="My Session". See _split_destructive_skip.
|
||||
_new_args, _ = self._split_destructive_skip(cmd_original)
|
||||
title = _new_args.strip() or None
|
||||
if self._confirm_destructive_slash(
|
||||
@@ -179,36 +165,27 @@ class CLILoopsMixin:
|
||||
def _cmd_retry(self, cmd_original: str):
|
||||
retry_msg = self.retry_last()
|
||||
if retry_msg and hasattr(self, '_pending_input'):
|
||||
# Re-queue the message so process_loop sends it to the agent
|
||||
self._pending_input.put(retry_msg)
|
||||
self._pending_input.put(retry_msg) # process_loop sends it to the agent
|
||||
|
||||
def _cmd_undo(self, cmd_original: str):
|
||||
# Parse optional turn count: "/undo" → 1, "/undo 3" → 3.
|
||||
# "/undo" → 1, "/undo 3" → 3.
|
||||
_undo_n = 1
|
||||
_undo_parts = cmd_original.split()
|
||||
if len(_undo_parts) > 1:
|
||||
try:
|
||||
_undo_n = int(_undo_parts[1])
|
||||
_undo_n = max(1, int(_undo_parts[1]))
|
||||
except ValueError:
|
||||
print(f"(._.) Invalid count {_undo_parts[1]!r} — use /undo or /undo N.")
|
||||
return True # bad arg — command handled, keep the REPL alive
|
||||
if _undo_n < 1:
|
||||
_undo_n = 1
|
||||
# Nothing to undo → say so immediately; don't pop a destructive
|
||||
# confirmation dialog for a guaranteed no-op (SC-06).
|
||||
# Nothing to undo → say so; no destructive confirmation for a no-op (SC-06).
|
||||
if not self.conversation_history:
|
||||
print("(._.) No messages to undo.")
|
||||
return True
|
||||
_undo_desc = (
|
||||
"This removes the last user/assistant exchange from history."
|
||||
if _undo_n == 1
|
||||
else f"This removes the last {_undo_n} user turns from history."
|
||||
)
|
||||
if self._confirm_destructive_slash(
|
||||
"undo",
|
||||
_undo_desc,
|
||||
cmd_original=cmd_original,
|
||||
) is None:
|
||||
else f"This removes the last {_undo_n} user turns from history.")
|
||||
if self._confirm_destructive_slash("undo", _undo_desc, cmd_original=cmd_original) is None:
|
||||
return True # confirmation cancelled — command handled, keep REPL alive
|
||||
self.undo_last(_undo_n)
|
||||
|
||||
@@ -218,16 +195,12 @@ class CLILoopsMixin:
|
||||
|
||||
def _cmd_egress(self, cmd_original: str):
|
||||
from hermes_cli.slash_exec import CommandContext, execute_command
|
||||
|
||||
self._console_print(
|
||||
execute_command("egress", CommandContext(surface="cli")).text,
|
||||
highlight=False, markup=False,
|
||||
)
|
||||
text = execute_command("egress", CommandContext(surface="cli")).text
|
||||
self._console_print(text, highlight=False, markup=False)
|
||||
|
||||
def _cmd_statusbar(self, cmd_original: str):
|
||||
self._status_bar_visible = not self._status_bar_visible
|
||||
state = "visible" if self._status_bar_visible else "hidden"
|
||||
self._console_print(f" Status bar {state}")
|
||||
self._console_print(f" Status bar {'visible' if self._status_bar_visible else 'hidden'}")
|
||||
|
||||
def _cmd_update(self, cmd_original: str) -> bool:
|
||||
# A truthy result means the process is relaunching — leave the REPL.
|
||||
@@ -235,7 +208,6 @@ class CLILoopsMixin:
|
||||
|
||||
def _cmd_version(self, cmd_original: str):
|
||||
from hermes_cli.main import _print_version_info
|
||||
|
||||
_print_version_info(check_updates=True)
|
||||
|
||||
def _cmd_reload(self, cmd_original: str):
|
||||
@@ -250,66 +222,45 @@ class CLILoopsMixin:
|
||||
def _cmd_plugins(self, cmd_original: str):
|
||||
from cli import display_hermes_home
|
||||
try:
|
||||
# Discover from disk (bundled + user), matching `hermes plugins
|
||||
# list` — so installed-but-not-enabled plugins are visible here
|
||||
# too. The plugin manager only knows about *loaded* plugins, so
|
||||
# using it alone made freshly-installed, not-yet-enabled plugins
|
||||
# look like "nothing installed".
|
||||
# Discover from disk (bundled + user) like `hermes plugins list`, so
|
||||
# installed-but-not-enabled plugins show up; the plugin manager only knows
|
||||
# *loaded* plugins and made fresh installs look like "nothing installed".
|
||||
from hermes_cli.plugins_cmd import (
|
||||
_discover_all_plugins,
|
||||
_get_disabled_set,
|
||||
_get_enabled_set,
|
||||
_plugin_status,
|
||||
)
|
||||
|
||||
_discover_all_plugins, _get_disabled_set, _get_enabled_set, _plugin_status)
|
||||
entries = _discover_all_plugins()
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
|
||||
# `/plugins` is a quick glance — default to user-installed
|
||||
# plugins (what the user actually added). Bundled provider/
|
||||
# platform plugins are summarized on one line; the full
|
||||
# catalog lives behind `hermes plugins list`.
|
||||
# `/plugins` is a quick glance: user plugins only, bundled ones summarized
|
||||
# on one line (full catalog behind `hermes plugins list`).
|
||||
user_entries = [e for e in entries if e[3] != "bundled"]
|
||||
bundled_count = len(entries) - len(user_entries)
|
||||
|
||||
if not user_entries:
|
||||
print("No user plugins installed.")
|
||||
print(" Install one: hermes plugins install owner/repo")
|
||||
print(f" Or drop a plugin directory into {display_hermes_home()}/plugins/")
|
||||
if bundled_count:
|
||||
print(f" ({bundled_count} bundled plugins available — see: hermes plugins list)")
|
||||
else:
|
||||
# Loaded-plugin details (tools/hooks/commands counts, errors)
|
||||
# keyed by name, when available.
|
||||
loaded: dict = {}
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
for p in get_plugin_manager().list_plugins():
|
||||
loaded[p["name"]] = p
|
||||
except Exception:
|
||||
loaded = {}
|
||||
|
||||
print(f"User plugins ({len(user_entries)}):")
|
||||
for name, version, _desc, source, _dir, key in sorted(user_entries):
|
||||
state = _plugin_status(name, enabled, disabled, key=key)
|
||||
glyph = {"enabled": "✓", "disabled": "✗"}.get(state, "○")
|
||||
ver = f" v{version}" if version else ""
|
||||
info = loaded.get(name) or {}
|
||||
bits = []
|
||||
if info.get("tools"):
|
||||
bits.append(f"{info['tools']} tools")
|
||||
if info.get("hooks"):
|
||||
bits.append(f"{info['hooks']} hooks")
|
||||
if info.get("commands"):
|
||||
bits.append(f"{info['commands']} commands")
|
||||
detail = f" ({', '.join(bits)})" if bits else ""
|
||||
label = "" if state == "enabled" else f" [{state}]"
|
||||
error = f" — {info['error']}" if info.get("error") else ""
|
||||
print(f" {glyph} {name}{ver}{label}{detail}{error}")
|
||||
if bundled_count:
|
||||
print(f" (+{bundled_count} bundled — see: hermes plugins list)")
|
||||
print(" Enable/disable: hermes plugins enable/disable <name>")
|
||||
return
|
||||
try: # loaded-plugin details (tools/hooks/commands counts, errors) by name
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
loaded = {p["name"]: p for p in get_plugin_manager().list_plugins()}
|
||||
except Exception:
|
||||
loaded = {}
|
||||
print(f"User plugins ({len(user_entries)}):")
|
||||
for name, version, _desc, source, _dir, key in sorted(user_entries):
|
||||
state = _plugin_status(name, enabled, disabled, key=key)
|
||||
info = loaded.get(name) or {}
|
||||
bits = [f"{info[k]} {k}" for k in ("tools", "hooks", "commands") if info.get(k)]
|
||||
glyph = {"enabled": "✓", "disabled": "✗"}.get(state, "○")
|
||||
ver = f" v{version}" if version else ""
|
||||
detail = f" ({', '.join(bits)})" if bits else ""
|
||||
label = "" if state == "enabled" else f" [{state}]"
|
||||
error = f" — {info['error']}" if info.get("error") else ""
|
||||
print(f" {glyph} {name}{ver}{label}{detail}{error}")
|
||||
if bundled_count:
|
||||
print(f" (+{bundled_count} bundled — see: hermes plugins list)")
|
||||
print(" Enable/disable: hermes plugins enable/disable <name>")
|
||||
except Exception as e:
|
||||
print(f"Plugin system error: {e}")
|
||||
|
||||
@@ -320,17 +271,13 @@ class CLILoopsMixin:
|
||||
_cprint(" Usage: /queue <prompt>")
|
||||
else:
|
||||
self._pending_input.put(payload)
|
||||
if self._agent_running:
|
||||
_cprint(f" Queued for the next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}")
|
||||
else:
|
||||
_cprint(f" Queued: {payload[:80]}{'...' if len(payload) > 80 else ''}")
|
||||
when = " for the next turn" if self._agent_running else ""
|
||||
_cprint(f" Queued{when}: {_preview(payload)}")
|
||||
|
||||
def _cmd_steer(self, cmd_original: str):
|
||||
# Inject a message after the next tool call without interrupting.
|
||||
# If the agent is actively running, push the text into the agent's
|
||||
# pending_steer slot — the drain hook in _execute_tool_calls_*
|
||||
# will append it to the next tool result's content. If no agent
|
||||
# is running, fall back to queue semantics (same as /queue).
|
||||
# Inject a message after the next tool call without interrupting: while the
|
||||
# agent runs, push into its pending_steer slot (drained by _execute_tool_calls_*
|
||||
# into the next tool result); otherwise fall back to /queue semantics.
|
||||
from cli import _cprint, _slash_args
|
||||
payload = _slash_args(cmd_original)
|
||||
if not payload:
|
||||
@@ -342,88 +289,76 @@ class CLILoopsMixin:
|
||||
_cprint(f" Steer failed: {exc}")
|
||||
else:
|
||||
if accepted:
|
||||
_cprint(f" ⏩ Steer queued — arrives after the next tool call: {payload[:80]}{'...' if len(payload) > 80 else ''}")
|
||||
_cprint(f" ⏩ Steer queued — arrives after the next tool call: {_preview(payload)}")
|
||||
else:
|
||||
_cprint(" Steer rejected (empty payload).")
|
||||
else:
|
||||
# No active run — treat as a normal next-turn message.
|
||||
self._pending_input.put(payload)
|
||||
_cprint(f" No agent running; queued as next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}")
|
||||
_cprint(f" No agent running; queued as next turn: {_preview(payload)}")
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# /goal — persistent cross-turn goals (Ralph-style loop)
|
||||
# Session-bound managers: /goal (Ralph-style loop), /heartbeat, /loop
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
def _get_goal_manager(self):
|
||||
"""Return the GoalManager bound to the current session_id.
|
||||
def _session_bound_manager(self, attr: str, label: str, load):
|
||||
"""Return the manager cached on ``self.<attr>``, rebuilt when ``session_id`` changed
|
||||
(after /new or a compression-driven session split).
|
||||
|
||||
Cached on ``self._goal_manager`` and rebound lazily when
|
||||
``session_id`` changes (e.g. after /new or a compression-driven
|
||||
session split).
|
||||
``load()`` does the imports and returns a ``sid -> manager`` factory; an import
|
||||
failure is logged and yields None, as does an empty session_id.
|
||||
"""
|
||||
try:
|
||||
make = load()
|
||||
except Exception as exc:
|
||||
logging.debug("%s unavailable: %s", label, exc)
|
||||
return None
|
||||
sid = getattr(self, "session_id", None) or ""
|
||||
if not sid:
|
||||
return None
|
||||
existing = getattr(self, attr, None)
|
||||
if existing is not None and getattr(existing, "session_id", None) == sid:
|
||||
return existing
|
||||
mgr = make(sid)
|
||||
setattr(self, attr, mgr)
|
||||
return mgr
|
||||
|
||||
def _get_goal_manager(self):
|
||||
"""GoalManager bound to the current session_id (see ``_session_bound_manager``)."""
|
||||
def load():
|
||||
from hermes_cli.goals import GoalManager
|
||||
from hermes_cli.config import load_config
|
||||
except Exception as exc:
|
||||
logging.debug("goal manager unavailable: %s", exc)
|
||||
return None
|
||||
|
||||
sid = getattr(self, "session_id", None) or ""
|
||||
if not sid:
|
||||
return None
|
||||
|
||||
existing = getattr(self, "_goal_manager", None)
|
||||
if existing is not None and getattr(existing, "session_id", None) == sid:
|
||||
return existing
|
||||
|
||||
try:
|
||||
cfg = load_config() or {}
|
||||
goals_cfg = cfg.get("goals") or {}
|
||||
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
|
||||
except Exception:
|
||||
max_turns = 20
|
||||
|
||||
mgr = GoalManager(session_id=sid, default_max_turns=max_turns)
|
||||
self._goal_manager = mgr
|
||||
return mgr
|
||||
def make(sid):
|
||||
try:
|
||||
goals_cfg = (load_config() or {}).get("goals") or {}
|
||||
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
|
||||
except Exception:
|
||||
max_turns = 20
|
||||
return GoalManager(session_id=sid, default_max_turns=max_turns)
|
||||
return make
|
||||
return self._session_bound_manager("_goal_manager", "goal manager", load)
|
||||
|
||||
def _get_heartbeat_manager(self):
|
||||
"""Return the HeartbeatManager bound to the current session_id.
|
||||
|
||||
Cached on ``self._heartbeat_manager`` and rebound lazily when
|
||||
``session_id`` changes (mirrors ``_get_goal_manager``).
|
||||
"""
|
||||
try:
|
||||
"""HeartbeatManager bound to the current session_id (see ``_session_bound_manager``)."""
|
||||
def load():
|
||||
from hermes_cli.heartbeat import HeartbeatManager
|
||||
except Exception as exc:
|
||||
logging.debug("heartbeat manager unavailable: %s", exc)
|
||||
return None
|
||||
return lambda sid: HeartbeatManager(session_id=sid)
|
||||
return self._session_bound_manager("_heartbeat_manager", "heartbeat manager", load)
|
||||
|
||||
sid = getattr(self, "session_id", None) or ""
|
||||
if not sid:
|
||||
return None
|
||||
|
||||
existing = getattr(self, "_heartbeat_manager", None)
|
||||
if existing is not None and getattr(existing, "session_id", None) == sid:
|
||||
return existing
|
||||
|
||||
mgr = HeartbeatManager(session_id=sid)
|
||||
self._heartbeat_manager = mgr
|
||||
return mgr
|
||||
def _get_loop_manager(self):
|
||||
"""LoopManager bound to the current session_id (see ``_session_bound_manager``)."""
|
||||
def load():
|
||||
from hermes_cli.loops import LoopManager
|
||||
return lambda sid: LoopManager(session_id=sid)
|
||||
return self._session_bound_manager("_loop_manager", "loop manager", load)
|
||||
|
||||
def _start_heartbeat_watchdog(self):
|
||||
"""Start the idle-poll thread that fires due heartbeats.
|
||||
|
||||
Same pattern as the wake-word watchdog: a daemon thread polls a few
|
||||
times a minute; when the session is idle (no agent running, empty
|
||||
input queue) and the heartbeat is due, its prompt is injected into
|
||||
``_pending_input`` as a normal user turn. Missed ticks coalesce —
|
||||
the anchor resets on fire, so a busy hour yields ONE heartbeat turn,
|
||||
not a backlog. Idempotent; safe to call on every /heartbeat set.
|
||||
"""
|
||||
"""Start the idle-poll daemon that injects a due heartbeat prompt into
|
||||
``_pending_input`` as a normal user turn when the session is idle. Missed ticks
|
||||
coalesce (the anchor resets on fire, so a busy hour yields ONE heartbeat turn).
|
||||
Idempotent; safe to call on every /heartbeat set."""
|
||||
if getattr(self, "_heartbeat_watchdog_started", False):
|
||||
return
|
||||
self._heartbeat_watchdog_started = True
|
||||
|
||||
from hermes_cli.heartbeat import POLL_SECONDS
|
||||
|
||||
def _loop():
|
||||
@@ -438,8 +373,7 @@ class CLILoopsMixin:
|
||||
self._agent_running
|
||||
or getattr(self, "_voice_recording", False)
|
||||
or getattr(self, "_voice_processing", False)
|
||||
or not self._pending_input.empty()
|
||||
)
|
||||
or not self._pending_input.empty())
|
||||
if busy:
|
||||
continue
|
||||
prompt = mgr.due_prompt()
|
||||
@@ -449,57 +383,25 @@ class CLILoopsMixin:
|
||||
logging.debug("heartbeat watchdog tick failed: %s", exc)
|
||||
finally:
|
||||
self._heartbeat_watchdog_started = False
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="heartbeat-watchdog").start()
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# /loop — recurring in-session wakeups (Claude Code /loop parity)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
def _get_loop_manager(self):
|
||||
"""Return the LoopManager bound to the current session_id.
|
||||
|
||||
Cached on ``self._loop_manager`` and rebound lazily when
|
||||
``session_id`` changes (mirrors ``_get_goal_manager``).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.loops import LoopManager
|
||||
except Exception as exc:
|
||||
logging.debug("loop manager unavailable: %s", exc)
|
||||
return None
|
||||
|
||||
sid = getattr(self, "session_id", None) or ""
|
||||
if not sid:
|
||||
return None
|
||||
|
||||
existing = getattr(self, "_loop_manager", None)
|
||||
if existing is not None and getattr(existing, "session_id", None) == sid:
|
||||
return existing
|
||||
|
||||
mgr = LoopManager(session_id=sid)
|
||||
self._loop_manager = mgr
|
||||
return mgr
|
||||
|
||||
def _maybe_fire_loop_tick(self) -> None:
|
||||
"""Idle hook run from process_loop: fire a due /loop wakeup.
|
||||
|
||||
Only runs while the agent is idle and nothing is queued — a real
|
||||
user message always wins the idle boundary. An active (non-parked)
|
||||
/goal also wins: its judge-driven continuations own the idle
|
||||
boundary, so the loop defers to the next poll.
|
||||
Only while the agent is idle and nothing is queued — a real user message always
|
||||
wins the idle boundary, and so does an active (non-parked) /goal, whose
|
||||
judge-driven continuations own it; the loop defers to the next poll.
|
||||
"""
|
||||
from cli import _DIM, _RST, _cprint
|
||||
mgr = self._get_loop_manager()
|
||||
if mgr is None or not mgr.is_due():
|
||||
return
|
||||
# The idle poll runs at ~10 Hz; once a tick is due but deferred
|
||||
# (queued input / active goal), every poll would otherwise hit the
|
||||
# DB via goal_blocks_loop_tick. Throttle the deferred re-check.
|
||||
# The idle poll runs at ~10 Hz; a due-but-deferred tick would otherwise hit the
|
||||
# DB (goal_blocks_loop_tick) on every poll. Throttle the re-check.
|
||||
now = time.time()
|
||||
if now - getattr(self, "_last_loop_tick_check", 0.0) < 2.0:
|
||||
return
|
||||
self._last_loop_tick_check = now
|
||||
# Real user input (or anything else queued) takes priority; the
|
||||
# loop stays due and fires at the next idle poll.
|
||||
try:
|
||||
if not self._pending_input.empty():
|
||||
return
|
||||
@@ -507,12 +409,10 @@ class CLILoopsMixin:
|
||||
return
|
||||
try:
|
||||
from hermes_cli.loops import goal_blocks_loop_tick
|
||||
|
||||
if goal_blocks_loop_tick(mgr.session_id):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
wakeup = mgr.fire_tick()
|
||||
if not wakeup:
|
||||
return
|
||||
@@ -528,33 +428,27 @@ class CLILoopsMixin:
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
# A slash-command loop (e.g. `/loop 10m /recap`) is dispatched via
|
||||
# process_command, which never reaches the post-turn chat() finally
|
||||
# block — so the tick would never complete and the loop would wedge
|
||||
# on awaiting_response. Slash ticks have no model reply to evaluate;
|
||||
# complete them immediately (caps and scheduling still apply).
|
||||
# A slash-command loop (`/loop 10m /recap`) is dispatched via process_command and
|
||||
# never reaches chat()'s post-turn finally, so its tick would never complete and
|
||||
# the loop would wedge on awaiting_response. Slash ticks have no model reply to
|
||||
# judge; complete them immediately (caps and scheduling still apply).
|
||||
if wakeup.lstrip().startswith("/"):
|
||||
try:
|
||||
decision = mgr.complete_tick("")
|
||||
msg = decision.get("message") or ""
|
||||
if msg:
|
||||
_cprint(f" {msg}")
|
||||
_print_decision_message(mgr.complete_tick(""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _last_assistant_response_text(self) -> str:
|
||||
"""Text of the most recent assistant message ("" when none); multimodal parts are flattened."""
|
||||
try:
|
||||
hist = self.conversation_history or []
|
||||
for msg in reversed(hist):
|
||||
for msg in reversed(self.conversation_history or []):
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
parts = [
|
||||
p.get("text", "")
|
||||
for p in content
|
||||
if isinstance(p, dict) and p.get("type") in {"text", "output_text"}
|
||||
]
|
||||
if isinstance(p, dict) and p.get("type") in {"text", "output_text"}]
|
||||
return "\n".join(t for t in parts if t)
|
||||
return str(content or "")
|
||||
except Exception:
|
||||
@@ -564,10 +458,9 @@ class CLILoopsMixin:
|
||||
def _maybe_complete_loop_tick_after_turn(self) -> None:
|
||||
"""Post-turn hook: evaluate a finished /loop wakeup turn.
|
||||
|
||||
No-op unless the turn that just ended was a loop wakeup
|
||||
(``awaiting_response`` set by ``fire_tick``). Detects the
|
||||
LOOP_COMPLETE marker, judges --until, applies caps, and schedules
|
||||
the next tick. Mirrors _maybe_continue_goal_after_turn's shape.
|
||||
No-op unless the turn was a loop wakeup (``awaiting_response`` set by
|
||||
``fire_tick``). Detects the LOOP_COMPLETE marker, judges --until, applies caps,
|
||||
and schedules the next tick. Mirrors _maybe_continue_goal_after_turn's shape.
|
||||
"""
|
||||
from cli import _DIM, _RST, _cprint
|
||||
mgr = self._get_loop_manager()
|
||||
@@ -577,8 +470,8 @@ class CLILoopsMixin:
|
||||
if state is None or not state.awaiting_response:
|
||||
return
|
||||
|
||||
# A user-interrupted wakeup turn pauses the loop (recoverable via
|
||||
# /loop resume) — same contract as the goal loop's Ctrl+C handling.
|
||||
# A user-interrupted wakeup turn pauses the loop (recoverable via /loop resume)
|
||||
# — same contract as the goal loop's Ctrl+C handling.
|
||||
if getattr(self, "_last_turn_interrupted", False):
|
||||
try:
|
||||
mgr.pause(reason="user-interrupted (Ctrl+C)")
|
||||
@@ -586,84 +479,42 @@ class CLILoopsMixin:
|
||||
pass
|
||||
_cprint(
|
||||
f" {_DIM}⏸ Loop paused — wakeup turn was interrupted. "
|
||||
f"Use /loop resume to continue, or /loop stop to end it.{_RST}"
|
||||
)
|
||||
f"Use /loop resume to continue, or /loop stop to end it.{_RST}")
|
||||
return
|
||||
|
||||
last_response = self._last_assistant_response_text()
|
||||
|
||||
decision = mgr.complete_tick(last_response)
|
||||
msg = decision.get("message") or ""
|
||||
if msg:
|
||||
_cprint(f" {msg}")
|
||||
elif decision.get("status") == "active" and mgr.state is not None:
|
||||
decision = mgr.complete_tick(self._last_assistant_response_text())
|
||||
if (not _print_decision_message(decision) and decision.get("status") == "active"
|
||||
and mgr.state is not None):
|
||||
_cprint(f" {_DIM}↻ Loop: {mgr.state.remaining_label()}.{_RST}")
|
||||
|
||||
def _maybe_continue_goal_after_turn(self) -> None:
|
||||
"""Hook run after every CLI turn. Judges + maybe re-queues.
|
||||
|
||||
Safe to call when no goal is set — returns quickly.
|
||||
|
||||
Preemption is automatic: if a real user message is already in
|
||||
``_pending_input`` we skip judging (the user's new input takes
|
||||
priority and we'll re-judge after that turn). If judge says done,
|
||||
mark it done and tell the user. If judge says continue and we're
|
||||
under budget, push the continuation prompt onto the queue.
|
||||
|
||||
Interrupt handling: if the turn was user-cancelled (Ctrl+C), we
|
||||
AUTO-PAUSE the goal instead of judging + re-queuing. Otherwise
|
||||
Ctrl+C feels like it did nothing — the judge runs on whatever
|
||||
partial output landed, almost always says "continue", and the
|
||||
loop keeps going. Auto-pause keeps the goal recoverable via
|
||||
``/goal resume`` once the user has sorted out what they want.
|
||||
The empty-response skip mirrors the gateway guard at
|
||||
``_handle_message`` in ``gateway/run.py``.
|
||||
"""
|
||||
"""Post-turn hook: judge the goal and maybe re-queue a continuation. A real user
|
||||
message already queued preempts judging (re-judged after their turn). Ctrl+C
|
||||
AUTO-PAUSES instead of judging — the judge on partial output nearly always says
|
||||
"continue" and would re-queue exactly what was cancelled; pausing is recoverable
|
||||
via ``/goal resume``. Empty-response skip mirrors ``gateway/run.py``."""
|
||||
from cli import _DIM, _RST, _cprint, _looks_like_slash_command
|
||||
mgr = self._get_goal_manager()
|
||||
if mgr is None or not mgr.is_active():
|
||||
return
|
||||
|
||||
# If a real user message is already queued, don't inject a
|
||||
# continuation prompt on top — let the user's turn go first.
|
||||
# Slash commands don't count as "real user messages" for this
|
||||
# check: they're inspection/mutation (e.g. /subgoal added mid-
|
||||
# run) and the process_loop dispatches them via process_command,
|
||||
# not via chat(). If we treat a queued /subgoal as preempting,
|
||||
# the goal loop silently stalls — we'd return here, then the
|
||||
# slash command consumes its queue slot via process_command()
|
||||
# which never re-fires the goal hook. Peek at all queued entries
|
||||
# and only defer when there's a non-slash payload.
|
||||
# Slash commands don't count as "real user messages": they're dispatched via
|
||||
# process_command, not chat(), so a queued /subgoal would consume its slot without
|
||||
# ever re-firing this hook and the goal loop would silently stall. Peek at every
|
||||
# queued entry (Queue.queue is the deque; FIFO undisturbed) and defer only on a
|
||||
# non-slash payload. Bundled payloads are (text, images) tuples.
|
||||
try:
|
||||
pending = getattr(self, "_pending_input", None)
|
||||
if pending is not None and not pending.empty():
|
||||
has_real_message = False
|
||||
try:
|
||||
# Queue.queue is the underlying deque — direct peek
|
||||
# without disturbing FIFO order.
|
||||
for entry in list(pending.queue):
|
||||
# Bundled payloads are (text, images) tuples;
|
||||
# unpack for inspection.
|
||||
if isinstance(entry, tuple) and entry:
|
||||
entry = entry[0]
|
||||
if isinstance(entry, str) and _looks_like_slash_command(entry):
|
||||
continue
|
||||
has_real_message = True
|
||||
break
|
||||
entries = [e[0] if isinstance(e, tuple) and e else e for e in list(pending.queue)]
|
||||
has_real_message = any(
|
||||
not (isinstance(e, str) and _looks_like_slash_command(e)) for e in entries)
|
||||
except Exception:
|
||||
# Fallback: if we can't introspect the queue, behave
|
||||
# like the old check and defer to be safe.
|
||||
has_real_message = True
|
||||
has_real_message = True # can't introspect — defer to be safe
|
||||
if has_real_message:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If the turn was user-interrupted (Ctrl+C), auto-pause the goal
|
||||
# and bail. The judge call would almost always return "continue"
|
||||
# on the partial output and immediately re-queue another turn,
|
||||
# which is exactly what the user cancelled. Pausing (rather than
|
||||
# silently skipping) is the observable, recoverable behavior.
|
||||
if getattr(self, "_last_turn_interrupted", False):
|
||||
try:
|
||||
mgr.pause(reason="user-interrupted (Ctrl+C)")
|
||||
@@ -671,35 +522,22 @@ class CLILoopsMixin:
|
||||
logging.debug("goal pause-on-interrupt failed: %s", exc)
|
||||
_cprint(
|
||||
f" {_DIM}⏸ Goal paused — turn was interrupted. "
|
||||
f"Use /goal resume to continue, or /goal clear to stop.{_RST}"
|
||||
)
|
||||
f"Use /goal resume to continue, or /goal clear to stop.{_RST}")
|
||||
return
|
||||
|
||||
# Extract the agent's final response for this turn.
|
||||
# Empty/whitespace responses are almost always transient failures (API error,
|
||||
# empty stream): judging would say "continue" and trip the parse-failure backstop.
|
||||
last_response = self._last_assistant_response_text()
|
||||
|
||||
# Skip judging on empty/whitespace-only responses. These are almost
|
||||
# always transient failures (API error, empty stream) where the
|
||||
# judge would say "continue" and trip the consecutive-parse-failures
|
||||
# backstop unnecessarily. Mirrors the gateway guard.
|
||||
if not last_response.strip():
|
||||
return
|
||||
|
||||
try:
|
||||
from hermes_cli.goals import gather_background_processes as _gather_bg
|
||||
_bg_procs = _gather_bg()
|
||||
except Exception:
|
||||
_bg_procs = None
|
||||
|
||||
decision = mgr.evaluate_after_turn(
|
||||
last_response,
|
||||
user_initiated=True,
|
||||
background_processes=_bg_procs,
|
||||
)
|
||||
msg = decision.get("message") or ""
|
||||
if msg:
|
||||
_cprint(f" {msg}")
|
||||
|
||||
last_response, user_initiated=True, background_processes=_bg_procs)
|
||||
_print_decision_message(decision)
|
||||
if decision.get("should_continue"):
|
||||
prompt = decision.get("continuation_prompt")
|
||||
if prompt:
|
||||
|
||||
+161
-370
@@ -1,9 +1,5 @@
|
||||
"""Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive CLI
|
||||
|
||||
Mixin split out of ``cli.py``; bound onto ``HermesCLI`` via the MRO. cli.py-internal
|
||||
symbols are imported LAZILY inside each method (``from cli import ...``) — the mixin
|
||||
never imports ``cli`` at module load time (import cycle).
|
||||
"""
|
||||
"""Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive
|
||||
CLI. Mixin on ``HermesCLI``; cli.py symbols are imported lazily inside methods (import cycle)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,6 +14,39 @@ import time
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
def _is_eio(exc: BaseException) -> bool:
|
||||
return getattr(exc, "errno", None) == errno.EIO
|
||||
|
||||
|
||||
def _run_on_app_loop(app, fn) -> None:
|
||||
"""Run *fn* on the app's asyncio loop when one exists, else inline (fail-open)."""
|
||||
try:
|
||||
loop = getattr(app, "loop", None)
|
||||
except Exception:
|
||||
loop = None
|
||||
if loop is not None:
|
||||
try:
|
||||
loop.call_soon_threadsafe(fn)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
fn()
|
||||
|
||||
|
||||
def _write_terminal_sequence(app, seq: str) -> None:
|
||||
"""Write a raw escape *seq* via the app output (write_raw > write) or stdout."""
|
||||
output = getattr(app, "output", None) if app else None
|
||||
if output and hasattr(output, "write_raw"):
|
||||
output.write_raw(seq)
|
||||
output.flush()
|
||||
elif output and hasattr(output, "write"):
|
||||
output.write(seq)
|
||||
output.flush()
|
||||
else:
|
||||
sys.stdout.write(seq)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
class CLITerminalMixin:
|
||||
"""Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive CLI"""
|
||||
|
||||
@@ -33,80 +62,58 @@ class CLITerminalMixin:
|
||||
pass
|
||||
logger.warning(
|
||||
"Terminal I/O broken%s — freezing UI paints to avoid redraw storm (#81521)",
|
||||
f" ({reason})" if reason else "",
|
||||
)
|
||||
f" ({reason})" if reason else "")
|
||||
|
||||
def _app_invalidate(self, app, where: str, *, swallow: bool) -> None:
|
||||
"""``app.invalidate()``: EIO freezes paints, other OSErrors re-raise, and any
|
||||
other exception re-raises unless *swallow*."""
|
||||
try:
|
||||
app.invalidate()
|
||||
except OSError as exc:
|
||||
if _is_eio(exc):
|
||||
self._mark_terminal_io_broken(where)
|
||||
return
|
||||
raise
|
||||
except Exception:
|
||||
if not swallow:
|
||||
raise
|
||||
|
||||
def _invalidate(self, min_interval: float = 0.25) -> None:
|
||||
"""Throttled UI repaint for high-frequency background updates.
|
||||
"""Throttled UI repaint for high-frequency background updates (spinner frames,
|
||||
streaming flushes): the throttle prevents blinking on slow/SSH links and the
|
||||
resize-recovery guard keeps footer chrome out of scrollback mid-SIGWINCH.
|
||||
|
||||
Use this for spinner frames, streaming token flushes, and other
|
||||
repaints that can fire many times per second — the throttle prevents
|
||||
terminal blinking on slow/SSH connections, and the resize-recovery
|
||||
guard avoids stamping footer/status-bar chrome into scrollback while a
|
||||
SIGWINCH reflow is in flight.
|
||||
|
||||
Do NOT use this for user-blocking modal prompts (approval / clarify /
|
||||
sudo). Those are rare, one-shot, user-blocking events that must paint
|
||||
immediately; route them through ``self._app.invalidate()`` directly, the
|
||||
same way the modal key-binding handlers already do. Sending a modal's
|
||||
entry paint through this throttle lets an unrelated background repaint
|
||||
within the 250ms window — or an in-flight resize — silently drop it, so
|
||||
the prompt never renders and times out unseen (#41098).
|
||||
NOT for user-blocking modals (approval / clarify / sudo) — use ``_paint_now``: a
|
||||
throttled or resize-gated entry paint is silently dropped, so the prompt never
|
||||
renders and times out unseen (#41098).
|
||||
"""
|
||||
if getattr(self, "_terminal_io_broken", False):
|
||||
return
|
||||
if getattr(self, "_resize_recovery_pending", False):
|
||||
if getattr(self, "_terminal_io_broken", False) or getattr(self, "_resize_recovery_pending", False):
|
||||
return
|
||||
now = time.monotonic()
|
||||
if hasattr(self, "_app") and self._app and (now - getattr(self, "_last_invalidate", 0.0)) >= min_interval:
|
||||
self._last_invalidate = now
|
||||
try:
|
||||
self._app.invalidate()
|
||||
except OSError as exc:
|
||||
if getattr(exc, "errno", None) == errno.EIO:
|
||||
self._mark_terminal_io_broken("invalidate")
|
||||
return
|
||||
raise
|
||||
self._app_invalidate(self._app, "invalidate", swallow=False)
|
||||
|
||||
def _paint_now(self) -> None:
|
||||
"""Immediate, unthrottled repaint for user-blocking modal prompts.
|
||||
|
||||
Background-thread callbacks (approval / clarify / sudo) set their modal
|
||||
state then call this to make the panel visible at once. It deliberately
|
||||
bypasses the ``_invalidate`` throttle and resize-recovery guard — a
|
||||
modal the user is actively waiting on must never be dropped — mirroring
|
||||
the direct ``event.app.invalidate()`` the modal key-binding handlers
|
||||
already use. See ``_invalidate`` for why the throttle must not gate
|
||||
these paints (#41098).
|
||||
Deliberately bypasses the ``_invalidate`` throttle and resize-recovery guard —
|
||||
a modal the user is waiting on must never be dropped (#41098) — mirroring the
|
||||
direct ``event.app.invalidate()`` the modal key-binding handlers use.
|
||||
"""
|
||||
if getattr(self, "_terminal_io_broken", False):
|
||||
return
|
||||
app = getattr(self, "_app", None)
|
||||
if app is not None:
|
||||
try:
|
||||
app.invalidate()
|
||||
except OSError as exc:
|
||||
if getattr(exc, "errno", None) == errno.EIO:
|
||||
self._mark_terminal_io_broken("paint_now")
|
||||
return
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
self._app_invalidate(app, "paint_now", swallow=True)
|
||||
|
||||
def _force_full_redraw(self) -> None:
|
||||
"""Force a clean full-screen repaint of the prompt_toolkit UI.
|
||||
"""Force a clean full-screen repaint of the prompt_toolkit UI (Ctrl+L, ``/redraw``).
|
||||
|
||||
Used to recover from terminal buffer drift caused by external
|
||||
redraws we can't detect — e.g. macOS cmux / tmux tab switches,
|
||||
``clear`` issued from a subshell, or SSH window restores. These
|
||||
wipe or repaint the terminal without firing SIGWINCH, so
|
||||
prompt_toolkit's tracked ``_cursor_pos`` no longer matches reality
|
||||
and the next incremental redraw stacks on top of stale content
|
||||
(ghost status bars, duplicated prompts).
|
||||
|
||||
Bound to Ctrl+L and exposed as the ``/redraw`` slash command,
|
||||
matching the standard terminal-UX convention (bash, zsh, fish,
|
||||
vim, htop).
|
||||
Recovers from terminal buffer drift caused by external redraws we can't detect
|
||||
(cmux/tmux tab switches, ``clear`` from a subshell, SSH window restores): they
|
||||
repaint without SIGWINCH, so prompt_toolkit's tracked ``_cursor_pos`` is stale
|
||||
and the next incremental redraw stacks on old content (ghost status bars).
|
||||
"""
|
||||
from cli import _replay_output_history
|
||||
if getattr(self, "_terminal_io_broken", False):
|
||||
@@ -114,88 +121,48 @@ class CLITerminalMixin:
|
||||
app = getattr(self, "_app", None)
|
||||
if not app:
|
||||
return
|
||||
self._clear_prompt_toolkit_screen(
|
||||
app,
|
||||
rebuild_scrollback=self._redraw_rebuilds_scrollback(),
|
||||
)
|
||||
self._clear_prompt_toolkit_screen(app, rebuild_scrollback=self._redraw_rebuilds_scrollback())
|
||||
if getattr(self, "_terminal_io_broken", False):
|
||||
return
|
||||
_replay_output_history()
|
||||
self._pet_queue_kitty_frame()
|
||||
try:
|
||||
app.invalidate()
|
||||
except OSError as exc:
|
||||
if getattr(exc, "errno", None) == errno.EIO:
|
||||
self._mark_terminal_io_broken("force_full_redraw")
|
||||
return
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
self._app_invalidate(app, "force_full_redraw", swallow=True)
|
||||
|
||||
def _schedule_focus_regain_redraw(self, min_interval: float = 1.0) -> None:
|
||||
"""Repaint after a terminal focus-in report (``CSI I``), rate-limited.
|
||||
|
||||
Terminals with focus tracking active (Ghostty, iTerm2, xterm builds,
|
||||
multiplexers that toggle DECSET 1004 upstream) emit ``\\x1b[I`` when
|
||||
the Hermes tab/window becomes visible again. Emulators can coalesce
|
||||
or drop hidden-tab output and repaint the surface while we're
|
||||
invisible, so on regain prompt_toolkit's incremental diff stacks on
|
||||
stale content — a second copy of the composer/prompt chrome next to
|
||||
the ghost of the old one (#60920 focus-regain variant, #25337).
|
||||
|
||||
The stock handling maps ``CSI I``/``CSI O`` to ``Keys.Ignore`` so the
|
||||
bytes never pollute the input buffer; this hook additionally routes
|
||||
focus-in through the same recovery as Ctrl+L / ``/redraw``. It is
|
||||
self-gating: terminals that never enable focus tracking never emit
|
||||
the sequence, so nothing changes for them. Rate-limited so a burst of
|
||||
focus reports (rapid Alt+Tab, mux pane hops) repaints at most once
|
||||
per ``min_interval`` seconds.
|
||||
"""Repaint after a terminal focus-in report (``CSI I``), at most once per
|
||||
``min_interval`` (rapid Alt+Tab / pane-hop bursts). Emulators with focus tracking
|
||||
(DECSET 1004) may coalesce hidden-tab output, so on regain the incremental diff
|
||||
stacks on stale content (#60920, #25337); terminals without it never emit ``CSI I``.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
last = getattr(self, "_last_focus_regain_redraw", 0.0)
|
||||
if now - last < min_interval:
|
||||
if now - getattr(self, "_last_focus_regain_redraw", 0.0) < min_interval:
|
||||
return
|
||||
self._last_focus_regain_redraw = now
|
||||
self._force_full_redraw()
|
||||
|
||||
@staticmethod
|
||||
def _redraw_rebuilds_scrollback() -> bool:
|
||||
"""Return whether CLI redraw/resize recovery should clear scrollback.
|
||||
"""Whether redraw/resize recovery should also clear scrollback (CSI 3J).
|
||||
|
||||
Some terminal/tmux stacks move prompt_toolkit's non-fullscreen bottom
|
||||
chrome into scrollback when the window is maximized/restored. A normal
|
||||
CSI 2J viewport clear cannot remove those stale prompt/input-rule rows,
|
||||
so users who hit that class of bug need CSI 3J as well, followed by the
|
||||
existing bounded output-history replay.
|
||||
Some terminal/tmux stacks move prompt_toolkit's bottom chrome into scrollback on
|
||||
maximize/restore; CSI 2J cannot remove those rows, so affected users opt in to 3J
|
||||
followed by the bounded output-history replay.
|
||||
"""
|
||||
from cli import CLI_CONFIG
|
||||
display_config = CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else {}
|
||||
if not isinstance(display_config, dict):
|
||||
display_config = {}
|
||||
raw = display_config.get("cli_rebuild_scrollback_on_redraw", False)
|
||||
raw = (display_config.get("cli_rebuild_scrollback_on_redraw", False)
|
||||
if isinstance(display_config, dict) else False)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on", "always"}
|
||||
return bool(raw)
|
||||
|
||||
def _recover_terminal_after_interrupt(self) -> None:
|
||||
"""Recover the terminal after an interrupted agent turn (#33271).
|
||||
|
||||
When the user interrupts a running turn by typing a new message,
|
||||
prompt_toolkit may have an in-flight ``CSI 6n`` cursor-position query
|
||||
whose reply (``ESC[<row>;<col>R``) arrives on stdin after the input
|
||||
parser has torn down. The reply then leaks as literal text
|
||||
(``^[[19;1R``) and the VT100 parser can stall in a partial-escape
|
||||
state, accepting no further keystrokes — the terminal appears frozen.
|
||||
|
||||
Two steps recover a sane state:
|
||||
1. ``flush_stdin()`` drains stray escape bytes from the OS input
|
||||
buffer (``termios.tcflush(TCIFLUSH)``; no-op on non-TTY).
|
||||
2. ``_force_full_redraw()`` drops prompt_toolkit's cached
|
||||
screen/cursor state and forces a clean repaint.
|
||||
|
||||
Both steps are independently safe and self-guard, so a failure of one
|
||||
never prevents the other. If the PTY is already dead (EIO), skip the
|
||||
redraw entirely — painting a broken fd is the #81521 redraw storm.
|
||||
"""Recover the terminal after an interrupted agent turn (#33271): an in-flight
|
||||
``CSI 6n`` reply arriving after the input parser tore down leaks as literal text
|
||||
and can stall the VT100 parser mid-escape. Drain stdin, then full redraw; each step
|
||||
self-guards. A dead PTY (EIO) skips the redraw (#81521 redraw storm). Never clear
|
||||
output history here — the interruption marker is printed under
|
||||
``_suspend_output_history``, so replay already excludes it (#60920).
|
||||
"""
|
||||
if getattr(self, "_terminal_io_broken", False):
|
||||
return
|
||||
@@ -204,13 +171,6 @@ class CLITerminalMixin:
|
||||
flush_stdin()
|
||||
except Exception:
|
||||
pass
|
||||
# #60920: The interruption marker is now printed with
|
||||
# _suspend_output_history in chat(), so _OUTPUT_HISTORY only
|
||||
# contains the normal response text (no marker text). Do NOT
|
||||
# clear history here — _force_full_redraw → _replay_output_history
|
||||
# replays the response correctly without duplicating the marker.
|
||||
# The /redraw + Ctrl+L paths also preserve replay for scrollback
|
||||
# recovery as intended.
|
||||
self._force_full_redraw()
|
||||
|
||||
def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False) -> None:
|
||||
@@ -229,114 +189,45 @@ class CLITerminalMixin:
|
||||
pass
|
||||
out.cursor_goto(0, 0)
|
||||
out.flush()
|
||||
# Drop prompt_toolkit's cached screen + cursor state so the
|
||||
# next _redraw() starts from a known (0, 0) origin and
|
||||
# re-renders every cell rather than diffing against stale.
|
||||
# Drop cached screen + cursor state so the next _redraw() starts from a
|
||||
# known (0, 0) origin and re-renders every cell instead of diffing stale.
|
||||
renderer.reset(leave_alternate_screen=False)
|
||||
except OSError as exc:
|
||||
if getattr(exc, "errno", None) == errno.EIO:
|
||||
if _is_eio(exc):
|
||||
self._mark_terminal_io_broken("clear_screen")
|
||||
return
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _recover_after_resize(self, app, original_on_resize) -> None:
|
||||
"""Recover a resized classic CLI without desynchronizing cursor state.
|
||||
|
||||
Unlike _force_full_redraw, we do NOT clear the physical screen or
|
||||
scrollback here. The startup banner and tool summary are printed
|
||||
before prompt_toolkit owns the live chrome, so they live in normal
|
||||
terminal scrollback. Erasing the screen on SIGWINCH removes that
|
||||
startup UI and ``_replay_output_history`` cannot reconstruct it
|
||||
(the banner was never added to ``_OUTPUT_HISTORY``).
|
||||
|
||||
Let prompt_toolkit's own resize path run with its renderer cursor
|
||||
cache intact. Its Application._on_resize() starts with
|
||||
renderer.erase(leave_alternate_screen=False), which needs the cached
|
||||
cursor position to move back to the live prompt origin before
|
||||
erase_down(). Resetting the renderer before that erase loses the
|
||||
origin and can leave stale prompt glyphs after a narrow resize.
|
||||
|
||||
We also flag ``_status_bar_suppressed_after_resize`` so the dynamic
|
||||
status bar and input separator rules stay hidden while the terminal
|
||||
reflow settles. On column shrink the terminal reflows already-rendered
|
||||
status bar rows into scrollback before prompt_toolkit can erase them;
|
||||
drawing a fresh full-width bar immediately makes the old and new
|
||||
versions look duplicated (#19280, #22976).
|
||||
|
||||
Suppression alone is not enough on a WIDTH change. prompt_toolkit's
|
||||
``renderer.erase()`` does ``cursor_up(_cursor_pos.y)`` + ``erase_down()``
|
||||
using the ``_cursor_pos.y`` cached from the LAST render at the OLD
|
||||
width (renderer.py). When the column count shrinks, the terminal
|
||||
reflows each already-painted full-width chrome row into 2+ physical
|
||||
rows, so the cached ``y`` undershoots: ``cursor_up`` does not climb
|
||||
past the reflowed rows and ``erase_down`` leaves the stale bar stranded
|
||||
ABOVE the live origin. The next paint then stacks a fresh bar below it
|
||||
— the duplicated-status-bar report (two bars, two elapsed readings).
|
||||
Suppression hides the *new* bar but never erases the already-reflowed
|
||||
*old* one, so the ghost survives the whole suppression window.
|
||||
|
||||
Fix: on a width change, wipe the visible viewport with ``erase_screen``
|
||||
(CSI 2J) BEFORE delegating to prompt_toolkit's resize, then let its
|
||||
repaint redraw from a clean origin. This is banner-safe: 2J clears
|
||||
only the visible screen, NOT scrollback history (that is CSI 3J, which
|
||||
we do not send here — ``rebuild_scrollback=False``), so the startup
|
||||
banner that scrolled into history is preserved and
|
||||
``_replay_output_history`` is not needed. Row-count-only changes skip
|
||||
the clear (no reflow, so no ghost) to avoid an unnecessary repaint.
|
||||
|
||||
The suppression is transient: a short follow-up timer clears it and
|
||||
repaints once the reflow has settled, so the bar returns on its own
|
||||
during idle. Previously the flag was only cleared on the next
|
||||
*submitted* user input, so a resize/reflow (tmux pane change, SSH
|
||||
window restore, font zoom) followed by idle left the status bar hidden
|
||||
indefinitely even while the refresh clock kept ticking (the dynamic
|
||||
chrome rendered at height 0 on every repaint). The next-submit clear
|
||||
at the input loop remains as a fast path.
|
||||
Never clears scrollback (the startup banner lives there and replay cannot rebuild
|
||||
it) and never resets the renderer before prompt_toolkit's own ``_on_resize``,
|
||||
which erases via the cached cursor position. The status bar / input rules are
|
||||
suppressed while the reflow settles: on column shrink the terminal reflows
|
||||
already-painted rows into scrollback first, so a fresh bar looks duplicated
|
||||
(#19280, #22976). Suppression cannot erase the already-reflowed OLD bar
|
||||
(``renderer.erase()`` uses ``_cursor_pos.y`` cached at the OLD width), so on an
|
||||
OBSERVED width change we wipe the viewport (CSI 2J, banner-safe; 3J only via
|
||||
``display.cli_rebuild_scrollback_on_redraw``) and replay the transcript first.
|
||||
Same-width SIGWINCH (tmux attach, GNOME tab bar, focus) and the first signal
|
||||
without a seeded baseline are left alone — 2J+replay against preserved scrollback
|
||||
duplicates ``_OUTPUT_HISTORY`` (#65293). tmux-attach's stale previous_screen is
|
||||
handled by ``_hermes_call_output_screen_diff`` (#83874). Suppression is cleared by
|
||||
a debounced timer so the bar returns during idle; next-submit stays a fast path.
|
||||
"""
|
||||
from cli import _replay_output_history
|
||||
self._status_bar_suppressed_after_resize = True
|
||||
# On a WIDTH change the terminal has already reflowed the old full-width
|
||||
# chrome into extra physical rows that prompt_toolkit's stale-cursor
|
||||
# erase (cursor_up(_cursor_pos.y) cached at the OLD width) will not
|
||||
# reach, leaving a duplicated status bar stranded above the live origin.
|
||||
# Ctrl+L / /redraw clears it cleanly, so route the resize path through
|
||||
# the SAME recovery: wipe the visible viewport (banner-safe — CSI 2J
|
||||
# by default; CSI 3J only when display.cli_rebuild_scrollback_on_redraw
|
||||
# is enabled) and replay the transcript so nothing is lost.
|
||||
# Same-width SIGWINCH (tmux attach, benign focus/tab signals) is left
|
||||
# untouched — no clear, no replay — because a 2J without replay erases
|
||||
# the visible transcript and a replay against preserved scrollback
|
||||
# duplicates it (#65293). The stale-previous_screen crash tmux attach
|
||||
# used to trigger is handled by _hermes_call_output_screen_diff's
|
||||
# retry-with-first-paint instead (#83874).
|
||||
try:
|
||||
new_width = self._get_tui_terminal_width()
|
||||
except Exception:
|
||||
new_width = None
|
||||
prev_width = getattr(self, "_last_resize_width", None)
|
||||
# Replay only on an OBSERVED width change. The first signal of a
|
||||
# session must not count as one (#65293): GNOME Terminal and friends
|
||||
# deliver benign SIGWINCHes (tab bar appearing, monitor-scale change,
|
||||
# focus events), and a 2J+replay against preserved scrollback
|
||||
# duplicates everything ``_OUTPUT_HISTORY`` holds — after a resume
|
||||
# that is the entire "Previous Conversation" recap plus the first
|
||||
# live exchange. ``_install_resize_recovery`` seeds the baseline at
|
||||
# startup, so an initial maximize/restore still differs from it and
|
||||
# is still recovered; with no baseline (width probe failed) this
|
||||
# signal just records one for the next comparison.
|
||||
width_changed = (
|
||||
new_width is not None
|
||||
and prev_width is not None
|
||||
and new_width != prev_width
|
||||
)
|
||||
width_changed = new_width is not None and prev_width is not None and new_width != prev_width
|
||||
if width_changed:
|
||||
try:
|
||||
self._clear_prompt_toolkit_screen(
|
||||
app,
|
||||
rebuild_scrollback=self._redraw_rebuilds_scrollback(),
|
||||
)
|
||||
app, rebuild_scrollback=self._redraw_rebuilds_scrollback())
|
||||
_replay_output_history()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -347,44 +238,36 @@ class CLITerminalMixin:
|
||||
original_on_resize()
|
||||
self._schedule_status_bar_unsuppress(app)
|
||||
|
||||
def _restart_debounce_timer(self, attr: str, delay: float, fn) -> None:
|
||||
"""Cancel the daemon Timer stored on ``self.<attr>`` (if any) and start a new one.
|
||||
|
||||
``fn`` receives the new Timer so it can detect being superseded."""
|
||||
old_timer = getattr(self, attr, None)
|
||||
if old_timer is not None:
|
||||
try:
|
||||
old_timer.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
timer = threading.Timer(delay, lambda: fn(timer))
|
||||
timer.daemon = True
|
||||
setattr(self, attr, timer)
|
||||
timer.start()
|
||||
|
||||
def _schedule_status_bar_unsuppress(self, app, delay: float = 0.35) -> None:
|
||||
"""Clear the post-resize status-bar suppression after the reflow settles.
|
||||
|
||||
Debounced: a fresh resize cancels the pending unsuppress and restarts
|
||||
the timer, so a resize storm only repaints the bar once it stops.
|
||||
Debounced: a fresh resize cancels the pending timer, so a resize storm repaints
|
||||
the bar only once it stops.
|
||||
"""
|
||||
try:
|
||||
old_timer = getattr(self, "_status_bar_unsuppress_timer", None)
|
||||
if old_timer is not None:
|
||||
try:
|
||||
old_timer.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _clear():
|
||||
self._status_bar_suppressed_after_resize = False
|
||||
try:
|
||||
app.invalidate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _fire():
|
||||
try:
|
||||
loop = getattr(app, "loop", None)
|
||||
except Exception:
|
||||
loop = None
|
||||
if loop is not None:
|
||||
try:
|
||||
loop.call_soon_threadsafe(_clear)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
_clear()
|
||||
|
||||
timer = threading.Timer(delay, _fire)
|
||||
timer.daemon = True
|
||||
self._status_bar_unsuppress_timer = timer
|
||||
timer.start()
|
||||
self._restart_debounce_timer(
|
||||
"_status_bar_unsuppress_timer", delay, lambda _t: _run_on_app_loop(app, _clear))
|
||||
except Exception:
|
||||
# Fail open: never leave the bar stuck hidden.
|
||||
self._status_bar_suppressed_after_resize = False
|
||||
@@ -392,7 +275,6 @@ class CLITerminalMixin:
|
||||
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:
|
||||
"""Debounce resize redraws so footer chrome is not stamped into scrollback."""
|
||||
try:
|
||||
old_timer = getattr(self, "_resize_recovery_timer", None)
|
||||
lock = getattr(self, "_resize_recovery_lock", None)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
@@ -402,91 +284,46 @@ class CLITerminalMixin:
|
||||
def _run_recovery():
|
||||
with lock:
|
||||
if getattr(self, "_resize_recovery_timer", None) is not timer_ref:
|
||||
return
|
||||
return # superseded by a newer resize
|
||||
self._resize_recovery_timer = None
|
||||
self._resize_recovery_pending = False
|
||||
self._recover_after_resize(app, original_on_resize)
|
||||
|
||||
try:
|
||||
loop = app.loop # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
loop = None
|
||||
if loop is not None:
|
||||
try:
|
||||
loop.call_soon_threadsafe(_run_recovery)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
_run_recovery()
|
||||
|
||||
_run_on_app_loop(app, _run_recovery)
|
||||
with lock:
|
||||
if old_timer is not None:
|
||||
try:
|
||||
old_timer.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self._resize_recovery_pending = True
|
||||
timer = threading.Timer(delay, lambda: _timer_fired(timer))
|
||||
timer.daemon = True
|
||||
self._resize_recovery_timer = timer
|
||||
timer.start()
|
||||
self._restart_debounce_timer("_resize_recovery_timer", delay, _timer_fired)
|
||||
except Exception:
|
||||
self._resize_recovery_pending = False
|
||||
self._recover_after_resize(app, original_on_resize)
|
||||
|
||||
def _install_resize_recovery(self, app) -> None:
|
||||
"""Route prompt_toolkit's ``_on_resize`` through the debounced
|
||||
ghost-clearing recovery (#5474/#49120) and record the current terminal
|
||||
width as the baseline for width-change detection.
|
||||
|
||||
Seeding the baseline here is what keeps the session's FIRST SIGWINCH
|
||||
honest (#65293): ``_recover_after_resize`` replays the transcript only
|
||||
on an observed width change, and without a startup baseline it could
|
||||
not tell a benign signal (GNOME Terminal tab bar, monitor-scale
|
||||
change) from a real one. An initial maximize/restore still differs
|
||||
from the seeded width, so it is still recovered.
|
||||
|
||||
The probe reads ``app.output`` directly — NOT
|
||||
``_get_tui_terminal_width`` — because this runs before ``app.run()``,
|
||||
when ``get_app()`` still returns prompt_toolkit's DummyApplication
|
||||
whose DummyOutput reports a hardcoded 80 columns; seeding that fake
|
||||
width would make the first real signal look like a width change and
|
||||
resurrect the duplicate-replay bug this exists to fix.
|
||||
``app.output`` is the same object the running app's resize handler
|
||||
measures, so install-time and signal-time widths are comparable.
|
||||
"""Route ``app._on_resize`` through the debounced ghost-clearing recovery
|
||||
(#5474/#49120) and seed the width baseline so the FIRST SIGWINCH can tell a benign
|
||||
signal from a real resize (#65293). Reads ``app.output`` directly, NOT
|
||||
``_get_tui_terminal_width``: before ``app.run()`` the DummyApplication's output
|
||||
reports a hardcoded 80 columns, which would make the first real signal look like a
|
||||
width change. ``app.output`` is what the running resize handler measures.
|
||||
"""
|
||||
width = None
|
||||
try:
|
||||
width = app.output.get_size().columns
|
||||
except Exception:
|
||||
width = None
|
||||
if not width or width <= 0:
|
||||
for probe in (lambda: app.output.get_size().columns,
|
||||
lambda: shutil.get_terminal_size((80, 24)).columns):
|
||||
try:
|
||||
width = shutil.get_terminal_size((80, 24)).columns
|
||||
width = probe()
|
||||
except Exception:
|
||||
width = None
|
||||
if width and width > 0:
|
||||
break
|
||||
self._last_resize_width = width
|
||||
original_on_resize = app._on_resize
|
||||
|
||||
def _resize_clear_ghosts():
|
||||
self._schedule_resize_recovery(app, original_on_resize)
|
||||
|
||||
app._on_resize = _resize_clear_ghosts
|
||||
app._on_resize = lambda: self._schedule_resize_recovery(app, original_on_resize)
|
||||
|
||||
def _try_attach_clipboard_image(self) -> bool:
|
||||
"""Check clipboard for an image and attach it if found.
|
||||
|
||||
Saves the image to ~/.hermes/images/ and appends the path to
|
||||
``_attached_images``. Returns True if an image was attached.
|
||||
"""
|
||||
"""Save a clipboard image to ~/.hermes/images/ and attach it; True if attached."""
|
||||
from cli import datetime
|
||||
from hermes_cli.clipboard import save_clipboard_image
|
||||
|
||||
img_dir = get_hermes_home() / "images"
|
||||
self._image_counter += 1
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
img_path = img_dir / f"clip_{ts}_{self._image_counter}.png"
|
||||
|
||||
img_path = get_hermes_home() / "images" / f"clip_{ts}_{self._image_counter}.png"
|
||||
if save_clipboard_image(img_path):
|
||||
self._attached_images.append(img_path)
|
||||
return True
|
||||
@@ -494,12 +331,10 @@ class CLITerminalMixin:
|
||||
return False
|
||||
|
||||
def _write_osc52_clipboard(self, text: str) -> None:
|
||||
"""Copy *text* to terminal clipboard via OSC 52.
|
||||
"""Copy *text* to the terminal clipboard via OSC 52.
|
||||
|
||||
Wrapped for tmux/screen passthrough (mirrors the TUI's
|
||||
wrapForMultiplexer in ui-tui/src/lib/osc52.ts) — without the DCS
|
||||
wrapper the multiplexer consumes the sequence and the copy is
|
||||
silently lost.
|
||||
Wrapped for tmux/screen passthrough (mirrors ui-tui/src/lib/osc52.ts) — without
|
||||
the DCS wrapper the multiplexer consumes the sequence and the copy is lost.
|
||||
"""
|
||||
payload = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||
seq = f"\x1b]52;c;{payload}\x07"
|
||||
@@ -507,85 +342,45 @@ class CLITerminalMixin:
|
||||
seq = "\x1bPtmux;" + seq.replace("\x1b", "\x1b\x1b") + "\x1b\\"
|
||||
elif os.environ.get("STY"):
|
||||
seq = "\x1bP" + seq + "\x1b\\"
|
||||
out = getattr(self, "_app", None)
|
||||
output = getattr(out, "output", None) if out else None
|
||||
if output and hasattr(output, "write_raw"):
|
||||
output.write_raw(seq)
|
||||
output.flush()
|
||||
return
|
||||
if output and hasattr(output, "write"):
|
||||
output.write(seq)
|
||||
output.flush()
|
||||
return
|
||||
sys.stdout.write(seq)
|
||||
sys.stdout.flush()
|
||||
_write_terminal_sequence(getattr(self, "_app", None), seq)
|
||||
|
||||
def _recover_terminal_input_modes(self, *, reason: str) -> None:
|
||||
"""Best-effort reset when leaked mouse reports indicate mode drift."""
|
||||
from cli import (
|
||||
CLI_CONFIG,
|
||||
_DIM,
|
||||
_RST,
|
||||
_TERMINAL_INPUT_MODE_RESET_SEQ,
|
||||
_cli_multiline_shortcuts_enabled,
|
||||
_cprint,
|
||||
_enable_extended_enter_keys,
|
||||
logger,
|
||||
)
|
||||
CLI_CONFIG, _DIM, _RST, _TERMINAL_INPUT_MODE_RESET_SEQ,
|
||||
_cli_multiline_shortcuts_enabled, _cprint, _enable_extended_enter_keys, logger)
|
||||
now = time.monotonic()
|
||||
# Rate-limit to avoid thrashing if a terminal floods reports.
|
||||
if now - self._last_input_mode_recovery < 0.5:
|
||||
return
|
||||
self._last_input_mode_recovery = now
|
||||
|
||||
out = getattr(self, "_app", None)
|
||||
output = getattr(out, "output", None) if out else None
|
||||
app = getattr(self, "_app", None)
|
||||
output = getattr(app, "output", None) if app else None
|
||||
try:
|
||||
if output and hasattr(output, "write_raw"):
|
||||
output.write_raw(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
output.flush()
|
||||
elif output and hasattr(output, "write"):
|
||||
output.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
output.flush()
|
||||
else:
|
||||
sys.stdout.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
sys.stdout.flush()
|
||||
_write_terminal_sequence(app, _TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# The reset sequence above pops kitty keyboard mode and resets
|
||||
# modifyOtherKeys too — re-request extended keys so Shift+Enter /
|
||||
# modified-key reporting isn't silently dead for the rest of the
|
||||
# session after a recovery (sibling of the startup push).
|
||||
# The reset pops kitty keyboard mode and resets modifyOtherKeys too — re-request
|
||||
# extended keys so Shift+Enter isn't silently dead for the rest of the session.
|
||||
try:
|
||||
if _cli_multiline_shortcuts_enabled(self.config or CLI_CONFIG):
|
||||
_enable_extended_enter_keys(output)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning("Recovered terminal input modes after leak: %s", reason)
|
||||
if not self._input_mode_recovery_notice_shown:
|
||||
self._input_mode_recovery_notice_shown = True
|
||||
_cprint(
|
||||
f" {_DIM}Recovered terminal input modes after leaked mouse reports. "
|
||||
f"If this repeats, run /new or restart this tab.{_RST}"
|
||||
)
|
||||
f"If this repeats, run /new or restart this tab.{_RST}")
|
||||
|
||||
def _check_termios_drift(self) -> None:
|
||||
"""Watchdog: heal the tty if it drifted back to cooked mode.
|
||||
|
||||
See ``_heal_cooked_mode_drift`` for the failure class (a lost
|
||||
``run_in_terminal`` cooked→raw restore leaves the terminal
|
||||
line-buffering keystrokes while the prompt_toolkit app believes it
|
||||
owns raw mode — the CLI looks dead but the process is healthy).
|
||||
|
||||
Called from ``process_loop``'s idle branch, so a drifted terminal
|
||||
self-heals within ~a second of the agent going idle instead of
|
||||
requiring an external ``stty`` rescue. Skipped while a
|
||||
``run_in_terminal`` window is legitimately holding cooked mode
|
||||
(``app._running_in_terminal``), while the agent is running (approval
|
||||
prompts and sudo prompts legitimately manipulate the tty), and on
|
||||
Windows (no termios).
|
||||
"""Idle watchdog: heal a tty that drifted back to cooked mode (a lost
|
||||
``run_in_terminal`` cooked→raw restore leaves it line-buffering while prompt_toolkit
|
||||
believes it owns raw mode — CLI looks dead, process is healthy). Skipped while
|
||||
``run_in_terminal`` legitimately holds cooked mode, while the agent runs
|
||||
(approval/sudo prompts touch the tty), and on Windows (no termios).
|
||||
"""
|
||||
from cli import _DIM, _RST, _cprint, _heal_cooked_mode_drift, logger
|
||||
if os.name == "nt":
|
||||
@@ -593,7 +388,6 @@ class CLITerminalMixin:
|
||||
app = getattr(self, "_app", None)
|
||||
if app is None or not getattr(app, "_is_running", False):
|
||||
return
|
||||
# A run_in_terminal window is *supposed* to be cooked — don't fight it.
|
||||
if getattr(app, "_running_in_terminal", False):
|
||||
return
|
||||
now = time.monotonic()
|
||||
@@ -609,16 +403,13 @@ class CLITerminalMixin:
|
||||
if _heal_cooked_mode_drift(fd):
|
||||
logger.warning(
|
||||
"Healed cooked-mode termios drift on stdin — a "
|
||||
"run_in_terminal cooked→raw restore was lost."
|
||||
)
|
||||
# Redraw so the prompt is visibly alive again.
|
||||
"run_in_terminal cooked→raw restore was lost.")
|
||||
try:
|
||||
self._invalidate()
|
||||
self._invalidate() # so the prompt is visibly alive again
|
||||
except Exception:
|
||||
pass
|
||||
if not self._termios_drift_notice_shown:
|
||||
self._termios_drift_notice_shown = True
|
||||
_cprint(
|
||||
f" {_DIM}Recovered terminal from cooked-mode drift "
|
||||
f"(input should respond normally again).{_RST}"
|
||||
)
|
||||
f"(input should respond normally again).{_RST}")
|
||||
|
||||
+146
-285
@@ -1,12 +1,8 @@
|
||||
"""Clipboard image extraction for macOS, Windows, Linux, and WSL2.
|
||||
"""Clipboard image extraction and text write for macOS, Windows, Linux, and WSL2.
|
||||
|
||||
Provides a single function `save_clipboard_image(dest)` that checks the system clipboard for image
|
||||
data, saves it to *dest* as PNG, and returns True on success. No external Python dependencies — uses
|
||||
only OS-level CLI tools that ship with the platform (or are commonly installed).
|
||||
|
||||
Platform support: macOS — osascript (always available), pngpaste (if installed) Windows — PowerShell
|
||||
via WinForms, Get-Clipboard, file-drop fallback WSL2 — powershell.exe via WinForms, Get-Clipboard,
|
||||
file-drop fallback Linux — wl-paste (Wayland), xclip (X11)
|
||||
No Python deps — only OS-level CLI tools: macOS osascript (always present) / pngpaste (optional);
|
||||
Windows and WSL2 PowerShell via WinForms, Get-Clipboard, then a file-drop fallback; Linux
|
||||
wl-paste (Wayland), xclip (X11).
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -21,6 +17,7 @@ from hermes_constants import is_wsl as _is_wsl
|
||||
logger = logging.getLogger(__name__)
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
_TEXT = dict(capture_output=True, text=True, encoding='utf-8', errors='replace')
|
||||
_PS_FLAGS = ("-NoProfile", "-NonInteractive")
|
||||
|
||||
|
||||
def _nonempty(path: Path) -> bool:
|
||||
@@ -40,8 +37,16 @@ def _probe(argv: list, timeout: int, ok, *, missing: str | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _pipe_to_file(argv: list, dest: Path) -> bool:
|
||||
"""Run *argv* with stdout redirected into *dest*; True when a non-empty file resulted."""
|
||||
with open(dest, "wb") as f:
|
||||
subprocess.run(argv, stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True)
|
||||
return _nonempty(dest)
|
||||
|
||||
|
||||
def _linux_backends():
|
||||
"""(enabled, has_image, save) in Linux fallthrough order: WSL → Wayland → X11."""
|
||||
"""(enabled, has_image, save) in Linux fallthrough order: WSL → Wayland → X11
|
||||
(a failed WSL probe falls through — WSLg might have wl-paste or xclip working)."""
|
||||
return (
|
||||
(_is_wsl(), _wsl_has_image, _wsl_save),
|
||||
(bool(os.environ.get("WAYLAND_DISPLAY")), _wayland_has_image, _wayland_save),
|
||||
@@ -50,16 +55,12 @@ def _linux_backends():
|
||||
|
||||
|
||||
def save_clipboard_image(dest: Path) -> bool:
|
||||
"""Extract an image from the system clipboard and save it as PNG.
|
||||
|
||||
Returns True if an image was found and saved, False otherwise.
|
||||
"""
|
||||
"""Save the clipboard image to *dest* as PNG; True when an image was found and written."""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if sys.platform == "darwin":
|
||||
return _macos_save(dest)
|
||||
if sys.platform == "win32":
|
||||
return _windows_save(dest)
|
||||
return _linux_save(dest)
|
||||
# pngpaste first (fast, handles more formats); osascript is the always-present fallback.
|
||||
return _macos_pngpaste(dest) or _macos_osascript(dest)
|
||||
return (_windows_save if sys.platform == "win32" else _linux_save)(dest)
|
||||
|
||||
|
||||
def has_clipboard_image() -> bool:
|
||||
@@ -73,66 +74,42 @@ def has_clipboard_image() -> bool:
|
||||
|
||||
# ── Text write (native tools, mirrors ui-tui/src/lib/clipboard.ts) ──────
|
||||
|
||||
def _powershell_write_script(b64: str) -> str:
|
||||
# PowerShell decodes piped stdin with the system ANSI code page (e.g.
|
||||
# CP936), not UTF-8, so stdin-based writes mangle CJK/emoji. Base64 the
|
||||
# UTF-8 bytes and decode inside PowerShell instead — same approach as
|
||||
# the TUI's writeClipboardText.
|
||||
return (
|
||||
"Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString("
|
||||
f"[System.Convert]::FromBase64String('{b64}')))"
|
||||
)
|
||||
|
||||
|
||||
def _write_clipboard_commands() -> list:
|
||||
"""Return (cmd_argv, use_stdin) candidates in platform fallback order."""
|
||||
if sys.platform == "darwin":
|
||||
return [(["pbcopy"], True)]
|
||||
if sys.platform == "win32":
|
||||
return [(["powershell", "-NoProfile", "-NonInteractive"], False)]
|
||||
attempts = []
|
||||
if _is_wsl():
|
||||
attempts.append((["powershell.exe", "-NoProfile", "-NonInteractive"], False))
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
attempts.append((["wl-copy", "--type", "text/plain"], True))
|
||||
attempts.append((["xclip", "-selection", "clipboard", "-in"], True))
|
||||
attempts.append((["xsel", "--clipboard", "--input"], True))
|
||||
return attempts
|
||||
def _write_clipboard_commands(data: bytes) -> list:
|
||||
"""(argv, run_kwargs) candidates for writing *data*, in platform fallback order."""
|
||||
# PowerShell decodes piped stdin with the system ANSI code page (e.g. CP936), not UTF-8, so
|
||||
# stdin-based writes mangle CJK/emoji. Base64 the UTF-8 bytes and decode inside PowerShell
|
||||
# instead (same approach as the TUI's writeClipboardText).
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
ps_argv = [*_PS_FLAGS, "-Command", "Set-Clipboard -Value ([System.Text.Encoding]::UTF8"
|
||||
f".GetString([System.Convert]::FromBase64String('{b64}')))"]
|
||||
ps_kw, pipe = {"stdin": subprocess.DEVNULL}, {"input": data}
|
||||
linux = sys.platform not in ("darwin", "win32")
|
||||
return [(argv, kw) for enabled, argv, kw in (
|
||||
(sys.platform == "darwin", ["pbcopy"], pipe),
|
||||
(sys.platform == "win32", ["powershell", *ps_argv], ps_kw),
|
||||
(linux and _is_wsl(), ["powershell.exe", *ps_argv], ps_kw),
|
||||
(linux and os.environ.get("WAYLAND_DISPLAY"), ["wl-copy", "--type", "text/plain"], pipe),
|
||||
(linux, ["xclip", "-selection", "clipboard", "-in"], pipe),
|
||||
(linux, ["xsel", "--clipboard", "--input"], pipe),
|
||||
) if enabled]
|
||||
|
||||
|
||||
def is_remote_shell_session(env=None) -> bool:
|
||||
"""True when running inside an SSH session.
|
||||
|
||||
Mirrors ui-tui/src/lib/terminalSetup.ts isRemoteShellSession(). Over SSH, native clipboard tools
|
||||
write the REMOTE machine's clipboard (or an X-forwarded one), which is almost never what the
|
||||
user wants — OSC 52 reaches the LOCAL terminal emulator instead.
|
||||
"""
|
||||
"""True inside an SSH session (mirrors ui-tui/src/lib/terminalSetup.ts). Over SSH, native
|
||||
clipboard tools write the REMOTE machine's clipboard (or an X-forwarded one), which is almost
|
||||
never what the user wants — OSC 52 reaches the LOCAL terminal instead."""
|
||||
e = os.environ if env is None else env
|
||||
return bool(
|
||||
e.get("SSH_CONNECTION") or e.get("SSH_TTY") or e.get("SSH_CLIENT")
|
||||
)
|
||||
return bool(e.get("SSH_CONNECTION") or e.get("SSH_TTY") or e.get("SSH_CLIENT"))
|
||||
|
||||
|
||||
def write_clipboard_text(text: str) -> bool:
|
||||
"""Write *text* to the system clipboard via native platform tools.
|
||||
|
||||
Fallback order matches the TUI (ui-tui/src/lib/clipboard.ts): macOS pbcopy → Windows/WSL
|
||||
PowerShell Set-Clipboard → wl-copy → xclip → xsel. Returns True if any backend succeeded;
|
||||
callers should fall back to OSC 52 on False.
|
||||
"""
|
||||
data = text.encode("utf-8")
|
||||
quiet = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for argv, use_stdin in _write_clipboard_commands():
|
||||
"""Write *text* to the clipboard via native tools; fallback order matches the TUI: pbcopy →
|
||||
Windows/WSL PowerShell Set-Clipboard → wl-copy → xclip → xsel. Returns True if any backend
|
||||
succeeded; callers fall back to OSC 52 on False."""
|
||||
for argv, kw in _write_clipboard_commands(text.encode("utf-8")):
|
||||
try:
|
||||
if use_stdin:
|
||||
proc = subprocess.run(argv, input=data, timeout=10, **quiet)
|
||||
else:
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
proc = subprocess.run(
|
||||
argv + ["-Command", _powershell_write_script(b64)],
|
||||
stdin=subprocess.DEVNULL, timeout=10, **quiet,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
if subprocess.run(argv, timeout=10, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, **kw).returncode == 0:
|
||||
return True
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
@@ -141,23 +118,16 @@ def write_clipboard_text(text: str) -> bool:
|
||||
|
||||
# ── macOS ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _macos_save(dest: Path) -> bool:
|
||||
"""Try pngpaste first (fast, handles more formats), fall back to osascript."""
|
||||
return _macos_pngpaste(dest) or _macos_osascript(dest)
|
||||
|
||||
|
||||
def _macos_has_image() -> bool:
|
||||
"""Check if macOS clipboard contains image data."""
|
||||
return _probe(["osascript", "-e", "clipboard info"], 3,
|
||||
lambda r: "«class PNGf»" in r.stdout or "«class TIFF»" in r.stdout)
|
||||
|
||||
|
||||
def _macos_pngpaste(dest: Path) -> bool:
|
||||
"""Use pngpaste (brew install pngpaste) — fastest, cleanest."""
|
||||
"""pngpaste (brew install pngpaste) — fastest, cleanest."""
|
||||
try:
|
||||
r = subprocess.run(["pngpaste", str(dest)], capture_output=True, timeout=3)
|
||||
if r.returncode == 0 and _nonempty(dest):
|
||||
return True
|
||||
return r.returncode == 0 and _nonempty(dest)
|
||||
except FileNotFoundError:
|
||||
pass # pngpaste not installed
|
||||
except Exception as e:
|
||||
@@ -166,74 +136,27 @@ def _macos_pngpaste(dest: Path) -> bool:
|
||||
|
||||
|
||||
def _macos_osascript(dest: Path) -> bool:
|
||||
"""Use osascript to extract PNG data from clipboard (always available)."""
|
||||
"""osascript PNG extraction (always available)."""
|
||||
if not _macos_has_image():
|
||||
return False
|
||||
|
||||
# Extract as PNG
|
||||
script = (
|
||||
'try\n'
|
||||
' set imgData to the clipboard as «class PNGf»\n'
|
||||
f' set f to open for access POSIX file "{dest}" with write permission\n'
|
||||
' write imgData to f\n'
|
||||
' close access f\n'
|
||||
'on error\n'
|
||||
' return "fail"\n'
|
||||
'end try\n'
|
||||
)
|
||||
script = f'''try
|
||||
set imgData to the clipboard as «class PNGf»
|
||||
set f to open for access POSIX file "{dest}" with write permission
|
||||
write imgData to f
|
||||
close access f
|
||||
on error
|
||||
return "fail"
|
||||
end try
|
||||
'''
|
||||
try:
|
||||
r = subprocess.run(["osascript", "-e", script], timeout=5, **_TEXT)
|
||||
if r.returncode == 0 and "fail" not in r.stdout and _nonempty(dest):
|
||||
return True
|
||||
return r.returncode == 0 and "fail" not in r.stdout and _nonempty(dest)
|
||||
except Exception as e:
|
||||
logger.debug("osascript clipboard extract failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# ── Shared PowerShell scripts (native Windows + WSL2) ─────────────────────
|
||||
|
||||
# .NET System.Windows.Forms.Clipboard — used by both native Windows (powershell)
|
||||
# and WSL2 (powershell.exe) paths.
|
||||
_PS_CHECK_IMAGE = (
|
||||
"Add-Type -AssemblyName System.Windows.Forms;"
|
||||
"[System.Windows.Forms.Clipboard]::ContainsImage()"
|
||||
)
|
||||
|
||||
_PS_EXTRACT_IMAGE = (
|
||||
"Add-Type -AssemblyName System.Windows.Forms;"
|
||||
"Add-Type -AssemblyName System.Drawing;"
|
||||
"$img = [System.Windows.Forms.Clipboard]::GetImage();"
|
||||
"if ($null -eq $img) { exit 1 }"
|
||||
"$ms = New-Object System.IO.MemoryStream;"
|
||||
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);"
|
||||
"[System.Convert]::ToBase64String($ms.ToArray())"
|
||||
)
|
||||
|
||||
_PS_CHECK_IMAGE_GET_CLIPBOARD = (
|
||||
"try { "
|
||||
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
|
||||
"if ($null -ne $img) { 'True' } else { 'False' }"
|
||||
"} catch { 'False' }"
|
||||
)
|
||||
|
||||
_PS_EXTRACT_IMAGE_GET_CLIPBOARD = (
|
||||
"try { "
|
||||
"Add-Type -AssemblyName System.Drawing;"
|
||||
"Add-Type -AssemblyName PresentationCore;"
|
||||
"Add-Type -AssemblyName WindowsBase;"
|
||||
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
|
||||
"if ($null -eq $img) { exit 1 }"
|
||||
"$ms = New-Object System.IO.MemoryStream;"
|
||||
"if ($img -is [System.Drawing.Image]) {"
|
||||
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)"
|
||||
"} elseif ($img -is [System.Windows.Media.Imaging.BitmapSource]) {"
|
||||
"$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder;"
|
||||
"$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($img));"
|
||||
"$enc.Save($ms)"
|
||||
"} else { exit 2 }"
|
||||
"[System.Convert]::ToBase64String($ms.ToArray())"
|
||||
"} catch { exit 1 }"
|
||||
)
|
||||
# ── PowerShell (native Windows powershell/pwsh + WSL2 powershell.exe) ─────
|
||||
|
||||
_FILEDROP_IMAGE_EXTS = "'.png','.jpg','.jpeg','.gif','.webp','.bmp','.tiff','.tif'"
|
||||
_PS_FILEDROP_HIT = (
|
||||
@@ -243,186 +166,141 @@ _PS_FILEDROP_HIT = (
|
||||
"$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;"
|
||||
)
|
||||
|
||||
_PS_CHECK_FILEDROP_IMAGE = (
|
||||
_PS_FILEDROP_HIT
|
||||
+ "if ($null -ne $hit) { 'True' } else { 'False' }"
|
||||
"} catch { 'False' }"
|
||||
)
|
||||
|
||||
_PS_EXTRACT_FILEDROP_IMAGE = (
|
||||
_PS_FILEDROP_HIT
|
||||
+ "if ($null -eq $hit) { exit 1 }"
|
||||
"[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($hit))"
|
||||
"} catch { exit 1 }"
|
||||
)
|
||||
|
||||
_POWERSHELL_HAS_IMAGE_SCRIPTS = (
|
||||
_PS_CHECK_IMAGE,
|
||||
_PS_CHECK_IMAGE_GET_CLIPBOARD,
|
||||
_PS_CHECK_FILEDROP_IMAGE,
|
||||
)
|
||||
|
||||
_POWERSHELL_EXTRACT_IMAGE_SCRIPTS = (
|
||||
_PS_EXTRACT_IMAGE,
|
||||
_PS_EXTRACT_IMAGE_GET_CLIPBOARD,
|
||||
_PS_EXTRACT_FILEDROP_IMAGE,
|
||||
# (has_image, extract-as-base64-PNG) script pairs, tried in order.
|
||||
_PS_IMAGE_STRATEGIES = (
|
||||
( # .NET System.Windows.Forms.Clipboard
|
||||
"Add-Type -AssemblyName System.Windows.Forms;"
|
||||
"[System.Windows.Forms.Clipboard]::ContainsImage()",
|
||||
"Add-Type -AssemblyName System.Windows.Forms;"
|
||||
"Add-Type -AssemblyName System.Drawing;"
|
||||
"$img = [System.Windows.Forms.Clipboard]::GetImage();"
|
||||
"if ($null -eq $img) { exit 1 }"
|
||||
"$ms = New-Object System.IO.MemoryStream;"
|
||||
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);"
|
||||
"[System.Convert]::ToBase64String($ms.ToArray())"),
|
||||
( # Get-Clipboard -Format Image (System.Drawing.Image or WPF BitmapSource)
|
||||
"try { "
|
||||
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
|
||||
"if ($null -ne $img) { 'True' } else { 'False' }"
|
||||
"} catch { 'False' }",
|
||||
"try { "
|
||||
"Add-Type -AssemblyName System.Drawing;"
|
||||
"Add-Type -AssemblyName PresentationCore;"
|
||||
"Add-Type -AssemblyName WindowsBase;"
|
||||
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
|
||||
"if ($null -eq $img) { exit 1 }"
|
||||
"$ms = New-Object System.IO.MemoryStream;"
|
||||
"if ($img -is [System.Drawing.Image]) {"
|
||||
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)"
|
||||
"} elseif ($img -is [System.Windows.Media.Imaging.BitmapSource]) {"
|
||||
"$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder;"
|
||||
"$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($img));"
|
||||
"$enc.Save($ms)"
|
||||
"} else { exit 2 }"
|
||||
"[System.Convert]::ToBase64String($ms.ToArray())"
|
||||
"} catch { exit 1 }"),
|
||||
( # copied image *file* (Explorer file drop)
|
||||
_PS_FILEDROP_HIT
|
||||
+ "if ($null -ne $hit) { 'True' } else { 'False' }"
|
||||
"} catch { 'False' }",
|
||||
_PS_FILEDROP_HIT
|
||||
+ "if ($null -eq $hit) { exit 1 }"
|
||||
"[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($hit))"
|
||||
"} catch { exit 1 }",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[exe, "-NoProfile", "-NonInteractive", "-Command", script], timeout=timeout, **_TEXT,
|
||||
)
|
||||
|
||||
|
||||
def _write_base64_image(dest: Path, b64_data: str) -> bool:
|
||||
dest.write_bytes(base64.b64decode(b64_data, validate=True))
|
||||
return _nonempty(dest)
|
||||
|
||||
|
||||
def _powershell_has_image(exe: str, *, timeout: int, label: str) -> bool:
|
||||
for script in _POWERSHELL_HAS_IMAGE_SCRIPTS:
|
||||
def _ps_clipboard(exe: str, timeout: int, label: str, dest: Path | None = None) -> bool:
|
||||
"""Probe (*dest* None) or extract (base64 PNG → *dest*) the Windows clipboard image via *exe*.
|
||||
A missing *exe* ends the whole chain (every script needs the same binary); any other failure
|
||||
logs (and drops a partial *dest*) then tries the next strategy."""
|
||||
for check, extract in _PS_IMAGE_STRATEGIES:
|
||||
try:
|
||||
r = _run_powershell(exe, script, timeout=timeout)
|
||||
if r.returncode == 0 and "True" in r.stdout:
|
||||
return True
|
||||
argv = [exe, *_PS_FLAGS, "-Command", check if dest is None else extract]
|
||||
r = subprocess.run(argv, timeout=timeout, **_TEXT)
|
||||
if dest is None:
|
||||
if r.returncode == 0 and "True" in r.stdout:
|
||||
return True
|
||||
elif r.returncode == 0 and r.stdout.strip():
|
||||
dest.write_bytes(base64.b64decode(r.stdout.strip(), validate=True))
|
||||
if _nonempty(dest):
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.debug("%s not found — clipboard unavailable", exe)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug("%s clipboard image check failed: %s", label, e)
|
||||
logger.debug("%s clipboard image %s failed: %s", label,
|
||||
"check" if dest is None else "extraction", e)
|
||||
if dest is not None:
|
||||
dest.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
|
||||
def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) -> bool:
|
||||
for script in _POWERSHELL_EXTRACT_IMAGE_SCRIPTS:
|
||||
try:
|
||||
r = _run_powershell(exe, script, timeout=timeout)
|
||||
if r.returncode != 0:
|
||||
continue
|
||||
b64_data = r.stdout.strip()
|
||||
if b64_data and _write_base64_image(dest, b64_data):
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.debug("%s not found — clipboard unavailable", exe)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug("%s clipboard image extraction failed: %s", label, e)
|
||||
dest.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
|
||||
# ── Native Windows ────────────────────────────────────────────────────────
|
||||
|
||||
# Native Windows uses ``powershell`` (Windows PowerShell 5.1, always present)
|
||||
# or ``pwsh`` (PowerShell 7+, optional). Discovery is cached per-process.
|
||||
|
||||
|
||||
def _find_powershell() -> str | None:
|
||||
"""Return the first available PowerShell executable, or None."""
|
||||
for name in ("powershell", "pwsh"):
|
||||
try:
|
||||
r = _run_powershell(name, "echo ok", timeout=5)
|
||||
if r.returncode == 0 and "ok" in r.stdout:
|
||||
return name
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# Cache the resolved PowerShell executable (checked once per process)
|
||||
_ps_exe: str | None | bool = False # False = not yet checked
|
||||
_ps_exe: str | None | bool = False # resolved PowerShell executable; False = not yet checked
|
||||
|
||||
|
||||
def _get_ps_exe() -> str | None:
|
||||
"""First working PowerShell — ``powershell`` (5.1, always present) or ``pwsh`` (7+,
|
||||
optional) — cached per process; None when neither runs."""
|
||||
global _ps_exe
|
||||
if _ps_exe is False:
|
||||
_ps_exe = _find_powershell()
|
||||
_ps_exe = next((name for name in ("powershell", "pwsh") if _probe(
|
||||
[name, *_PS_FLAGS, "-Command", "echo ok"], 5,
|
||||
lambda r: r.returncode == 0 and "ok" in r.stdout)), None)
|
||||
return _ps_exe
|
||||
|
||||
|
||||
def _windows_has_image() -> bool:
|
||||
"""Check if the Windows clipboard contains an image."""
|
||||
ps = _get_ps_exe()
|
||||
if ps is None:
|
||||
return False
|
||||
return _powershell_has_image(ps, timeout=5, label="Windows")
|
||||
return ps is not None and _ps_clipboard(ps, 5, "Windows")
|
||||
|
||||
|
||||
def _windows_save(dest: Path) -> bool:
|
||||
"""Extract clipboard image on native Windows via PowerShell → base64 PNG."""
|
||||
ps = _get_ps_exe()
|
||||
if ps is None:
|
||||
logger.debug("No PowerShell found — Windows clipboard image paste unavailable")
|
||||
return False
|
||||
return _powershell_save_image(ps, dest, timeout=15, label="Windows")
|
||||
return _ps_clipboard(ps, 15, "Windows", dest)
|
||||
|
||||
|
||||
# ── Linux ────────────────────────────────────────────────────────────────
|
||||
# ── Linux: WSL (powershell.exe) → Wayland (wl-paste) → X11 (xclip) ───────
|
||||
|
||||
def _linux_save(dest: Path) -> bool:
|
||||
"""Try clipboard backends in priority order: WSL → Wayland → X11
|
||||
(a failed WSL probe falls through — WSLg might have wl-paste or xclip working)."""
|
||||
return any(enabled and save(dest) for enabled, _, save in _linux_backends())
|
||||
|
||||
|
||||
# ── WSL2 (powershell.exe) ────────────────────────────────────────────────
|
||||
# Reuses _PS_CHECK_IMAGE / _PS_EXTRACT_IMAGE defined above.
|
||||
|
||||
def _wsl_has_image() -> bool:
|
||||
"""Check if Windows clipboard has an image (via powershell.exe)."""
|
||||
return _powershell_has_image("powershell.exe", timeout=8, label="WSL")
|
||||
return _ps_clipboard("powershell.exe", 8, "WSL")
|
||||
|
||||
|
||||
def _wsl_save(dest: Path) -> bool:
|
||||
"""Extract clipboard image via powershell.exe → base64 → decode to PNG."""
|
||||
return _powershell_save_image("powershell.exe", dest, timeout=15, label="WSL")
|
||||
return _ps_clipboard("powershell.exe", 15, "WSL", dest)
|
||||
|
||||
|
||||
# ── Wayland (wl-paste) ──────────────────────────────────────────────────
|
||||
|
||||
_WAYLAND_MIME_PREFERENCE = ("image/png", "image/jpeg", "image/bmp", "image/gif", "image/webp")
|
||||
_WL_LIST_TYPES = ["wl-paste", "--list-types"]
|
||||
_WL_MISSING = "wl-paste not installed — Wayland clipboard unavailable"
|
||||
|
||||
|
||||
def _wayland_has_image() -> bool:
|
||||
"""Check if Wayland clipboard has image content."""
|
||||
return _probe(_WL_LIST_TYPES, 3, lambda r: r.returncode == 0 and any(
|
||||
t.startswith("image/") for t in r.stdout.splitlines()), missing=_WL_MISSING)
|
||||
|
||||
|
||||
def _pipe_to_file(argv: list, dest: Path) -> bool:
|
||||
"""Run *argv* with stdout redirected into *dest*; True when a non-empty file resulted."""
|
||||
with open(dest, "wb") as f:
|
||||
subprocess.run(argv, stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True)
|
||||
return _nonempty(dest)
|
||||
|
||||
|
||||
def _wayland_save(dest: Path) -> bool:
|
||||
"""Use wl-paste to extract clipboard image (Wayland sessions)."""
|
||||
try:
|
||||
types_r = subprocess.run(_WL_LIST_TYPES, timeout=3, **_TEXT)
|
||||
if types_r.returncode != 0:
|
||||
return False
|
||||
types = types_r.stdout.splitlines()
|
||||
# Prefer PNG, fall back to other image formats
|
||||
mime = next((m for m in _WAYLAND_MIME_PREFERENCE if m in types), None)
|
||||
types = types_r.stdout.splitlines() if types_r.returncode == 0 else ()
|
||||
mime = next((m for m in _WAYLAND_MIME_PREFERENCE if m in types), None) # PNG preferred
|
||||
if not mime:
|
||||
return False
|
||||
|
||||
if not _pipe_to_file(["wl-paste", "--type", mime], dest):
|
||||
dest.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
# save_clipboard_image() promises a PNG output path. Wayland can offer
|
||||
# JPEG/GIF/WebP/BMP payloads, so normalize every non-PNG result before
|
||||
# returning success.
|
||||
if mime != "image/png" and (not _convert_to_png(dest) or not _is_png_file(dest)):
|
||||
dest.unlink(missing_ok=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
# save_clipboard_image() promises a PNG. Wayland can offer JPEG/GIF/WebP/BMP payloads,
|
||||
# so every non-PNG result is normalized (and re-verified) before reporting success.
|
||||
if _pipe_to_file(["wl-paste", "--type", mime], dest) and (
|
||||
mime == "image/png" or (_convert_to_png(dest) and _is_png_file(dest))):
|
||||
return True
|
||||
dest.unlink(missing_ok=True)
|
||||
except FileNotFoundError:
|
||||
logger.debug(_WL_MISSING)
|
||||
except Exception as e:
|
||||
@@ -432,31 +310,25 @@ def _wayland_save(dest: Path) -> bool:
|
||||
|
||||
|
||||
def _convert_to_png(path: Path) -> bool:
|
||||
"""Convert an image file to PNG in-place (requires Pillow or ImageMagick)."""
|
||||
# Try Pillow first (likely installed in the venv)
|
||||
"""Convert an image file to PNG in-place: Pillow first (likely in the venv), then ImageMagick.
|
||||
When neither works the file is left as-is — BMP is still usable for most APIs."""
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(path)
|
||||
img.save(path, "PNG")
|
||||
Image.open(path).save(path, "PNG")
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Pillow BMP→PNG conversion failed: %s", e)
|
||||
|
||||
# Fall back to ImageMagick convert
|
||||
tmp = path.with_suffix(".bmp")
|
||||
try:
|
||||
path.rename(tmp)
|
||||
r = subprocess.run(
|
||||
["convert", str(tmp), "png:" + str(path)],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
r = subprocess.run(["convert", str(tmp), "png:" + str(path)], capture_output=True,
|
||||
timeout=5)
|
||||
if r.returncode == 0 and _nonempty(path):
|
||||
tmp.unlink(missing_ok=True)
|
||||
return True
|
||||
# Convert failed — restore the original file
|
||||
tmp.rename(path)
|
||||
tmp.rename(path) # convert failed — restore the original file
|
||||
except Exception as e:
|
||||
if isinstance(e, FileNotFoundError):
|
||||
logger.debug("ImageMagick not installed — cannot convert BMP to PNG")
|
||||
@@ -464,13 +336,10 @@ def _convert_to_png(path: Path) -> bool:
|
||||
logger.debug("ImageMagick BMP→PNG conversion failed: %s", e)
|
||||
if tmp.exists() and not path.exists():
|
||||
tmp.rename(path)
|
||||
|
||||
# Can't convert — BMP is still usable as-is for most APIs
|
||||
return _nonempty(path)
|
||||
|
||||
|
||||
def _is_png_file(path: Path) -> bool:
|
||||
"""Return True when *path* starts with the PNG file signature."""
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
return f.read(len(_PNG_SIGNATURE)) == _PNG_SIGNATURE
|
||||
@@ -478,27 +347,19 @@ def _is_png_file(path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ── X11 (xclip) ─────────────────────────────────────────────────────────
|
||||
|
||||
_XCLIP_TARGETS = ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"]
|
||||
|
||||
|
||||
def _xclip_has_image() -> bool:
|
||||
"""Check if X11 clipboard has image content."""
|
||||
return _probe(_XCLIP_TARGETS, 3, lambda r: r.returncode == 0 and "image/png" in r.stdout)
|
||||
|
||||
|
||||
def _xclip_save(dest: Path) -> bool:
|
||||
"""Use xclip to extract clipboard image (X11 sessions)."""
|
||||
# Check if clipboard has image content
|
||||
if not _probe(_XCLIP_TARGETS, 3, lambda r: "image/png" in r.stdout,
|
||||
missing="xclip not installed — X11 clipboard image paste unavailable"):
|
||||
return False
|
||||
|
||||
# Extract PNG data
|
||||
try:
|
||||
if _pipe_to_file(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], dest):
|
||||
return True
|
||||
return _pipe_to_file(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], dest)
|
||||
except Exception as e:
|
||||
logger.debug("xclip image extraction failed: %s", e)
|
||||
dest.unlink(missing_ok=True)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Migrate Hermes' MCP server config and Codex's installed curated plugins to the format Codex expects
|
||||
in ~/.codex/config.toml.
|
||||
"""Migrate Hermes MCP server config and Codex's installed curated plugins into ~/.codex/config.toml.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,14 +12,11 @@ from typing import Any, Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Marker comments wrapping the managed section so re-runs can detect
|
||||
# what's ours and what's user-edited. Both must appear or strip is a no-op.
|
||||
# Marker comments wrapping the managed section so re-runs can detect what's ours and what's
|
||||
# user-edited. Both must appear or strip is a no-op.
|
||||
MIGRATION_MARKER = (
|
||||
"# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section"
|
||||
)
|
||||
MIGRATION_END_MARKER = (
|
||||
"# end hermes-agent managed section"
|
||||
)
|
||||
"# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section")
|
||||
MIGRATION_END_MARKER = "# end hermes-agent managed section"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -47,302 +43,210 @@ class MigrationReport:
|
||||
lines.append(f"Migrated {len(self.migrated)} MCP server(s):")
|
||||
for name in self.migrated:
|
||||
skipped = self.skipped_keys_per_server.get(name, [])
|
||||
note = (
|
||||
f" (skipped: {', '.join(skipped)})" if skipped else ""
|
||||
)
|
||||
note = f" (skipped: {', '.join(skipped)})" if skipped else ""
|
||||
lines.append(f" - {name}{note}")
|
||||
else:
|
||||
lines.append("No MCP servers found in Hermes config.")
|
||||
if self.migrated_plugins:
|
||||
lines.append(
|
||||
f"Migrated {len(self.migrated_plugins)} native Codex plugin(s):"
|
||||
)
|
||||
for name in self.migrated_plugins:
|
||||
lines.append(f" - {name}")
|
||||
lines.append(f"Migrated {len(self.migrated_plugins)} native Codex plugin(s):")
|
||||
lines.extend(f" - {name}" for name in self.migrated_plugins)
|
||||
elif self.plugin_query_error:
|
||||
lines.append(f"Codex plugin discovery skipped: {self.plugin_query_error}")
|
||||
if self.wrote_permissions_default:
|
||||
lines.append(
|
||||
f"Wrote default_permissions = "
|
||||
f"{self.wrote_permissions_default!r}"
|
||||
)
|
||||
for err in self.errors:
|
||||
lines.append(f"⚠ {err}")
|
||||
lines.append(f"Wrote default_permissions = {self.wrote_permissions_default!r}")
|
||||
lines.extend(f"⚠ {err}" for err in self.errors)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Hermes keys that codex's MCP schema doesn't support — dropped during
|
||||
# migration with a warning. Anything not on the keep list AND not the
|
||||
# transport keys is added to skipped.
|
||||
# Hermes MCP keys codex understands (transport stdio/http, timeouts, general). Any other key is
|
||||
# dropped with a warning: ``sampling`` has no codex equivalent; the rest are unknown Hermes keys.
|
||||
_KNOWN_HERMES_KEYS = {
|
||||
# transport — stdio
|
||||
"command", "args", "env", "cwd",
|
||||
# transport — http
|
||||
"url", "headers", "transport",
|
||||
# timeouts
|
||||
"timeout", "connect_timeout",
|
||||
# general
|
||||
"enabled", "description",
|
||||
}
|
||||
"enabled", "description"}
|
||||
_KEYS_DROPPED_WITH_WARNING = {"sampling"}
|
||||
|
||||
# Subset that have a direct codex equivalent.
|
||||
_KEYS_DROPPED_WITH_WARNING = {
|
||||
# Hermes' sampling subsection — codex MCP has no equivalent
|
||||
"sampling",
|
||||
}
|
||||
# (hermes key, codex key, skip note) — timeouts are emitted as floats or skipped when non-numeric.
|
||||
_TIMEOUT_KEYS = (
|
||||
("timeout", "tool_timeout_sec", "timeout (not numeric)"),
|
||||
("connect_timeout", "startup_timeout_sec", "connect_timeout (not numeric)"),
|
||||
)
|
||||
|
||||
|
||||
def _translate_one_server(
|
||||
name: str, hermes_cfg: dict
|
||||
) -> tuple[Optional[dict], list[str]]:
|
||||
"""Translate one Hermes MCP server config to the codex inline-table dict representation. Returns
|
||||
(codex_entry, skipped_keys).
|
||||
def _str_map(d: dict) -> dict[str, str]:
|
||||
"""Codex expects string keys and values in env / header tables."""
|
||||
return {str(k): str(v) for k, v in d.items()}
|
||||
|
||||
|
||||
def _translate_one_server(name: str, hermes_cfg: dict) -> tuple[Optional[dict], list[str]]:
|
||||
"""Translate one Hermes MCP server config to codex's inline-table dict.
|
||||
|
||||
Returns ``(codex_entry, skipped_keys)``; ``codex_entry`` is None when the config is unusable.
|
||||
stdio (``command``) wins over ``url`` when both are set. Hermes' ``transport: sse`` hint is
|
||||
informational only — codex auto-negotiates. ``enabled`` is emitted only when explicitly false
|
||||
(codex defaults to true).
|
||||
"""
|
||||
if not isinstance(hermes_cfg, dict):
|
||||
return None, []
|
||||
|
||||
skipped: list[str] = []
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
has_command = bool(hermes_cfg.get("command"))
|
||||
has_url = bool(hermes_cfg.get("url"))
|
||||
|
||||
if has_command and has_url:
|
||||
skipped.append("url (both command and url set; preferring stdio)")
|
||||
has_url = False
|
||||
|
||||
if has_command:
|
||||
# Stdio transport
|
||||
if hermes_cfg.get("command"):
|
||||
if hermes_cfg.get("url"):
|
||||
skipped.append("url (both command and url set; preferring stdio)")
|
||||
out["command"] = str(hermes_cfg["command"])
|
||||
args = hermes_cfg.get("args") or []
|
||||
if args:
|
||||
out["args"] = [str(a) for a in args]
|
||||
env = hermes_cfg.get("env") or {}
|
||||
if env:
|
||||
# Codex expects string values
|
||||
out["env"] = {str(k): str(v) for k, v in env.items()}
|
||||
cwd = hermes_cfg.get("cwd")
|
||||
if cwd:
|
||||
out["cwd"] = str(cwd)
|
||||
elif has_url:
|
||||
# streamable_http transport (codex covers both http and SSE here)
|
||||
if hermes_cfg.get("args"):
|
||||
out["args"] = [str(a) for a in hermes_cfg["args"]]
|
||||
if hermes_cfg.get("env"):
|
||||
out["env"] = _str_map(hermes_cfg["env"])
|
||||
if hermes_cfg.get("cwd"):
|
||||
out["cwd"] = str(hermes_cfg["cwd"])
|
||||
elif hermes_cfg.get("url"):
|
||||
out["url"] = str(hermes_cfg["url"])
|
||||
headers = hermes_cfg.get("headers") or {}
|
||||
if headers:
|
||||
out["http_headers"] = {str(k): str(v) for k, v in headers.items()}
|
||||
# Hermes' transport: sse hint is informational; codex auto-negotiates
|
||||
if hermes_cfg.get("headers"):
|
||||
out["http_headers"] = _str_map(hermes_cfg["headers"])
|
||||
if hermes_cfg.get("transport") == "sse":
|
||||
skipped.append("transport=sse (codex auto-negotiates)")
|
||||
else:
|
||||
return None, ["no command or url field"]
|
||||
|
||||
# Timeouts
|
||||
if "timeout" in hermes_cfg:
|
||||
try:
|
||||
out["tool_timeout_sec"] = float(hermes_cfg["timeout"])
|
||||
except (TypeError, ValueError):
|
||||
skipped.append("timeout (not numeric)")
|
||||
if "connect_timeout" in hermes_cfg:
|
||||
try:
|
||||
out["startup_timeout_sec"] = float(hermes_cfg["connect_timeout"])
|
||||
except (TypeError, ValueError):
|
||||
skipped.append("connect_timeout (not numeric)")
|
||||
|
||||
# Enabled flag (codex defaults to true so we only emit when explicitly false)
|
||||
for hermes_key, codex_key, note in _TIMEOUT_KEYS:
|
||||
if hermes_key in hermes_cfg:
|
||||
try:
|
||||
out[codex_key] = float(hermes_cfg[hermes_key])
|
||||
except (TypeError, ValueError):
|
||||
skipped.append(note)
|
||||
if hermes_cfg.get("enabled") is False:
|
||||
out["enabled"] = False
|
||||
|
||||
# Detect keys we explicitly drop with warning
|
||||
for key in hermes_cfg:
|
||||
if key in _KEYS_DROPPED_WITH_WARNING:
|
||||
skipped.append(f"{key} (no codex equivalent)")
|
||||
elif key not in _KNOWN_HERMES_KEYS:
|
||||
skipped.append(f"{key} (unknown Hermes key)")
|
||||
|
||||
return out, skipped
|
||||
|
||||
|
||||
def _format_toml_value(value: Any) -> str:
|
||||
"""Minimal TOML value formatter for the value types we emit.
|
||||
# TOML basic-string escapes. Order matters: backslash first so the others aren't re-escaped.
|
||||
# Control chars must be \-escaped — a literal one is invalid TOML that codex refuses to load;
|
||||
# env-var passthrough (HERMES_HOME, PYTHONPATH) could carry one in pathological cases.
|
||||
_TOML_ESCAPES = (
|
||||
("\\", "\\\\"), ('"', '\\"'), ("\b", "\\b"), ("\t", "\\t"),
|
||||
("\n", "\\n"), ("\f", "\\f"), ("\r", "\\r"),
|
||||
)
|
||||
|
||||
We only emit strings, numbers, booleans, and tables of those — no nested arrays of tables. This
|
||||
covers everything codex's MCP schema accepts.
|
||||
"""
|
||||
|
||||
def _escape_toml_string(value: str) -> str:
|
||||
for raw, esc in _TOML_ESCAPES:
|
||||
value = value.replace(raw, esc)
|
||||
return f'"{value}"'
|
||||
|
||||
|
||||
def _format_toml_value(value: Any) -> str:
|
||||
"""Minimal TOML formatter for what we emit: strings, numbers, bools, and flat lists/tables of
|
||||
those (everything codex's MCP schema accepts; no nested arrays of tables)."""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return repr(value)
|
||||
if isinstance(value, str):
|
||||
# Escape per TOML basic-string rules. Order matters: backslash
|
||||
# first so the other escapes don't get re-escaped.
|
||||
# Control characters (newline, tab, etc.) must use \-escapes
|
||||
# because TOML basic strings don't allow literal control chars
|
||||
# — passing them through would produce invalid TOML that codex
|
||||
# would refuse to load. Paths usually don't contain control
|
||||
# chars but env-var passthrough (HERMES_HOME, PYTHONPATH) could
|
||||
# in pathological cases.
|
||||
escaped = (
|
||||
value
|
||||
.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\b", "\\b")
|
||||
.replace("\t", "\\t")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\f", "\\f")
|
||||
.replace("\r", "\\r")
|
||||
)
|
||||
return f'"{escaped}"'
|
||||
return _escape_toml_string(value)
|
||||
if isinstance(value, list):
|
||||
items = ", ".join(_format_toml_value(v) for v in value)
|
||||
return f"[{items}]"
|
||||
return f"[{', '.join(_format_toml_value(v) for v in value)}]"
|
||||
if isinstance(value, dict):
|
||||
items = ", ".join(
|
||||
f'{_quote_key(k)} = {_format_toml_value(v)}' for k, v in value.items()
|
||||
)
|
||||
items = ", ".join(f'{_quote_key(k)} = {_format_toml_value(v)}' for k, v in value.items())
|
||||
return "{ " + items + " }" if items else "{}"
|
||||
raise ValueError(f"Unsupported TOML value type: {type(value).__name__}")
|
||||
|
||||
|
||||
def _quote_key(key: str) -> str:
|
||||
"""Return key bare-or-quoted depending on whether it's a valid bare key."""
|
||||
if all(c.isalnum() or c in "-_" for c in key) and key:
|
||||
if key and all(c.isalnum() or c in "-_" for c in key):
|
||||
return key
|
||||
escaped = key.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
return '"' + key.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def render_codex_toml_section(
|
||||
servers: dict[str, dict],
|
||||
plugins: Optional[list[dict]] = None,
|
||||
default_permission_profile: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Render the managed [mcp_servers.<n>] / [plugins.<id>] / [permissions] block for
|
||||
~/.codex/config.toml.
|
||||
servers: dict[str, dict], plugins: Optional[list[dict]] = None,
|
||||
default_permission_profile: Optional[str] = None) -> str:
|
||||
"""Render the managed [mcp_servers.<n>] / [plugins.<id>] / default_permissions block.
|
||||
|
||||
``default_permission_profile`` (e.g. "workspace-write", "read-only", "full-access") writes
|
||||
``[permissions] default`` so the user is not prompted on every write attempt.
|
||||
``default_permission_profile`` (e.g. "workspace-write", "read-only", "full-access") is written
|
||||
as the top-level ``default_permissions`` string so the user is not prompted on every write.
|
||||
Built-in profile names start with ":" (":workspace-write", ...) and are normalized to that
|
||||
form; the ``[permissions]`` table is for *user-defined* named profiles, not what we want.
|
||||
"""
|
||||
out = [MIGRATION_MARKER]
|
||||
if not servers and not plugins and not default_permission_profile:
|
||||
out.append("# (no MCP servers, plugins, or permissions configured by Hermes)")
|
||||
out.append(MIGRATION_END_MARKER)
|
||||
out += ["# (no MCP servers, plugins, or permissions configured by Hermes)", MIGRATION_END_MARKER]
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
if default_permission_profile:
|
||||
# Codex's config schema: `default_permissions` is a top-level
|
||||
# string referencing a profile name. Built-in profile names start
|
||||
# with ":" (":workspace-write", ":read-only", ":full-access"). The
|
||||
# [permissions] table is for *user-defined* named profiles with
|
||||
# structured fields — not what we want.
|
||||
normalized = (
|
||||
default_permission_profile
|
||||
if default_permission_profile.startswith(":")
|
||||
else f":{default_permission_profile}"
|
||||
)
|
||||
out.append("")
|
||||
out.append(f"default_permissions = {_format_toml_value(normalized)}")
|
||||
|
||||
if servers:
|
||||
for name in sorted(servers.keys()):
|
||||
cfg = servers[name]
|
||||
out.append("")
|
||||
out.append(f"[mcp_servers.{_quote_key(name)}]")
|
||||
for k, v in cfg.items():
|
||||
out.append(f"{_quote_key(k)} = {_format_toml_value(v)}")
|
||||
|
||||
if plugins:
|
||||
for plugin in sorted(plugins, key=lambda p: f"{p.get('name','')}@{p.get('marketplace','')}"):
|
||||
name = plugin.get("name") or ""
|
||||
marketplace = plugin.get("marketplace") or "openai-curated"
|
||||
enabled = bool(plugin.get("enabled", True))
|
||||
qualified = f"{name}@{marketplace}"
|
||||
out.append("")
|
||||
out.append(f'[plugins.{_quote_key(qualified)}]')
|
||||
out.append(f"enabled = {_format_toml_value(enabled)}")
|
||||
|
||||
out.append("")
|
||||
out.append(MIGRATION_END_MARKER)
|
||||
profile = default_permission_profile
|
||||
normalized = profile if profile.startswith(":") else f":{profile}"
|
||||
out += ["", f"default_permissions = {_format_toml_value(normalized)}"]
|
||||
for name in sorted(servers or ()):
|
||||
out += ["", f"[mcp_servers.{_quote_key(name)}]"]
|
||||
out += [f"{_quote_key(k)} = {_format_toml_value(v)}" for k, v in servers[name].items()]
|
||||
plugin_sort_key = lambda p: f"{p.get('name','')}@{p.get('marketplace','')}" # noqa: E731
|
||||
for plugin in sorted(plugins or (), key=plugin_sort_key):
|
||||
qualified = f"{plugin.get('name') or ''}@{plugin.get('marketplace') or 'openai-curated'}"
|
||||
out += ["", f'[plugins.{_quote_key(qualified)}]',
|
||||
f"enabled = {_format_toml_value(bool(plugin.get('enabled', True)))}"]
|
||||
out += ["", MIGRATION_END_MARKER]
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def _insert_managed_block_at_top_level(user_text: str, managed_block: str) -> str:
|
||||
"""Insert Hermes' managed Codex TOML block while keeping root keys root-scoped.
|
||||
"""Insert the managed block before the user's first table header.
|
||||
|
||||
TOML has no syntax to return to the document root after a table header. Therefore appending a
|
||||
root key like `default_permissions = ...` after a user table such as `[features]` actually
|
||||
creates `features.default_permissions`, which Codex rejects.
|
||||
TOML has no syntax to return to the document root after a table header, so appending a root
|
||||
key like ``default_permissions = ...`` after a user ``[features]`` table would actually create
|
||||
``features.default_permissions``, which codex rejects.
|
||||
"""
|
||||
if not user_text.strip():
|
||||
return managed_block
|
||||
|
||||
lines = user_text.splitlines(keepends=True)
|
||||
first_table_idx: Optional[int] = None
|
||||
for idx, line in enumerate(lines):
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("["):
|
||||
first_table_idx = idx
|
||||
break
|
||||
|
||||
first_table_idx = next((i for i, ln in enumerate(lines) if ln.lstrip().startswith("[")), None)
|
||||
if first_table_idx is None:
|
||||
prefix = user_text.rstrip("\n")
|
||||
return f"{prefix}\n\n{managed_block}" if prefix else managed_block
|
||||
|
||||
prefix = "".join(lines[:first_table_idx]).rstrip("\n")
|
||||
suffix = "".join(lines[first_table_idx:]).lstrip("\n")
|
||||
if prefix:
|
||||
return f"{prefix}\n\n{managed_block}\n{suffix}"
|
||||
return f"{managed_block}\n{suffix}"
|
||||
return f"{prefix}\n\n{managed_block}\n{suffix}" if prefix else f"{managed_block}\n{suffix}"
|
||||
|
||||
|
||||
def _strip_unmanaged_plugin_tables(toml_text: str) -> str:
|
||||
"""Remove ``[plugins."<name>@<marketplace>"]`` tables that live OUTSIDE the managed block.
|
||||
|
||||
Codex itself writes these tables when the user runs ``codex plugins enable`` directly (i.e.
|
||||
before Hermes' migrate has ever touched the file). When we later run migrate,
|
||||
``_query_codex_plugins()`` reports the same plugins via the live ``plugin/list`` RPC and we re-
|
||||
emit them inside the managed block.
|
||||
|
||||
We own the ``[plugins.*]`` namespace once migrate has run, so dropping any pre-existing
|
||||
``[plugins.*]`` tables is safe: ``plugin/list`` is the source of truth for what's actually
|
||||
installed.
|
||||
Codex writes these when the user runs ``codex plugins enable`` directly, before migrate has
|
||||
ever touched the file. Once migrate runs, ``plugin/list`` is the source of truth for what is
|
||||
installed and we own the ``[plugins.*]`` namespace, so dropping pre-existing tables is safe —
|
||||
and necessary, since re-emitting them inside the managed block would otherwise produce
|
||||
duplicate-table-header parse errors on codex's next startup.
|
||||
"""
|
||||
lines = toml_text.splitlines(keepends=True)
|
||||
out: list[str] = []
|
||||
in_plugin_table = False
|
||||
for line in lines:
|
||||
for line in toml_text.splitlines(keepends=True):
|
||||
stripped = line.lstrip()
|
||||
# Only treat a line as a table header when it has the shape
|
||||
# ``[...]`` (optionally followed by a comment). Multi-line array
|
||||
# continuations like ``["nested"],`` also start with ``[`` after
|
||||
# lstrip but are not headers — without this guard they would
|
||||
# falsely flip ``in_plugin_table`` to False mid-table and leak
|
||||
# Only a real ``[...]`` header flips state: multi-line array continuations like
|
||||
# ``["nested"],`` also start with ``[`` but must not end the table early and leak
|
||||
# array fragments into the output.
|
||||
if _looks_like_table_header(stripped):
|
||||
in_plugin_table = stripped.startswith("[plugins.")
|
||||
if in_plugin_table:
|
||||
continue
|
||||
if in_plugin_table:
|
||||
# Swallow keys/comments/blanks until the next table header.
|
||||
continue
|
||||
out.append(line)
|
||||
if not in_plugin_table: # swallow keys/comments/blanks until the next table header
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _looks_like_table_header(stripped_line: str) -> bool:
|
||||
"""Return True if ``stripped_line`` is a TOML table header.
|
||||
|
||||
A header has the shape ``[name]`` or ``[[name]]`` (array-of-tables), optionally followed by a
|
||||
comment. The closing ``]`` (or ``]]``) must appear on the same line, and no key-assignment ``=``
|
||||
can precede it.
|
||||
"""
|
||||
"""True for ``[name]`` / ``[[name]]`` headers (optional trailing comment); the closing ``]``
|
||||
must be on the same line and no ``=`` may precede it (``key = [x]`` is not a header)."""
|
||||
if not stripped_line.startswith("["):
|
||||
return False
|
||||
# Drop trailing comment so e.g. ``[features] # note`` still matches.
|
||||
head = stripped_line.split("#", 1)[0].rstrip()
|
||||
if not head.endswith("]"):
|
||||
return False
|
||||
# ``key = [x]`` would have an ``=`` before the bracket; a header doesn't.
|
||||
bracket_idx = head.index("]")
|
||||
return "=" not in head[: bracket_idx + 1]
|
||||
return head.endswith("]") and "=" not in head[: head.index("]") + 1]
|
||||
|
||||
|
||||
# Section headers an old-format managed block (no end marker) may contain.
|
||||
_LEGACY_MANAGED_HEADERS = ("[mcp_servers", "[plugins", "[permissions]", "[permissions.")
|
||||
|
||||
|
||||
def _strip_existing_managed_block(toml_text: str) -> str:
|
||||
@@ -350,268 +254,184 @@ def _strip_existing_managed_block(toml_text: str) -> str:
|
||||
|
||||
The managed section spans MIGRATION_MARKER through MIGRATION_END_MARKER inclusive; user text
|
||||
outside it is preserved verbatim. If the start marker exists without an end marker (older
|
||||
writers), fall back to swallowing lines until a section that is not [mcp_servers.*]/
|
||||
[plugins.*]/[permissions]/``default_permissions =``, so prior-version configs still migrate.
|
||||
writers), swallow lines until a section that is not [mcp_servers.*]/[plugins.*]/[permissions]
|
||||
so prior-version configs still migrate.
|
||||
"""
|
||||
lines = toml_text.splitlines(keepends=True)
|
||||
out: list[str] = []
|
||||
in_managed = False
|
||||
saw_end_marker = False
|
||||
for line in lines:
|
||||
line_stripped_nl = line.rstrip("\n")
|
||||
if line_stripped_nl == MIGRATION_MARKER:
|
||||
for line in toml_text.splitlines(keepends=True):
|
||||
bare = line.rstrip("\n")
|
||||
if bare == MIGRATION_MARKER:
|
||||
in_managed = True
|
||||
saw_end_marker = False
|
||||
continue
|
||||
if in_managed:
|
||||
if line_stripped_nl == MIGRATION_END_MARKER:
|
||||
if bare == MIGRATION_END_MARKER:
|
||||
in_managed = False
|
||||
saw_end_marker = True
|
||||
continue
|
||||
stripped = line.lstrip()
|
||||
if not saw_end_marker and stripped.startswith("[") and not (
|
||||
stripped.startswith("[mcp_servers")
|
||||
or stripped.startswith("[plugins")
|
||||
or stripped.startswith("[permissions]")
|
||||
or stripped.startswith("[permissions.")
|
||||
):
|
||||
# Old-format managed block without end marker: bail back
|
||||
# to user content as soon as we see a non-managed section.
|
||||
in_managed = False
|
||||
out.append(line)
|
||||
if not stripped.startswith("[") or stripped.startswith(_LEGACY_MANAGED_HEADERS):
|
||||
continue
|
||||
# Otherwise swallow the line.
|
||||
continue
|
||||
in_managed = False # legacy block: first non-managed section ends it
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _query_codex_plugins(
|
||||
codex_home: Optional[Path] = None,
|
||||
timeout: float = 8.0,
|
||||
) -> tuple[list[dict], Optional[str]]:
|
||||
"""Query codex's `plugin/list` for installed curated plugins.
|
||||
|
||||
Spawns ``codex app-server`` briefly, sends initialize + plugin/list and keeps installed=true.
|
||||
Returns ``(plugins, error)``; any failure (not installed, RPC error, timeout) yields ``([],
|
||||
error)`` and is non-fatal — MCP servers and permissions still write through.
|
||||
codex_home: Optional[Path] = None, timeout: float = 8.0) -> tuple[list[dict], Optional[str]]:
|
||||
"""Spawn ``codex app-server`` briefly and return ``(installed plugins, error)`` from
|
||||
``plugin/list``. Any failure yields ``([], error)`` and is non-fatal (servers and
|
||||
permissions still write). Plugins codex reports unavailable (broken install, missing OAuth,
|
||||
delisted) are skipped — we write config.toml directly, so they would surface as a codex
|
||||
error on the first turn. ``enabled`` is carried forward as reported.
|
||||
"""
|
||||
try:
|
||||
from agent.transports.codex_app_server import CodexAppServerClient
|
||||
except Exception as exc:
|
||||
return [], f"transport unavailable: {exc}"
|
||||
|
||||
try:
|
||||
with CodexAppServerClient(
|
||||
codex_home=str(codex_home) if codex_home else None
|
||||
) as client:
|
||||
with CodexAppServerClient(codex_home=str(codex_home) if codex_home else None) as client:
|
||||
client.initialize(client_name="hermes-migration")
|
||||
resp = client.request("plugin/list", {}, timeout=timeout)
|
||||
except Exception as exc:
|
||||
return [], f"plugin/list query failed: {exc}"
|
||||
|
||||
out: list[dict] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
marketplaces = resp.get("marketplaces") or []
|
||||
if not isinstance(marketplaces, list):
|
||||
return [], "plugin/list response missing 'marketplaces'"
|
||||
out: dict[tuple[str, str], dict] = {} # (name, marketplace) -> entry; first wins
|
||||
for marketplace in marketplaces:
|
||||
if not isinstance(marketplace, dict):
|
||||
continue
|
||||
market_name = str(marketplace.get("name") or "openai-curated")
|
||||
plugins = marketplace.get("plugins") or []
|
||||
if not isinstance(plugins, list):
|
||||
continue
|
||||
for plugin in plugins:
|
||||
if not isinstance(plugin, dict):
|
||||
for plugin in plugins if isinstance(plugins, list) else ():
|
||||
if not isinstance(plugin, dict) or not plugin.get("installed", False):
|
||||
continue
|
||||
installed = bool(plugin.get("installed", False))
|
||||
if not installed:
|
||||
continue
|
||||
# Skip plugins codex itself reports as unavailable (broken
|
||||
# install, missing OAuth, removed from marketplace, etc.).
|
||||
# Cf. openclaw/openclaw#80815 — OpenClaw learned to gate
|
||||
# migration on app readiness to avoid writing config that
|
||||
# would fail at activation time. Our migration writes to
|
||||
# codex's config.toml directly, so a broken plugin would
|
||||
# surface as a codex error on first use. Skipping it here
|
||||
# keeps the migrated config clean and the user's first
|
||||
# codex turn from failing.
|
||||
availability = str(plugin.get("availability") or "").upper()
|
||||
if availability and availability != "AVAILABLE":
|
||||
logger.debug(
|
||||
"skipping plugin %s: availability=%s",
|
||||
plugin.get("name"), availability,
|
||||
)
|
||||
logger.debug("skipping plugin %s: availability=%s", plugin.get("name"),
|
||||
availability)
|
||||
continue
|
||||
name = str(plugin.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
key = (name, market_name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
# Carry forward whatever 'enabled' codex reports — defaults to
|
||||
# true for installed plugins. This is the same shape OpenClaw
|
||||
# writes when migrating native codex plugins.
|
||||
out.append({
|
||||
"name": name,
|
||||
"marketplace": market_name,
|
||||
"enabled": bool(plugin.get("enabled", True)),
|
||||
})
|
||||
return out, None
|
||||
if name:
|
||||
out.setdefault((name, market_name), {
|
||||
"name": name, "marketplace": market_name,
|
||||
"enabled": bool(plugin.get("enabled", True))})
|
||||
return list(out.values()), None
|
||||
|
||||
|
||||
# pytest tempdir shapes: ``pytest-of-<user>/pytest-<n>/``, macOS ``/private/var/folders/…/T``.
|
||||
_TEST_TEMPDIR_NEEDLES = ("pytest-of-", "/pytest-", "/tmp/pytest", "/private/var/folders/")
|
||||
|
||||
|
||||
def _looks_like_test_tempdir(path: str) -> bool:
|
||||
"""Heuristic: does ``path`` look like a pytest/transient tempdir?
|
||||
|
||||
pytest tempdirs (``pytest-of-<user>/pytest-<n>/``, on macOS under ``/private/var/folders/…/T``)
|
||||
are reaped between sessions; a HERMES_HOME pointing there burned into ``~/.codex/config.toml``
|
||||
makes every codex-routed call fail silently once GC'd. Err on refusing: a false positive is far
|
||||
less harmful than silently bricking codex's tool surface.
|
||||
Such dirs are reaped between sessions; a HERMES_HOME pointing there burned into
|
||||
``~/.codex/config.toml`` makes every codex-routed call fail silently once GC'd. Err on
|
||||
refusing: a false positive is far less harmful than silently bricking codex's tool surface.
|
||||
"""
|
||||
if not path:
|
||||
return False
|
||||
needles = (
|
||||
"pytest-of-",
|
||||
"/pytest-",
|
||||
"/tmp/pytest",
|
||||
"/private/var/folders/", # macOS tempdir root
|
||||
)
|
||||
normalized = path.lower()
|
||||
return any(needle in normalized for needle in needles)
|
||||
return bool(path) and any(needle in path.lower() for needle in _TEST_TEMPDIR_NEEDLES)
|
||||
|
||||
|
||||
def _build_hermes_tools_mcp_entry() -> dict:
|
||||
"""Build the codex stdio-transport entry that launches Hermes' own tool surface as an MCP server.
|
||||
Codex's subprocess will call back into this for browser/web/delegate_task/vision/memory/skills
|
||||
tools.
|
||||
"""Codex stdio entry launching Hermes' own tool surface as an MCP server (browser/web/
|
||||
delegate_task/vision/memory/skills call-backs).
|
||||
|
||||
HERMES_HOME passes through only IF SET, read from os.environ (not get_hermes_home()): when
|
||||
unset the codex subprocess must inherit its launcher's runtime HERMES_HOME (systemd, gateway,
|
||||
kanban), not a migrate-time default burned into config.toml that pins the wrong profile. The
|
||||
pytest-tempdir guard keeps a sibling test's monkeypatched HERMES_HOME out of the user's real
|
||||
config. PYTHONPATH passes through so a worktree-launched hermes finds the branch's modules.
|
||||
"""
|
||||
import sys
|
||||
|
||||
env: dict[str, str] = {}
|
||||
# HERMES_HOME passes through IF SET so the MCP subprocess sees the same
|
||||
# config / auth / sessions DB as the parent CLI. Read from os.environ
|
||||
# (not get_hermes_home()) on purpose: when the env var is unset we want
|
||||
# codex's subprocess to inherit whatever HERMES_HOME its launcher sets
|
||||
# at runtime (systemd unit, gateway, kanban dispatcher, custom shell),
|
||||
# rather than burning the migrate-time resolved default into config.toml
|
||||
# — that would override the launcher's HERMES_HOME and pin the subprocess
|
||||
# to the wrong profile.
|
||||
#
|
||||
# The pytest-tempdir guard below catches the issue #26250 Bug C scenario:
|
||||
# a sibling test's monkeypatch.setenv("HERMES_HOME", tmp_path) would
|
||||
# otherwise leak a transient pytest tempdir into the user's real
|
||||
# ~/.codex/config.toml and silently brick codex once the tempdir is GC'd.
|
||||
hermes_home = os.environ.get("HERMES_HOME") or ""
|
||||
if hermes_home and _looks_like_test_tempdir(hermes_home):
|
||||
hermes_home = ""
|
||||
if hermes_home:
|
||||
if hermes_home and not _looks_like_test_tempdir(hermes_home):
|
||||
env["HERMES_HOME"] = hermes_home
|
||||
# PYTHONPATH passes through so a worktree-launched hermes finds the
|
||||
# branch's modules instead of the installed package.
|
||||
pythonpath = os.environ.get("PYTHONPATH")
|
||||
if pythonpath:
|
||||
env["PYTHONPATH"] = pythonpath
|
||||
if os.environ.get("PYTHONPATH"):
|
||||
env["PYTHONPATH"] = os.environ["PYTHONPATH"]
|
||||
# Quiet mode + redaction defaults so the MCP wire stays clean.
|
||||
env["HERMES_QUIET"] = "1"
|
||||
env["HERMES_REDACT_SECRETS"] = env.get("HERMES_REDACT_SECRETS", "true")
|
||||
|
||||
out: dict[str, Any] = {
|
||||
return {
|
||||
"command": sys.executable,
|
||||
"args": ["-m", "agent.transports.hermes_tools_mcp_server"],
|
||||
}
|
||||
if env:
|
||||
out["env"] = env
|
||||
# Generous timeouts — browser_navigate or delegate_task can take a
|
||||
# while; we don't want codex's MCP client to give up too early.
|
||||
out["startup_timeout_sec"] = 30.0
|
||||
out["tool_timeout_sec"] = 600.0
|
||||
return out
|
||||
"env": env,
|
||||
# Generous timeouts — browser_navigate or delegate_task can take a while.
|
||||
"startup_timeout_sec": 30.0,
|
||||
"tool_timeout_sec": 600.0}
|
||||
|
||||
|
||||
def _write_atomic(target: Path, text: str) -> None:
|
||||
"""Write via a same-directory temp file + rename (atomic on POSIX, ReplaceFile on Windows) so a
|
||||
crash mid-write never leaves a half-written config.toml that codex would refuse to load."""
|
||||
import tempfile
|
||||
tmp_fd, tmp_path_str = tempfile.mkstemp(prefix=".config.toml.", dir=str(target.parent))
|
||||
tmp_path = Path(tmp_path_str)
|
||||
try:
|
||||
with os.fdopen(tmp_fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
tmp_path.replace(target)
|
||||
except Exception:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def migrate(
|
||||
hermes_config: dict,
|
||||
*,
|
||||
codex_home: Optional[Path] = None,
|
||||
dry_run: bool = False,
|
||||
discover_plugins: bool = True,
|
||||
default_permission_profile: Optional[str] = ":workspace",
|
||||
expose_hermes_tools: bool = True,
|
||||
) -> MigrationReport:
|
||||
hermes_config: dict, *, codex_home: Optional[Path] = None, dry_run: bool = False,
|
||||
discover_plugins: bool = True, default_permission_profile: Optional[str] = ":workspace",
|
||||
expose_hermes_tools: bool = True) -> MigrationReport:
|
||||
"""Translate Hermes mcp_servers config + Codex curated plugins into ~/.codex/config.toml.
|
||||
|
||||
``discover_plugins`` spawns the live codex CLI (set False in tests).
|
||||
``default_permission_profile`` (default ":workspace"; built-ins carry a leading ":", user
|
||||
profiles do not; None leaves codex's read-only default) avoids an approval prompt on every
|
||||
write. ``expose_hermes_tools`` registers Hermes' own tool surface as an MCP server so the codex
|
||||
subprocess can call back for tools it lacks.
|
||||
``discover_plugins`` spawns the live codex CLI (set False in tests); discovery is best-effort
|
||||
and never blocks the migration. ``default_permission_profile`` (default ":workspace"; built-ins
|
||||
carry a leading ":", user profiles do not; None leaves codex's read-only default) avoids an
|
||||
approval prompt on every write. ``expose_hermes_tools`` registers Hermes' own tool surface
|
||||
(agent/transports/hermes_tools_mcp_server.py, launched on demand by codex over stdio) as an MCP
|
||||
server so the codex subprocess can call back for tools it lacks.
|
||||
"""
|
||||
report = MigrationReport(dry_run=dry_run)
|
||||
codex_home = codex_home or Path.home() / ".codex"
|
||||
target = codex_home / "config.toml"
|
||||
report.target_path = target
|
||||
|
||||
hermes_servers = (hermes_config or {}).get("mcp_servers") or {}
|
||||
if not isinstance(hermes_servers, dict):
|
||||
report.errors.append(
|
||||
"mcp_servers in Hermes config is not a dict; cannot migrate."
|
||||
)
|
||||
report.errors.append("mcp_servers in Hermes config is not a dict; cannot migrate.")
|
||||
return report
|
||||
|
||||
translated: dict[str, dict] = {}
|
||||
for name, cfg in hermes_servers.items():
|
||||
out, skipped = _translate_one_server(str(name), cfg or {})
|
||||
for raw_name, cfg in hermes_servers.items():
|
||||
name = str(raw_name)
|
||||
out, skipped = _translate_one_server(name, cfg or {})
|
||||
if out is None:
|
||||
report.errors.append(
|
||||
f"server {name!r} skipped: {', '.join(skipped) or 'no transport configured'}"
|
||||
)
|
||||
reason = ", ".join(skipped) or "no transport configured"
|
||||
report.errors.append(f"server {raw_name!r} skipped: {reason}")
|
||||
continue
|
||||
translated[str(name)] = out
|
||||
translated[name] = out
|
||||
if skipped:
|
||||
report.skipped_keys_per_server[str(name)] = skipped
|
||||
report.migrated.append(str(name))
|
||||
|
||||
# Discover installed Codex curated plugins. Best-effort — never blocks
|
||||
# the migration if codex is unreachable or the RPC fails.
|
||||
report.skipped_keys_per_server[name] = skipped
|
||||
report.migrated.append(name)
|
||||
plugins: list[dict] = []
|
||||
plugin_query_succeeded = False
|
||||
if discover_plugins and not dry_run:
|
||||
plugins, plugin_err = _query_codex_plugins(codex_home=codex_home)
|
||||
if plugin_err:
|
||||
report.plugin_query_error = plugin_err
|
||||
else:
|
||||
# plugin/list returned authoritatively (even if the list is empty).
|
||||
# That means we own [plugins.*] for this re-render and can safely
|
||||
# strip any pre-existing tables outside the managed block.
|
||||
plugin_query_succeeded = True
|
||||
for p in plugins:
|
||||
report.migrated_plugins.append(f"{p['name']}@{p['marketplace']}")
|
||||
|
||||
# Track whether we wrote a default permission profile so the report
|
||||
# surfaces it to the user.
|
||||
# An authoritative plugin/list (even an empty one) means we own [plugins.*] for this
|
||||
# re-render and may strip pre-existing tables outside the managed block.
|
||||
plugin_query_succeeded = not plugin_err
|
||||
report.migrated_plugins += [f"{p['name']}@{p['marketplace']}" for p in plugins]
|
||||
if default_permission_profile:
|
||||
report.wrote_permissions_default = default_permission_profile
|
||||
|
||||
# Inject Hermes' own tool surface as an MCP server so the spawned
|
||||
# codex subprocess can call back into Hermes for the tools codex
|
||||
# doesn't ship with — web_search, browser_*, delegate_task, vision,
|
||||
# memory, skills, session_search, image_generate, text_to_speech.
|
||||
# The server itself is agent/transports/hermes_tools_mcp_server.py
|
||||
# and is launched on demand by codex (stdio MCP).
|
||||
if expose_hermes_tools:
|
||||
translated["hermes-tools"] = _build_hermes_tools_mcp_entry()
|
||||
if "hermes-tools" not in report.migrated:
|
||||
report.migrated.append("hermes-tools")
|
||||
|
||||
# Build the new managed block
|
||||
managed_block = render_codex_toml_section(
|
||||
translated, plugins=plugins,
|
||||
default_permission_profile=default_permission_profile,
|
||||
)
|
||||
|
||||
# Read existing codex config if any, strip the prior managed block,
|
||||
# append the new one.
|
||||
translated, plugins=plugins, default_permission_profile=default_permission_profile)
|
||||
new_text = managed_block
|
||||
if target.exists():
|
||||
try:
|
||||
existing = target.read_text(encoding="utf-8")
|
||||
@@ -619,44 +439,14 @@ def migrate(
|
||||
report.errors.append(f"could not read {target}: {exc}")
|
||||
return report
|
||||
without_managed = _strip_existing_managed_block(existing)
|
||||
# Bug B: when plugin/list ran authoritatively, codex's own
|
||||
# [plugins."<name>@<marketplace>"] tables outside our managed block
|
||||
# would survive _strip_existing_managed_block and then collide with
|
||||
# the entries we re-emit inside the managed block — producing
|
||||
# duplicate-table-header parse errors on codex's next startup. Drop
|
||||
# those pre-existing tables since plugin/list is the source of truth.
|
||||
if plugin_query_succeeded:
|
||||
without_managed = _strip_unmanaged_plugin_tables(without_managed)
|
||||
new_text = _insert_managed_block_at_top_level(without_managed, managed_block)
|
||||
else:
|
||||
new_text = managed_block
|
||||
|
||||
if dry_run:
|
||||
return report
|
||||
|
||||
try:
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
# Atomic write: write to a temp file in the same directory then
|
||||
# rename. Same-directory rename is atomic on POSIX and ReplaceFile
|
||||
# on Windows. Avoids leaving a half-written config.toml that
|
||||
# codex would refuse to load if we crash mid-write.
|
||||
import tempfile
|
||||
tmp_fd, tmp_path_str = tempfile.mkstemp(
|
||||
prefix=".config.toml.", dir=str(codex_home)
|
||||
)
|
||||
tmp_path = Path(tmp_path_str)
|
||||
try:
|
||||
with os.fdopen(tmp_fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(new_text)
|
||||
tmp_path.replace(target)
|
||||
except Exception:
|
||||
# Clean up the temp file if the rename didn't happen.
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
_write_atomic(target, new_text)
|
||||
report.written = True
|
||||
except Exception as exc:
|
||||
report.errors.append(f"could not write {target}: {exc}")
|
||||
|
||||
@@ -254,9 +254,9 @@ class TestCliEnsureRuntimeCredentialsCallable:
|
||||
# module the method actually lives in now.
|
||||
src = (Path(__file__).resolve().parent.parent.parent
|
||||
/ "hermes_cli" / "cli_agent_setup_mixin.py").read_text()
|
||||
# The fix introduces ``_is_callable_provider`` which gates the
|
||||
# string-only check so callable token providers survive.
|
||||
assert "_is_callable_provider = callable(api_key)" in src, (
|
||||
# The fix gates the string-only check on ``callable(api_key)`` so callable
|
||||
# token providers survive.
|
||||
assert "if not callable(api_key) and not (isinstance(api_key, str) and api_key):" in src, (
|
||||
"_ensure_runtime_credentials must preserve a callable "
|
||||
"api_key (Entra ID bearer provider). Without the guard, the "
|
||||
"callable is stringified to 'no-key-required' and Azure 401s."
|
||||
|
||||
Reference in New Issue
Block a user