Merge branch 'simp/r3-17-g6-misc' into simp/r3-17

This commit is contained in:
Teknium
2026-09-02 23:12:28 -07:00
7 changed files with 1285 additions and 2279 deletions
+300 -483
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+207 -417
View File
@@ -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}")
+3 -3
View File
@@ -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."