Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
+9
-1
@@ -235,6 +235,9 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
|
||||
"""
|
||||
findings: list[tuple[Path, str]] = []
|
||||
|
||||
if not source_dir.exists():
|
||||
return findings
|
||||
|
||||
# Direct state files in the root
|
||||
for name in ("todo.json", "sessions", "logs"):
|
||||
candidate = source_dir / name
|
||||
@@ -243,7 +246,12 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
|
||||
findings.append((candidate, f"Root {kind}: {name}"))
|
||||
|
||||
# State files inside workspace directories
|
||||
for child in sorted(source_dir.iterdir()):
|
||||
try:
|
||||
children = sorted(source_dir.iterdir())
|
||||
except OSError:
|
||||
return findings
|
||||
|
||||
for child in children:
|
||||
if not child.is_dir() or child.name.startswith("."):
|
||||
continue
|
||||
# Check for workspace-like subdirectories
|
||||
|
||||
@@ -781,6 +781,11 @@ DEFAULT_CONFIG = {
|
||||
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
# UI language for static user-facing messages (approval prompts, a
|
||||
# handful of gateway slash-command replies). Does NOT affect agent
|
||||
# responses, log lines, tool outputs, or slash-command descriptions.
|
||||
# Supported: en, zh, ja, de, es. Unknown values fall back to en.
|
||||
"language": "en",
|
||||
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
|
||||
# spinner), or ascii. Live-swappable via `/indicator <style>`.
|
||||
"tui_status_indicator": "kaomoji",
|
||||
|
||||
@@ -245,6 +245,111 @@ def _cmd_restore(args) -> int:
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _cmd_archive(args) -> int:
|
||||
"""Manually archive an agent-created skill. Refuses if pinned.
|
||||
|
||||
The auto-curator archives stale skills on its own schedule; this verb is
|
||||
for the user who wants to archive *now* without waiting for a run.
|
||||
"""
|
||||
from tools import skill_usage
|
||||
if skill_usage.get_record(args.skill).get("pinned"):
|
||||
print(
|
||||
f"curator: '{args.skill}' is pinned — unpin first with "
|
||||
f"`hermes curator unpin {args.skill}`"
|
||||
)
|
||||
return 1
|
||||
ok, msg = skill_usage.archive_skill(args.skill)
|
||||
print(f"curator: {msg}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _idle_days(record: dict) -> Optional[int]:
|
||||
"""Days since the skill's last activity (view / use / patch).
|
||||
|
||||
Falls back to ``created_at`` so a skill that was authored but never used
|
||||
can still be pruned — otherwise never-touched skills would be immortal.
|
||||
Returns None only when both fields are missing or unparseable.
|
||||
"""
|
||||
ts = record.get("last_activity_at") or record.get("created_at")
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(ts))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return max(0, (datetime.now(timezone.utc) - dt).days)
|
||||
|
||||
|
||||
def _cmd_prune(args) -> int:
|
||||
"""Bulk-archive agent-created skills idle for >= N days.
|
||||
|
||||
Pinned skills are exempt. Already-archived skills are skipped. Default
|
||||
``--days 90`` matches a conservative read of the curator's own archive
|
||||
threshold; adjust with ``--days``. Use ``--dry-run`` to preview.
|
||||
"""
|
||||
from tools import skill_usage
|
||||
days = getattr(args, "days", 90)
|
||||
if days < 1:
|
||||
print(f"curator: --days must be >= 1 (got {days})", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
dry_run = bool(getattr(args, "dry_run", False))
|
||||
skip_confirm = bool(getattr(args, "yes", False))
|
||||
|
||||
candidates = []
|
||||
for r in skill_usage.agent_created_report():
|
||||
if r.get("pinned"):
|
||||
continue
|
||||
if r.get("state") == skill_usage.STATE_ARCHIVED:
|
||||
continue
|
||||
idle = _idle_days(r)
|
||||
if idle is None or idle < days:
|
||||
continue
|
||||
candidates.append((r["name"], idle))
|
||||
|
||||
if not candidates:
|
||||
print(f"curator: nothing to prune (no unpinned skills idle >= {days}d)")
|
||||
return 0
|
||||
|
||||
candidates.sort(key=lambda c: -c[1])
|
||||
print(f"curator: {len(candidates)} skill(s) idle >= {days}d:")
|
||||
for name, idle in candidates:
|
||||
print(f" {name:40s} idle {idle}d")
|
||||
|
||||
if dry_run:
|
||||
print("\n(dry run — no changes made)")
|
||||
return 0
|
||||
|
||||
if not skip_confirm:
|
||||
try:
|
||||
reply = input(f"\nArchive {len(candidates)} skill(s)? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\ncurator: aborted")
|
||||
return 1
|
||||
if reply not in ("y", "yes"):
|
||||
print("curator: aborted")
|
||||
return 1
|
||||
|
||||
archived = 0
|
||||
failures = []
|
||||
for name, _ in candidates:
|
||||
ok, msg = skill_usage.archive_skill(name)
|
||||
if ok:
|
||||
archived += 1
|
||||
else:
|
||||
failures.append((name, msg))
|
||||
|
||||
print(f"\ncurator: archived {archived}/{len(candidates)}")
|
||||
if failures:
|
||||
print("failures:")
|
||||
for name, msg in failures:
|
||||
print(f" {name}: {msg}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_backup(args) -> int:
|
||||
"""Take a manual snapshot of the skills tree. Same mechanism as the
|
||||
automatic pre-run snapshot, just user-initiated."""
|
||||
@@ -383,6 +488,31 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
p_restore.add_argument("skill", help="Skill name")
|
||||
p_restore.set_defaults(func=_cmd_restore)
|
||||
|
||||
p_archive = subs.add_parser(
|
||||
"archive",
|
||||
help="Manually archive a skill (move to .archive/, excluded from prompt)",
|
||||
)
|
||||
p_archive.add_argument("skill", help="Skill name")
|
||||
p_archive.set_defaults(func=_cmd_archive)
|
||||
|
||||
p_prune = subs.add_parser(
|
||||
"prune",
|
||||
help="Bulk-archive agent-created skills idle for >= N days (default 90)",
|
||||
)
|
||||
p_prune.add_argument(
|
||||
"--days", type=int, default=90,
|
||||
help="Archive skills idle for at least N days (default: 90)",
|
||||
)
|
||||
p_prune.add_argument(
|
||||
"-y", "--yes", action="store_true",
|
||||
help="Skip the confirmation prompt",
|
||||
)
|
||||
p_prune.add_argument(
|
||||
"--dry-run", dest="dry_run", action="store_true",
|
||||
help="Show what would be archived without doing it",
|
||||
)
|
||||
p_prune.set_defaults(func=_cmd_prune)
|
||||
|
||||
p_backup = subs.add_parser(
|
||||
"backup",
|
||||
help="Take a manual tar.gz snapshot of ~/.hermes/skills/ "
|
||||
|
||||
@@ -12,6 +12,7 @@ import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
PROJECT_ROOT = get_project_root()
|
||||
@@ -19,15 +20,8 @@ HERMES_HOME = get_hermes_home()
|
||||
_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)
|
||||
|
||||
# Load environment variables from ~/.hermes/.env so API key checks work
|
||||
from dotenv import load_dotenv
|
||||
_env_path = get_env_path()
|
||||
if _env_path.exists():
|
||||
try:
|
||||
load_dotenv(_env_path, encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
load_dotenv(_env_path, encoding="latin-1")
|
||||
# Also try project .env as dev fallback
|
||||
load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8")
|
||||
load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".env")
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.models import _HERMES_USER_AGENT
|
||||
|
||||
+5
-8
@@ -14,6 +14,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_env_path, get_project_root, load_config
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
|
||||
@@ -195,15 +196,11 @@ def run_dump(args):
|
||||
show_keys = getattr(args, "show_keys", False)
|
||||
|
||||
# Load env from .env file so key checks work
|
||||
from dotenv import load_dotenv
|
||||
env_path = get_env_path()
|
||||
if env_path.exists():
|
||||
try:
|
||||
load_dotenv(env_path, encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
load_dotenv(env_path, encoding="latin-1")
|
||||
# Also try project .env as dev fallback
|
||||
load_dotenv(get_project_root() / ".env", override=False, encoding="utf-8")
|
||||
load_hermes_dotenv(
|
||||
hermes_home=env_path.parent,
|
||||
project_env=get_project_root() / ".env",
|
||||
)
|
||||
|
||||
project_root = get_project_root()
|
||||
hermes_home = get_hermes_home()
|
||||
|
||||
+136
-8
@@ -308,6 +308,35 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
p_assign.add_argument("task_id")
|
||||
p_assign.add_argument("profile", help="Profile name (or 'none' to unassign)")
|
||||
|
||||
# --- reclaim / reassign (recovery) ---
|
||||
p_reclaim = sub.add_parser(
|
||||
"reclaim",
|
||||
help="Release an active worker claim on a running task",
|
||||
)
|
||||
p_reclaim.add_argument("task_id")
|
||||
p_reclaim.add_argument(
|
||||
"--reason", default=None,
|
||||
help="Human-readable reason (recorded on the reclaimed event)",
|
||||
)
|
||||
|
||||
p_reassign = sub.add_parser(
|
||||
"reassign",
|
||||
help="Reassign a task to a different profile, optionally reclaiming first",
|
||||
)
|
||||
p_reassign.add_argument("task_id")
|
||||
p_reassign.add_argument(
|
||||
"profile",
|
||||
help="New profile name (or 'none' to unassign)",
|
||||
)
|
||||
p_reassign.add_argument(
|
||||
"--reclaim", action="store_true",
|
||||
help="Release any active claim before reassigning (required if task is running)",
|
||||
)
|
||||
p_reassign.add_argument(
|
||||
"--reason", default=None,
|
||||
help="Human-readable reason (recorded on the reclaimed event)",
|
||||
)
|
||||
|
||||
# --- link / unlink ---
|
||||
p_link = sub.add_parser("link", help="Add a parent->child dependency")
|
||||
p_link.add_argument("parent_id")
|
||||
@@ -343,6 +372,27 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
help='JSON dict of structured facts (e.g. \'{"changed_files": [...], '
|
||||
'"tests_run": 12}\'). Stored on the closing run.')
|
||||
|
||||
p_edit = sub.add_parser(
|
||||
"edit",
|
||||
help="Edit recovery fields on an already-completed task",
|
||||
)
|
||||
p_edit.add_argument("task_id")
|
||||
p_edit.add_argument(
|
||||
"--result",
|
||||
required=True,
|
||||
help="Backfilled task result text for a done task",
|
||||
)
|
||||
p_edit.add_argument(
|
||||
"--summary",
|
||||
default=None,
|
||||
help="Structured handoff summary. Falls back to --result if omitted.",
|
||||
)
|
||||
p_edit.add_argument(
|
||||
"--metadata",
|
||||
default=None,
|
||||
help="JSON dict of structured facts to store on the latest completed run.",
|
||||
)
|
||||
|
||||
p_block = sub.add_parser("block", help="Mark one or more tasks blocked")
|
||||
p_block.add_argument("task_id")
|
||||
p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)")
|
||||
@@ -576,11 +626,14 @@ def kanban_command(args: argparse.Namespace) -> int:
|
||||
"ls": _cmd_list,
|
||||
"show": _cmd_show,
|
||||
"assign": _cmd_assign,
|
||||
"reclaim": _cmd_reclaim,
|
||||
"reassign": _cmd_reassign,
|
||||
"link": _cmd_link,
|
||||
"unlink": _cmd_unlink,
|
||||
"claim": _cmd_claim,
|
||||
"comment": _cmd_comment,
|
||||
"complete": _cmd_complete,
|
||||
"edit": _cmd_edit,
|
||||
"block": _cmd_block,
|
||||
"unblock": _cmd_unblock,
|
||||
"archive": _cmd_archive,
|
||||
@@ -1095,6 +1148,45 @@ def _cmd_assign(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_reclaim(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
ok = kb.reclaim_task(
|
||||
conn, args.task_id,
|
||||
reason=getattr(args, "reason", None),
|
||||
)
|
||||
if not ok:
|
||||
print(
|
||||
f"cannot reclaim {args.task_id} (not running or unknown id)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(f"Reclaimed {args.task_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_reassign(args: argparse.Namespace) -> int:
|
||||
profile = None if args.profile.lower() in ("none", "-", "null") else args.profile
|
||||
with kb.connect() as conn:
|
||||
ok = kb.reassign_task(
|
||||
conn, args.task_id, profile,
|
||||
reclaim_first=bool(getattr(args, "reclaim", False)),
|
||||
reason=getattr(args, "reason", None),
|
||||
)
|
||||
if not ok:
|
||||
print(
|
||||
f"cannot reassign {args.task_id} "
|
||||
f"(unknown id, or still running — pass --reclaim to release first)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(
|
||||
f"Reassigned {args.task_id} to "
|
||||
f"{profile or '(unassigned)'}"
|
||||
+ (" (claim reclaimed)" if getattr(args, "reclaim", False) else "")
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_link(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
kb.link_tasks(conn, args.parent_id, args.child_id)
|
||||
@@ -1187,6 +1279,34 @@ def _cmd_complete(args: argparse.Namespace) -> int:
|
||||
return 0 if not failed else 1
|
||||
|
||||
|
||||
def _cmd_edit(args: argparse.Namespace) -> int:
|
||||
raw_meta = getattr(args, "metadata", None)
|
||||
metadata = None
|
||||
if raw_meta:
|
||||
try:
|
||||
metadata = json.loads(raw_meta)
|
||||
if not isinstance(metadata, dict):
|
||||
raise ValueError("must be a JSON object")
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"kanban: --metadata: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
if not kb.edit_completed_task_result(
|
||||
conn,
|
||||
args.task_id,
|
||||
result=args.result,
|
||||
summary=getattr(args, "summary", None),
|
||||
metadata=metadata,
|
||||
):
|
||||
print(
|
||||
f"cannot edit {args.task_id} (unknown id or task is not done)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(f"Edited {args.task_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_block(args: argparse.Namespace) -> int:
|
||||
reason = " ".join(args.reason).strip() if args.reason else None
|
||||
author = _profile_author()
|
||||
@@ -1274,6 +1394,7 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
for (tid, who, ws) in res.spawned
|
||||
],
|
||||
"skipped_unassigned": res.skipped_unassigned,
|
||||
"skipped_nonspawnable": res.skipped_nonspawnable,
|
||||
}, indent=2))
|
||||
return 0
|
||||
print(f"Reclaimed: {res.reclaimed}")
|
||||
@@ -1293,6 +1414,11 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
print(f" - {tid} -> {who} @ {ws or '-'}{tag}")
|
||||
if res.skipped_unassigned:
|
||||
print(f"Skipped (unassigned): {', '.join(res.skipped_unassigned)}")
|
||||
if res.skipped_nonspawnable:
|
||||
print(
|
||||
f"Skipped (non-spawnable assignee — terminal lane, OK): "
|
||||
f"{', '.join(res.skipped_nonspawnable)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1404,16 +1530,18 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
|
||||
)
|
||||
|
||||
def _ready_queue_nonempty() -> bool:
|
||||
"""Cheap SELECT — just asks whether there's at least one ready
|
||||
task with an assignee that the dispatcher could have picked up."""
|
||||
"""Cheap probe — is there at least one ready+assigned+unclaimed
|
||||
task whose assignee maps to a real Hermes profile (i.e. one the
|
||||
dispatcher would actually try to spawn for)?
|
||||
|
||||
Filters out tasks assigned to control-plane lanes
|
||||
(e.g. ``orion-cc``, ``orion-research``) that are pulled by
|
||||
terminals via ``claim_task`` directly — those are correctly idle
|
||||
from the dispatcher's perspective, not stuck.
|
||||
"""
|
||||
try:
|
||||
with kb.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM tasks "
|
||||
"WHERE status = 'ready' AND assignee IS NOT NULL "
|
||||
" AND claim_lock IS NULL LIMIT 1"
|
||||
).fetchone()
|
||||
return row is not None
|
||||
return kb.has_spawnable_ready(conn)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
+464
-32
@@ -76,6 +76,7 @@ import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
@@ -190,12 +191,12 @@ def get_current_board() -> str:
|
||||
1. ``HERMES_KANBAN_BOARD`` env var (set by the dispatcher on worker
|
||||
spawn, or manually for ad-hoc overrides).
|
||||
2. ``<root>/kanban/current`` on disk (set by ``hermes kanban boards
|
||||
switch``).
|
||||
switch``), but only when that board still exists.
|
||||
3. ``DEFAULT_BOARD`` (``"default"``).
|
||||
|
||||
A malformed slug at any step falls through to the next layer with a
|
||||
best-effort warning — the dispatcher must never crash because a user
|
||||
hand-edited a file.
|
||||
A malformed or stale slug at any step falls through to the next layer
|
||||
with a best-effort warning — the dispatcher must never crash because a
|
||||
user hand-edited a file or removed a board directory.
|
||||
"""
|
||||
env = os.environ.get("HERMES_KANBAN_BOARD", "").strip()
|
||||
if env:
|
||||
@@ -212,7 +213,7 @@ def get_current_board() -> str:
|
||||
if val:
|
||||
try:
|
||||
normed = _normalize_board_slug(val)
|
||||
if normed:
|
||||
if normed and board_exists(normed):
|
||||
return normed
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -1841,6 +1842,212 @@ def release_stale_claims(conn: sqlite3.Connection) -> int:
|
||||
return reclaimed
|
||||
|
||||
|
||||
def reclaim_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Operator-driven reclaim: release the claim and reset to ``ready``.
|
||||
|
||||
Unlike :func:`release_stale_claims` which only acts on tasks whose
|
||||
``claim_expires`` has passed, this function reclaims immediately
|
||||
regardless of TTL. Intended for the dashboard/CLI recovery flow
|
||||
when an operator wants to abort a running worker without waiting
|
||||
for the TTL to expire (e.g. after seeing a hallucination warning).
|
||||
|
||||
Returns True if a reclaim happened, False if the task isn't in a
|
||||
reclaimable state (not running, or doesn't exist).
|
||||
"""
|
||||
with write_txn(conn):
|
||||
row = conn.execute(
|
||||
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
if row["status"] != "running" and row["claim_lock"] is None:
|
||||
# Nothing to reclaim — already ready / blocked / done.
|
||||
return False
|
||||
prev_lock = row["claim_lock"]
|
||||
prev_pid = row["worker_pid"]
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
|
||||
"claim_expires = NULL, worker_pid = NULL "
|
||||
"WHERE id = ? AND status IN ('running', 'ready', 'blocked')",
|
||||
(task_id,),
|
||||
)
|
||||
run_id = _end_run(
|
||||
conn, task_id,
|
||||
outcome="reclaimed", status="reclaimed",
|
||||
error=(
|
||||
f"manual_reclaim: {reason}" if reason
|
||||
else f"manual_reclaim lock={prev_lock}"
|
||||
),
|
||||
)
|
||||
_append_event(
|
||||
conn, task_id, "reclaimed",
|
||||
{
|
||||
"manual": True,
|
||||
"reason": reason,
|
||||
"prev_lock": prev_lock,
|
||||
"prev_pid": prev_pid,
|
||||
},
|
||||
run_id=run_id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def reassign_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
profile: Optional[str],
|
||||
*,
|
||||
reclaim_first: bool = False,
|
||||
reason: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Reassign a task, optionally reclaiming a stuck running worker first.
|
||||
|
||||
This is the recovery path for "this profile's model is broken, try
|
||||
a different one". If ``reclaim_first`` is True, any active claim is
|
||||
released (via :func:`reclaim_task`) before the reassign happens;
|
||||
otherwise the function refuses to reassign a currently-running task
|
||||
and returns False (caller can retry with ``reclaim_first=True``).
|
||||
|
||||
Returns True if the reassign landed. ``profile`` may be ``None`` to
|
||||
unassign entirely.
|
||||
"""
|
||||
if reclaim_first:
|
||||
# Safe to call even if nothing to reclaim.
|
||||
reclaim_task(conn, task_id, reason=reason or "reassign")
|
||||
# assign_task handles its own txn + the still-running guard.
|
||||
try:
|
||||
return assign_task(conn, task_id, profile)
|
||||
except RuntimeError:
|
||||
# Task is still running and reclaim_first was False; caller
|
||||
# needs to decide whether to retry with reclaim.
|
||||
return False
|
||||
|
||||
|
||||
def _verify_created_cards(
|
||||
conn: sqlite3.Connection,
|
||||
completing_task_id: str,
|
||||
claimed_ids: Iterable[str],
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Partition ``claimed_ids`` into (verified, phantom).
|
||||
|
||||
A card is "verified" iff a row exists in ``tasks`` with the given id
|
||||
AND ``created_by`` matches the completing task's ``assignee`` (or
|
||||
the completing task itself — workers that create children of their
|
||||
own task also qualify).
|
||||
|
||||
``phantom`` returns ids that either don't exist at all or exist but
|
||||
were not created by the completing worker. The caller decides what
|
||||
to do with each bucket; this helper never mutates.
|
||||
"""
|
||||
claimed = [str(x).strip() for x in (claimed_ids or []) if str(x).strip()]
|
||||
if not claimed:
|
||||
return [], []
|
||||
# Dedupe while preserving order.
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for cid in claimed:
|
||||
if cid not in seen:
|
||||
seen.add(cid)
|
||||
ordered.append(cid)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT assignee FROM tasks WHERE id = ?", (completing_task_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
# Completing task not found — nothing resolves.
|
||||
return [], ordered
|
||||
completing_assignee = row["assignee"]
|
||||
|
||||
# Batch-fetch existence + created_by in one query.
|
||||
placeholders = ",".join(["?"] * len(ordered))
|
||||
rows = conn.execute(
|
||||
f"SELECT id, created_by FROM tasks WHERE id IN ({placeholders})",
|
||||
tuple(ordered),
|
||||
).fetchall()
|
||||
found = {r["id"]: r["created_by"] for r in rows}
|
||||
|
||||
verified: list[str] = []
|
||||
phantom: list[str] = []
|
||||
for cid in ordered:
|
||||
created_by = found.get(cid)
|
||||
if created_by is None:
|
||||
phantom.append(cid)
|
||||
continue
|
||||
# Accept if created_by matches the completing task's assignee
|
||||
# profile, OR the task itself (workers whose created_by happens
|
||||
# to match their task id are unusual but harmless to accept).
|
||||
if completing_assignee and created_by == completing_assignee:
|
||||
verified.append(cid)
|
||||
elif created_by == completing_task_id:
|
||||
verified.append(cid)
|
||||
else:
|
||||
phantom.append(cid)
|
||||
return verified, phantom
|
||||
|
||||
|
||||
# Task-id pattern used both by ``kanban_create`` (``t_<12 hex>``) and
|
||||
# ``_new_task_id`` below. Kept permissive on length for forward compat:
|
||||
# accept 8+ hex chars after the ``t_`` prefix.
|
||||
_TASK_ID_PROSE_RE = re.compile(r"\bt_[a-f0-9]{8,}\b")
|
||||
|
||||
|
||||
def _scan_prose_for_phantom_ids(
|
||||
conn: sqlite3.Connection,
|
||||
text: str,
|
||||
) -> list[str]:
|
||||
"""Regex-scan free-form text for ``t_<hex>`` references; return the
|
||||
ones that don't exist in ``tasks``.
|
||||
|
||||
Used as a non-blocking advisory check on completion summaries. An
|
||||
empty return means "no suspicious references found" — either the
|
||||
text had no IDs at all, or every ID it mentioned resolves to a real
|
||||
task. Duplicates are deduped.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
matches = _TASK_ID_PROSE_RE.findall(text)
|
||||
if not matches:
|
||||
return []
|
||||
# Dedupe preserving order.
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for m in matches:
|
||||
if m not in seen:
|
||||
seen.add(m)
|
||||
unique.append(m)
|
||||
placeholders = ",".join(["?"] * len(unique))
|
||||
rows = conn.execute(
|
||||
f"SELECT id FROM tasks WHERE id IN ({placeholders})",
|
||||
tuple(unique),
|
||||
).fetchall()
|
||||
existing = {r["id"] for r in rows}
|
||||
return [m for m in unique if m not in existing]
|
||||
|
||||
|
||||
class HallucinatedCardsError(ValueError):
|
||||
"""Raised by ``complete_task`` when ``created_cards`` contains ids
|
||||
that don't exist or weren't created by the completing worker.
|
||||
|
||||
The phantom list is attached as ``.phantom`` for callers that want
|
||||
structured access. Kept as ``ValueError`` subclass so existing
|
||||
tool-error handlers treat it as a recoverable user error.
|
||||
"""
|
||||
|
||||
def __init__(self, phantom: list[str], completing_task_id: str):
|
||||
self.phantom = list(phantom)
|
||||
self.completing_task_id = completing_task_id
|
||||
super().__init__(
|
||||
f"completion blocked: claimed created_cards that do not exist "
|
||||
f"or were not created by this worker: {', '.join(phantom)}"
|
||||
)
|
||||
|
||||
|
||||
def complete_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
@@ -1848,21 +2055,65 @@ def complete_task(
|
||||
result: Optional[str] = None,
|
||||
summary: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
created_cards: Optional[Iterable[str]] = None,
|
||||
) -> bool:
|
||||
"""Transition ``running|ready -> done`` and record ``result``.
|
||||
|
||||
Accepts a task that's merely ``ready`` too, so a manual CLI
|
||||
Accepts a task that is merely ``ready`` too, so a manual CLI
|
||||
completion (``hermes kanban complete <id>``) works without requiring
|
||||
a claim/start/complete sequence.
|
||||
|
||||
``summary`` and ``metadata`` are stored on the closing run (if any)
|
||||
and surfaced to downstream children via :func:`build_worker_context`.
|
||||
When ``summary`` is omitted we fall back to ``result`` so single-run
|
||||
callers don't have to pass both. ``metadata`` is a free-form dict
|
||||
callers do not have to pass both. ``metadata`` is a free-form dict
|
||||
(e.g. ``{"changed_files": [...], "tests_run": [...]}``) — workers
|
||||
are encouraged to use it for structured handoff facts.
|
||||
|
||||
``created_cards`` is an optional list of task ids the completing
|
||||
worker claims to have created. Each id is verified against
|
||||
``tasks.created_by``. If any id is phantom (does not exist or was
|
||||
not created by this worker's assignee profile), completion is blocked
|
||||
with a ``HallucinatedCardsError`` and a
|
||||
``completion_blocked_hallucination`` event is emitted so the rejected
|
||||
attempt is auditable. When all ids verify, they are recorded on the
|
||||
``completed`` event payload.
|
||||
|
||||
After a successful completion, ``summary`` and ``result`` are scanned
|
||||
for prose references like ``t_deadbeefcafe`` that do not resolve.
|
||||
Any suspected phantom references are recorded as a
|
||||
``suspected_hallucinated_references`` event. This pass is advisory
|
||||
and never blocks.
|
||||
"""
|
||||
now = int(time.time())
|
||||
|
||||
# Gate: verify created_cards BEFORE the main write txn. A rejected
|
||||
# completion still needs an auditable event, so we emit it in a
|
||||
# tiny dedicated txn, then raise. The caller is responsible for
|
||||
# surfacing HallucinatedCardsError to the worker; this function
|
||||
# never mutates task state on a phantom-card rejection.
|
||||
if created_cards:
|
||||
verified_cards, phantom_cards = _verify_created_cards(
|
||||
conn, task_id, created_cards
|
||||
)
|
||||
if phantom_cards:
|
||||
with write_txn(conn):
|
||||
_append_event(
|
||||
conn, task_id, "completion_blocked_hallucination",
|
||||
{
|
||||
"phantom_cards": phantom_cards,
|
||||
"verified_cards": verified_cards,
|
||||
"summary_preview": (
|
||||
(summary or result or "").strip().splitlines()[0][:200]
|
||||
if (summary or result)
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
raise HallucinatedCardsError(phantom_cards, task_id)
|
||||
else:
|
||||
verified_cards = []
|
||||
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
"""
|
||||
@@ -1903,16 +2154,107 @@ def complete_task(
|
||||
# full summary stays on the run row.
|
||||
ev_summary = (summary if summary is not None else result) or ""
|
||||
ev_summary = ev_summary.strip().splitlines()[0][:400] if ev_summary else ""
|
||||
completed_payload: dict = {
|
||||
"result_len": len(result) if result else 0,
|
||||
"summary": ev_summary or None,
|
||||
}
|
||||
if verified_cards:
|
||||
completed_payload["verified_cards"] = verified_cards
|
||||
_append_event(
|
||||
conn, task_id, "completed",
|
||||
completed_payload,
|
||||
run_id=run_id,
|
||||
)
|
||||
# Prose-scan the summary + result for t_<hex> references that do
|
||||
# not resolve. Advisory — does not block the completion. Runs in
|
||||
# its own txn so the completion itself is already durable by the
|
||||
# time we emit the warning.
|
||||
scan_text = " ".join(filter(None, [summary, result]))
|
||||
if scan_text:
|
||||
phantom_refs = _scan_prose_for_phantom_ids(conn, scan_text)
|
||||
# Drop any phantom refs that were already flagged as verified
|
||||
# above (shouldn't happen — verified means they exist — but
|
||||
# belt-and-suspenders).
|
||||
phantom_refs = [p for p in phantom_refs if p not in set(verified_cards)]
|
||||
if phantom_refs:
|
||||
with write_txn(conn):
|
||||
_append_event(
|
||||
conn, task_id, "suspected_hallucinated_references",
|
||||
{
|
||||
"phantom_refs": phantom_refs,
|
||||
"source": "completion_summary",
|
||||
},
|
||||
run_id=run_id,
|
||||
)
|
||||
# Recompute ready status for dependents (separate txn so children see done).
|
||||
recompute_ready(conn)
|
||||
return True
|
||||
|
||||
|
||||
def edit_completed_task_result(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
result: str,
|
||||
summary: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> bool:
|
||||
"""Backfill the user-visible result for an already completed task."""
|
||||
handoff_summary = summary if summary is not None else result
|
||||
with write_txn(conn):
|
||||
row = conn.execute(
|
||||
"SELECT status FROM tasks WHERE id = ?", (task_id,),
|
||||
).fetchone()
|
||||
if not row or row["status"] != "done":
|
||||
return False
|
||||
conn.execute(
|
||||
"UPDATE tasks SET result = ? WHERE id = ?",
|
||||
(result, task_id),
|
||||
)
|
||||
run = conn.execute(
|
||||
"""
|
||||
SELECT id FROM task_runs
|
||||
WHERE task_id = ?
|
||||
AND outcome = 'completed'
|
||||
ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
run_id = int(run["id"]) if run else None
|
||||
if run_id is None:
|
||||
run_id = _synthesize_ended_run(
|
||||
conn, task_id,
|
||||
outcome="completed",
|
||||
summary=handoff_summary,
|
||||
metadata=metadata,
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET summary = ? WHERE id = ?",
|
||||
(handoff_summary, run_id),
|
||||
)
|
||||
if metadata is not None:
|
||||
conn.execute(
|
||||
"UPDATE task_runs SET metadata = ? WHERE id = ?",
|
||||
(json.dumps(metadata, ensure_ascii=False), run_id),
|
||||
)
|
||||
ev_summary = (
|
||||
handoff_summary.strip().splitlines()[0][:400]
|
||||
if handoff_summary else ""
|
||||
)
|
||||
_append_event(
|
||||
conn, task_id, "edited",
|
||||
{
|
||||
"fields": (
|
||||
["result", "summary"]
|
||||
+ (["metadata"] if metadata is not None else [])
|
||||
),
|
||||
"result_len": len(result) if result else 0,
|
||||
"summary": ev_summary or None,
|
||||
},
|
||||
run_id=run_id,
|
||||
)
|
||||
# Recompute ready status for dependents (separate txn so children see done).
|
||||
recompute_ready(conn)
|
||||
return True
|
||||
|
||||
|
||||
@@ -2118,6 +2460,15 @@ class DispatchResult:
|
||||
spawned: list[tuple[str, str, str]] = field(default_factory=list)
|
||||
"""List of ``(task_id, assignee, workspace_path)`` triples."""
|
||||
skipped_unassigned: list[str] = field(default_factory=list)
|
||||
"""Ready task ids skipped because they have no assignee at all.
|
||||
Operator-actionable — usually a misfiled task waiting for routing."""
|
||||
skipped_nonspawnable: list[str] = field(default_factory=list)
|
||||
"""Ready task ids skipped because their assignee names a control-plane
|
||||
lane (a Claude Code terminal like ``orion-cc``) rather than a Hermes
|
||||
profile. Expected steady-state on multi-lane setups; NOT an
|
||||
operator-actionable failure. Tracked separately so health telemetry
|
||||
can distinguish "real stuck" (nothing spawned but spawnable work
|
||||
available) from "correctly idle" (nothing spawnable in the queue)."""
|
||||
crashed: list[str] = field(default_factory=list)
|
||||
"""Task ids reclaimed because their worker PID disappeared."""
|
||||
auto_blocked: list[str] = field(default_factory=list)
|
||||
@@ -2132,16 +2483,16 @@ def _pid_alive(pid: Optional[int]) -> bool:
|
||||
Cross-platform: uses ``os.kill(pid, 0)`` on POSIX and ``OpenProcess``
|
||||
on Windows. Returns False for falsy PIDs or on any OS error.
|
||||
|
||||
**Zombie handling (Linux):** ``os.kill(pid, 0)`` succeeds against
|
||||
**Zombie handling:** ``os.kill(pid, 0)`` succeeds against
|
||||
zombie processes (post-exit, pre-reap) because the process table
|
||||
entry still exists. A worker that exits without being reaped by its
|
||||
parent would stay "alive" to the dispatcher forever. Dispatcher
|
||||
workers are started via ``start_new_session=True`` + intentional
|
||||
Popen handle abandonment, so init reaps them quickly — but during
|
||||
the window between exit and reap, we'd otherwise see stale "alive"
|
||||
signals. On Linux we additionally peek at ``/proc/<pid>/status``
|
||||
and treat ``State: Z`` as dead. On other POSIX or on Windows the
|
||||
zombie check is a no-op.
|
||||
signals. On Linux we peek at ``/proc/<pid>/status`` and treat
|
||||
``State: Z`` as dead. On macOS we ask ``ps`` for the BSD ``stat``
|
||||
field and treat values containing ``Z`` as dead.
|
||||
"""
|
||||
if not pid or pid <= 0:
|
||||
return False
|
||||
@@ -2155,7 +2506,8 @@ def _pid_alive(pid: Optional[int]) -> bool:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
# Still here → kill(0) succeeded. Check for zombie on Linux.
|
||||
# Still here → kill(0) succeeded. Check for zombie on platforms
|
||||
# where we have a cheap, deterministic process-state probe.
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
with open(f"/proc/{int(pid)}/status", "r") as f:
|
||||
@@ -2170,6 +2522,23 @@ def _pid_alive(pid: Optional[int]) -> bool:
|
||||
# PermissionError shouldn't happen for our own children but
|
||||
# be defensive.
|
||||
pass
|
||||
elif sys.platform == "darwin":
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["ps", "-o", "stat=", "-p", str(int(pid))],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=1,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return False
|
||||
if "Z" in (proc.stdout or "").strip():
|
||||
return False
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError):
|
||||
# If the secondary probe fails, keep the kill(0) answer.
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
@@ -2459,6 +2828,38 @@ def _clear_spawn_failures(conn: sqlite3.Connection, task_id: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def has_spawnable_ready(conn: sqlite3.Connection) -> bool:
|
||||
"""Return True iff there is at least one ready+assigned+unclaimed task
|
||||
whose assignee maps to a real Hermes profile.
|
||||
|
||||
Used by the gateway- and CLI-embedded dispatchers' health telemetry to
|
||||
decide whether ``0 spawned`` is a "stuck" condition (real spawnable
|
||||
work waiting) or a "correctly idle" condition (only control-plane
|
||||
lanes like ``orion-cc`` / ``orion-research`` waiting on terminals
|
||||
that pull tasks via ``claim_task`` directly).
|
||||
|
||||
Falls back to "any ready+assigned" if ``profile_exists`` is not
|
||||
importable (e.g. partial install) — preserves the old behavior so
|
||||
the warning still fires in degraded environments.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT assignee FROM tasks "
|
||||
"WHERE status = 'ready' AND assignee IS NOT NULL "
|
||||
" AND claim_lock IS NULL"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.profiles import profile_exists # local import: avoids cycle
|
||||
except Exception:
|
||||
# Can't introspect — assume spawnable, preserve legacy behavior.
|
||||
return True
|
||||
for row in rows:
|
||||
if profile_exists(row["assignee"]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def dispatch_once(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
@@ -2506,6 +2907,29 @@ def dispatch_once(
|
||||
if not row["assignee"]:
|
||||
result.skipped_unassigned.append(row["id"])
|
||||
continue
|
||||
# Skip ready tasks whose assignee is not a real Hermes profile.
|
||||
# `_default_spawn` invokes ``hermes -p <assignee>`` which fails
|
||||
# with "Profile 'X' does not exist" when the assignee names a
|
||||
# control-plane lane (e.g. an interactive Claude Code terminal
|
||||
# like ``orion-cc`` / ``orion-research``) rather than a Hermes
|
||||
# profile. Those task lanes are pulled by terminals via
|
||||
# ``claim_task`` directly and should NEVER auto-spawn — the
|
||||
# subprocess would crash on startup, get reaped as a zombie,
|
||||
# the task would loop back to ``ready`` on next tick, and we'd
|
||||
# burn CPU forever (#kanban-dispatcher-crash-loop 2026-05-05).
|
||||
try:
|
||||
from hermes_cli.profiles import profile_exists # local import: avoids cycle
|
||||
except Exception:
|
||||
profile_exists = None # type: ignore[assignment]
|
||||
if profile_exists is not None and not profile_exists(row["assignee"]):
|
||||
# Bucket separately from skipped_unassigned: the operator
|
||||
# cannot fix this by assigning a profile (the assignee IS the
|
||||
# intended owner — a terminal lane). Health telemetry uses
|
||||
# this distinction to suppress spurious "stuck" warnings on
|
||||
# multi-lane setups where the ready queue is steadily full
|
||||
# of human-pulled work.
|
||||
result.skipped_nonspawnable.append(row["id"])
|
||||
continue
|
||||
if dry_run:
|
||||
result.spawned.append((row["id"], row["assignee"], ""))
|
||||
continue
|
||||
@@ -3213,30 +3637,38 @@ def read_worker_log(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_profiles_on_disk() -> list[str]:
|
||||
"""Return the set of named profiles discovered on disk.
|
||||
"""Return the set of assignee/profile names discovered on disk.
|
||||
|
||||
Reads ``~/.hermes/profiles/`` directly so this module has no import
|
||||
dependency on ``hermes_cli.profiles`` (which pulls in a large chunk
|
||||
of the CLI startup path). Only returns directories that contain a
|
||||
``config.yaml`` — a bare dir without config isn't a real profile.
|
||||
Includes:
|
||||
- named profiles under ``<default-root>/profiles/<name>/config.yaml``
|
||||
- the implicit ``default`` profile when the default Hermes root exists
|
||||
|
||||
Reads profile paths directly so this module has no import dependency on
|
||||
``hermes_cli.profiles`` (which pulls in a large chunk of the CLI startup
|
||||
path).
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
home = get_default_hermes_root() / "profiles"
|
||||
default_root = get_default_hermes_root()
|
||||
profiles_dir = default_root / "profiles"
|
||||
except Exception:
|
||||
return []
|
||||
if not home.is_dir():
|
||||
return []
|
||||
names: list[str] = []
|
||||
try:
|
||||
for entry in sorted(home.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if (entry / "config.yaml").is_file():
|
||||
names.append(entry.name)
|
||||
except OSError:
|
||||
return names
|
||||
return names
|
||||
|
||||
names: set[str] = set()
|
||||
if default_root.exists():
|
||||
names.add("default")
|
||||
|
||||
if profiles_dir.is_dir():
|
||||
try:
|
||||
for entry in sorted(profiles_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if (entry / "config.yaml").is_file():
|
||||
names.add(entry.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def known_assignees(conn: sqlite3.Connection) -> list[dict]:
|
||||
|
||||
+110
-66
@@ -1216,6 +1216,26 @@ def _launch_tui(
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def _pin_kanban_board_env() -> None:
|
||||
"""Pin the active kanban board into ``HERMES_KANBAN_BOARD`` for the chat session.
|
||||
|
||||
Without this, in-process tools (``kanban_*``) and shelled-out CLI calls
|
||||
(``hermes kanban …``) resolve the board on different paths: the env-pin if
|
||||
set, otherwise the global ``<root>/kanban/current`` file. A concurrent
|
||||
``hermes kanban boards switch`` from another session can flip the file
|
||||
mid-turn, so the same chat sees its tool calls hit board A while its shell
|
||||
calls hit board B (#20074). Pinning at chat boot mirrors what the
|
||||
dispatcher already does for spawned workers.
|
||||
"""
|
||||
if os.environ.get("HERMES_KANBAN_BOARD"):
|
||||
return
|
||||
try:
|
||||
from hermes_cli.kanban_db import get_current_board
|
||||
os.environ["HERMES_KANBAN_BOARD"] = get_current_board()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cmd_chat(args):
|
||||
"""Run interactive chat CLI."""
|
||||
use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"
|
||||
@@ -1324,6 +1344,8 @@ def cmd_chat(args):
|
||||
if getattr(args, "source", None):
|
||||
os.environ["HERMES_SESSION_SOURCE"] = args.source
|
||||
|
||||
_pin_kanban_board_env()
|
||||
|
||||
if use_tui:
|
||||
_launch_tui(
|
||||
getattr(args, "resume", None),
|
||||
@@ -3974,6 +3996,85 @@ def _model_flow_copilot_acp(config, current_model=""):
|
||||
print(f"Default model set to: {selected} (via {pconfig.name})")
|
||||
|
||||
|
||||
def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple:
|
||||
"""Shared API-key entry point for ``hermes setup`` / ``hermes model``.
|
||||
|
||||
Handles both first-time entry and the already-configured case. When a key
|
||||
is already present, offers [K]eep / [R]eplace / [C]lear so the user can
|
||||
recover from a malformed paste without editing ``~/.hermes/.env`` by hand.
|
||||
|
||||
Returns ``(resolved_key, abort)``. ``abort=True`` means the caller should
|
||||
``return`` immediately — the user cancelled entry, declined to replace, or
|
||||
cleared the key and is now unconfigured.
|
||||
"""
|
||||
import getpass
|
||||
|
||||
from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
|
||||
from hermes_cli.config import save_env_value
|
||||
|
||||
key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
|
||||
|
||||
def _prompt_new_key(*, allow_lmstudio_default: bool) -> str:
|
||||
if provider_id == "lmstudio" and allow_lmstudio_default:
|
||||
prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
|
||||
else:
|
||||
prompt = f"{key_env} (or Enter to cancel): "
|
||||
try:
|
||||
entered = getpass.getpass(prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return ""
|
||||
if not entered and provider_id == "lmstudio" and allow_lmstudio_default:
|
||||
return LMSTUDIO_NOAUTH_PLACEHOLDER
|
||||
return entered
|
||||
|
||||
# First-time entry ────────────────────────────────────────────────────
|
||||
if not existing_key:
|
||||
print(f"No {pconfig.name} API key configured.")
|
||||
if not key_env:
|
||||
return "", True
|
||||
new_key = _prompt_new_key(allow_lmstudio_default=True)
|
||||
if not new_key:
|
||||
print("Cancelled.")
|
||||
return "", True
|
||||
save_env_value(key_env, new_key)
|
||||
print("API key saved.")
|
||||
print()
|
||||
return new_key, False
|
||||
|
||||
# Already configured — offer K / R / C ────────────────────────────────
|
||||
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
||||
if not key_env:
|
||||
# Nothing we can rewrite; just acknowledge and move on.
|
||||
print()
|
||||
return existing_key, False
|
||||
try:
|
||||
choice = input(" [K]eep / [R]eplace / [C]lear (default K): ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
choice = "k"
|
||||
|
||||
if choice.startswith("r"):
|
||||
new_key = _prompt_new_key(allow_lmstudio_default=False)
|
||||
if not new_key:
|
||||
print(" No change.")
|
||||
print()
|
||||
return existing_key, False
|
||||
save_env_value(key_env, new_key)
|
||||
print(" API key updated.")
|
||||
print()
|
||||
return new_key, False
|
||||
|
||||
if choice.startswith("c"):
|
||||
save_env_value(key_env, "")
|
||||
print(f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again.")
|
||||
return "", True
|
||||
|
||||
# Keep (default, or any other input)
|
||||
print()
|
||||
return existing_key, False
|
||||
|
||||
|
||||
def _model_flow_kimi(config, current_model=""):
|
||||
"""Kimi / Moonshot model selection with automatic endpoint routing.
|
||||
|
||||
@@ -4008,26 +4109,9 @@ def _model_flow_kimi(config, current_model=""):
|
||||
if existing_key:
|
||||
break
|
||||
|
||||
if not existing_key:
|
||||
print(f"No {pconfig.name} API key configured.")
|
||||
if key_env:
|
||||
try:
|
||||
import getpass
|
||||
|
||||
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not new_key:
|
||||
print("Cancelled.")
|
||||
return
|
||||
save_env_value(key_env, new_key)
|
||||
existing_key = new_key
|
||||
print("API key saved.")
|
||||
print()
|
||||
else:
|
||||
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
||||
print()
|
||||
existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
|
||||
if abort:
|
||||
return
|
||||
|
||||
# Step 2: Auto-detect endpoint from key prefix
|
||||
is_coding_plan = existing_key.startswith("sk-kimi-")
|
||||
@@ -4128,25 +4212,9 @@ def _model_flow_stepfun(config, current_model=""):
|
||||
if existing_key:
|
||||
break
|
||||
|
||||
if not existing_key:
|
||||
print(f"No {pconfig.name} API key configured.")
|
||||
if key_env:
|
||||
try:
|
||||
import getpass
|
||||
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not new_key:
|
||||
print("Cancelled.")
|
||||
return
|
||||
save_env_value(key_env, new_key)
|
||||
existing_key = new_key
|
||||
print("API key saved.")
|
||||
print()
|
||||
else:
|
||||
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
||||
print()
|
||||
existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
|
||||
if abort:
|
||||
return
|
||||
|
||||
current_base = ""
|
||||
if base_url_env:
|
||||
@@ -4522,33 +4590,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
if existing_key:
|
||||
break
|
||||
|
||||
if not existing_key:
|
||||
print(f"No {pconfig.name} API key configured.")
|
||||
if key_env:
|
||||
try:
|
||||
import getpass
|
||||
|
||||
if provider_id == "lmstudio":
|
||||
prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
|
||||
else:
|
||||
prompt = f"{key_env} (or Enter to cancel): "
|
||||
new_key = getpass.getpass(prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not new_key:
|
||||
if provider_id == "lmstudio":
|
||||
new_key = LMSTUDIO_NOAUTH_PLACEHOLDER
|
||||
else:
|
||||
print("Cancelled.")
|
||||
return
|
||||
save_env_value(key_env, new_key)
|
||||
existing_key = new_key
|
||||
print("API key saved.")
|
||||
print()
|
||||
else:
|
||||
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
||||
print()
|
||||
existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
|
||||
if abort:
|
||||
return
|
||||
|
||||
# Gemini free-tier gate: free-tier daily quotas (<= 250 RPD for Flash)
|
||||
# are exhausted in a handful of agent turns, so refuse to wire up the
|
||||
|
||||
@@ -190,11 +190,18 @@ def _load_direct_aliases() -> dict[str, DirectAlias]:
|
||||
model: "minimax-m2.7"
|
||||
provider: custom
|
||||
base_url: "https://ollama.com/v1"
|
||||
|
||||
Also reads ``model.aliases`` (set by ``hermes config set model.aliases.xxx``)
|
||||
and converts simple string entries (``ds-flash: deepseek/deepseek-v4-flash``)
|
||||
into DirectAlias objects. The provider is parsed from the ``provider/``
|
||||
prefix in the value; if no slash, the current provider is used.
|
||||
"""
|
||||
merged = dict(_BUILTIN_DIRECT_ALIASES)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
|
||||
# --- model_aliases (dict-based format) ---
|
||||
user_aliases = cfg.get("model_aliases")
|
||||
if isinstance(user_aliases, dict):
|
||||
for name, entry in user_aliases.items():
|
||||
@@ -207,6 +214,30 @@ def _load_direct_aliases() -> dict[str, DirectAlias]:
|
||||
merged[name.strip().lower()] = DirectAlias(
|
||||
model=model, provider=provider, base_url=base_url,
|
||||
)
|
||||
|
||||
# --- model.aliases (string-based format, from config set) ---
|
||||
model_section = cfg.get("model", {})
|
||||
if isinstance(model_section, dict):
|
||||
simple_aliases = model_section.get("aliases")
|
||||
if isinstance(simple_aliases, dict):
|
||||
current_provider = model_section.get("provider", "")
|
||||
for name, value in simple_aliases.items():
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
continue
|
||||
key = name.strip().lower()
|
||||
if key in merged:
|
||||
continue # don't override explicit model_aliases entries
|
||||
val = value.strip()
|
||||
if "/" in val:
|
||||
provider, model = val.split("/", 1)
|
||||
else:
|
||||
provider = current_provider
|
||||
model = val
|
||||
merged[key] = DirectAlias(
|
||||
model=model.strip(),
|
||||
provider=provider.strip() or current_provider,
|
||||
base_url="",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return merged
|
||||
@@ -1652,3 +1683,59 @@ def list_authenticated_providers(
|
||||
results.sort(key=lambda r: (not r["is_current"], -r["total_models"]))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def list_picker_providers(
|
||||
current_provider: str = "",
|
||||
user_providers: dict = None,
|
||||
custom_providers: list | None = None,
|
||||
max_models: int = 8,
|
||||
) -> List[dict]:
|
||||
"""Interactive-picker variant of :func:`list_authenticated_providers`.
|
||||
|
||||
Post-processes the base list so the ``/model`` picker (Telegram/Discord
|
||||
inline keyboards) only surfaces models that are actually callable in the
|
||||
current install:
|
||||
|
||||
- OpenRouter's model list is replaced with the output of
|
||||
:func:`hermes_cli.models.fetch_openrouter_models`, which filters the
|
||||
curated ``OPENROUTER_MODELS`` snapshot against the live OpenRouter
|
||||
catalog. IDs the live catalog no longer carries drop out, so the
|
||||
picker never offers a model the user can't call.
|
||||
- Provider rows whose model list ends up empty are dropped, except
|
||||
custom endpoints (``is_user_defined=True`` with an ``api_url``) where
|
||||
the user may supply their own model set through config.
|
||||
|
||||
All other providers and metadata fields are passed through unchanged.
|
||||
The typed ``/model <name>`` path is unaffected -- only the interactive
|
||||
picker payload is narrowed.
|
||||
"""
|
||||
from hermes_cli.models import fetch_openrouter_models
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
max_models=max_models,
|
||||
)
|
||||
|
||||
filtered: List[dict] = []
|
||||
for p in providers:
|
||||
slug = str(p.get("slug", "")).lower()
|
||||
if slug == "openrouter":
|
||||
try:
|
||||
live = fetch_openrouter_models()
|
||||
live_ids = [mid for mid, _ in live]
|
||||
except Exception:
|
||||
live_ids = list(p.get("models", []))
|
||||
p = dict(p)
|
||||
p["models"] = live_ids[:max_models]
|
||||
p["total_models"] = len(live_ids)
|
||||
|
||||
has_models = bool(p.get("models"))
|
||||
is_custom_endpoint = bool(p.get("is_user_defined")) and bool(p.get("api_url"))
|
||||
if not has_models and not is_custom_endpoint:
|
||||
continue
|
||||
filtered.append(p)
|
||||
|
||||
return filtered
|
||||
|
||||
+13
-1
@@ -15,6 +15,7 @@ import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import copy
|
||||
@@ -208,12 +209,23 @@ def prompt(question: str, default: str = None, password: bool = False) -> str:
|
||||
else:
|
||||
value = input(color(display, Colors.YELLOW))
|
||||
|
||||
return value.strip() or default or ""
|
||||
cleaned = _sanitize_pasted_input(value)
|
||||
return cleaned.strip() or default or ""
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
_BRACKETED_PASTE_PATTERN = re.compile(r"\x1b\[\s*200~|\x1b\[\s*201~")
|
||||
|
||||
|
||||
def _sanitize_pasted_input(value: str) -> str:
|
||||
"""Strip terminal bracketed-paste control markers from pasted text."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return value
|
||||
return _BRACKETED_PASTE_PATTERN.sub("", value)
|
||||
|
||||
|
||||
def _curses_prompt_choice(question: str, choices: list, default: int = 0, description: str | None = None) -> int:
|
||||
"""Single-select menu using curses. Delegates to curses_radiolist."""
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
|
||||
@@ -334,6 +334,144 @@ TIPS = [
|
||||
"MCP ${ENV_VAR} placeholders in config are resolved at server spawn — including vars from ~/.hermes/.env.",
|
||||
"Skills from trusted repos (NousResearch) get a 'trusted' security level; community skills get extra scanning.",
|
||||
"The skills quarantine at ~/.hermes/skills/.hub/quarantine/ holds skills pending security review.",
|
||||
|
||||
# --- Advanced Slash Commands ---
|
||||
'/steer <prompt> injects a note after the next tool call — nudge direction mid-task without interrupting.',
|
||||
'/goal <text> sets a standing Ralph-loop objective — Hermes auto-continues turn after turn until a judge says done.',
|
||||
'/snapshot create [label] saves a full state snapshot of Hermes config; /snapshot restore <id> reverts later.',
|
||||
'/copy [N] copies the last assistant response to your clipboard, or the Nth-from-last with a number.',
|
||||
'/redraw forces a full UI repaint, fixing terminal drift after tmux resize or mouse selection artifacts.',
|
||||
'/agents (alias /tasks) shows active agents and running background tasks across the current session.',
|
||||
'/footer toggles the gateway footer on final replies showing model, tool counts, and turn timing.',
|
||||
'/busy queue|steer|interrupt controls what pressing Enter does while Hermes is working.',
|
||||
'/topic in Telegram DMs enables user-managed multi-session topic mode — /topic <id> restores past sessions inline.',
|
||||
'/approve session|always runs a pending dangerous command with your chosen trust scope; /deny rejects it.',
|
||||
'/restart gracefully restarts the gateway after draining active runs, then pings the requester when back up.',
|
||||
'/kanban boards switch <slug> changes the active multi-project Kanban board from inside chat.',
|
||||
'/reload reloads ~/.hermes/.env into the running session — pick up new API keys without restarting.',
|
||||
|
||||
# --- Cron (no-agent & scripts) ---
|
||||
'cronjob with no_agent=True runs a script on schedule and sends its stdout directly — zero tokens, zero LLM.',
|
||||
'An empty cron script stdout means silent tick — nothing is delivered, perfect for threshold watchdogs.',
|
||||
"HERMES_CRON_MAX_PARALLEL (default 4) caps how many cron jobs run per tick so bursts don't saturate your keys.",
|
||||
|
||||
# --- Gateway Hooks ---
|
||||
'Gateway hooks live under ~/.hermes/hooks/<name>/ with HOOK.yaml + handler.py — handler must be named `handle`.',
|
||||
'Hook events include gateway:startup, session:start, agent:step, and command:* wildcard subscriptions.',
|
||||
'Drop a ~/.hermes/BOOT.md checklist and a gateway:startup hook runs it as a one-shot agent every boot.',
|
||||
|
||||
# --- Curator ---
|
||||
'hermes curator run --dry-run previews what the curator would archive or consolidate without mutating anything.',
|
||||
"hermes curator pin <skill> hard-fences a skill against both auto-archival and the agent's skill_manage tool.",
|
||||
'hermes curator rollback restores skills from a pre-run snapshot — backups live under skills/.curator_backups/.',
|
||||
|
||||
# --- Credential Pools & Routing ---
|
||||
'hermes auth reset <provider> clears all cooldowns and exhaustion flags on a credential pool.',
|
||||
'credential_pool_strategies.<provider>: round_robin cycles keys evenly instead of the fill_first default.',
|
||||
'use_gateway: true per-tool routes web, image, tts, or browser through your Nous subscription — no extra keys.',
|
||||
'provider_routing.data_collection: deny excludes data-storing providers on OpenRouter.',
|
||||
'provider_routing.require_parameters: true only routes to providers that support every param in your request.',
|
||||
|
||||
# --- TUI & Dashboard ---
|
||||
'HERMES_TUI_RESUME=1 auto-re-attaches to the most recent TUI session on launch — handy after SSH drops.',
|
||||
"HERMES_TUI_THEME=light|dark|<hex> forces the TUI theme on terminals that don't set COLORFGBG.",
|
||||
'Ctrl+G or Ctrl+X Ctrl+E in the TUI opens the input buffer in $EDITOR for long multi-line prompts.',
|
||||
'The TUI renders LaTeX inline — $E=mc^2$ becomes Unicode math instead of raw TeX.',
|
||||
'hermes dashboard launches a local web UI at 127.0.0.1:9119 — zero data leaves localhost.',
|
||||
'hermes dashboard --tui embeds the full Hermes TUI in your browser via xterm.js and a WebSocket PTY.',
|
||||
'Drop a YAML in ~/.hermes/dashboard-themes/ with two palette colors to reskin the entire dashboard.',
|
||||
'Dashboard plugins are drop-in: manifest.json + JS bundle in ~/.hermes/dashboard-plugins/ — no npm build required.',
|
||||
'layoutVariant: cockpit in a dashboard theme adds a 260px left rail that plugins can populate via the sidebar slot.',
|
||||
|
||||
# --- Env Vars & Config Gates ---
|
||||
"display.tool_progress_command: true exposes /verbose on messaging platforms; it's CLI-only by default.",
|
||||
'HERMES_BACKGROUND_NOTIFICATIONS=result only pings when background tasks finish (vs all/error/off).',
|
||||
'HERMES_WRITE_SAFE_ROOT restricts write_file and patch to a directory prefix; writes outside require approval.',
|
||||
'HERMES_IGNORE_RULES skips auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills.',
|
||||
'HERMES_ACCEPT_HOOKS auto-approves unseen shell hooks declared in config.yaml without a TTY prompt.',
|
||||
'auxiliary.goal_judge.model routes the /goal judge to a cheap fast model to keep loop cost near zero.',
|
||||
'Checkpoints skip directories with more than 50,000 files to avoid slow git operations on massive monorepos.',
|
||||
|
||||
# --- TTS ---
|
||||
'tts.provider: piper runs 44-language local TTS on CPU — voices auto-download to ~/.hermes/cache/piper-voices/.',
|
||||
'tts.providers.<name>.type: command wires any CLI TTS engine with {input_path} and {output_path} placeholders.',
|
||||
|
||||
# --- API Server & Proxy ---
|
||||
'API_SERVER_ENABLED=true runs an OpenAI-compatible endpoint alongside the gateway for Open WebUI and LibreChat.',
|
||||
'GATEWAY_PROXY_URL runs a split setup: platform I/O locally, agent work delegated to a remote API server.',
|
||||
|
||||
# --- Platform-specific ---
|
||||
'MATRIX_DEVICE_ID pins a stable device ID for E2EE — without it, keys rotate every start and historic decrypt breaks.',
|
||||
'TELEGRAM_WEBHOOK_SECRET is required whenever TELEGRAM_WEBHOOK_URL is set — generate with openssl rand -hex 32.',
|
||||
|
||||
# --- Batch ---
|
||||
"batch_runner.py --resume content-matches completed prompts by text so dataset reorders don't re-run finished work.",
|
||||
|
||||
# --- Less-Known Slash Commands ---
|
||||
'/new starts a fresh session in place (alias /reset) — fresh session ID, clean history, CLI stays open.',
|
||||
'/clear wipes the terminal screen AND starts a new session — one shortcut for a visual reset.',
|
||||
'/history prints the current conversation in-line without leaving the CLI — useful for a quick re-read.',
|
||||
'/save writes the current conversation to disk without ending the session.',
|
||||
'/status shows session info at a glance: ID, title, model, token usage, and elapsed time.',
|
||||
'/image <path> attaches a local image file for your next prompt without pasting or drag-and-drop.',
|
||||
'/platforms shows gateway and messaging-platform connection status right from inside chat.',
|
||||
'/commands paginates the full slash-command + installed-skill list — useful on platforms without tab completion.',
|
||||
'/toolsets lists every available toolset so you know what -t/--toolsets accepts.',
|
||||
'/gquota shows Google Gemini Code Assist quota usage with progress bars when that provider is active.',
|
||||
'/voice tts toggles TTS-only mode — agent replies out loud but you still type your prompts.',
|
||||
'/reload-skills re-scans ~/.hermes/skills/ so drop-in skills appear without restarting the session.',
|
||||
'/indicator kaomoji|emoji|unicode|ascii picks the TUI busy-indicator style shown during agent runs.',
|
||||
'/debug uploads a support bundle (system info + logs) and returns shareable links — works in chat too.',
|
||||
|
||||
# --- CLI Subcommands & Flags ---
|
||||
'hermes -z "<prompt>" is the purest one-shot: final answer on stdout, nothing else — ideal for piping in scripts.',
|
||||
'hermes chat --pass-session-id injects the session ID into the system prompt so the agent can self-reference it.',
|
||||
'hermes chat --image path/to/pic.png attaches a local image to a single -q query without a separate upload step.',
|
||||
'hermes chat --ignore-user-config skips ~/.hermes/config.yaml — reproducible bug reports and CI runs.',
|
||||
"hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.",
|
||||
'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.',
|
||||
'hermes sessions rename <ID> "new title" renames any past session; hermes sessions delete <ID> removes one.',
|
||||
'hermes import restores a session export or profile archive produced by sessions export or profile export.',
|
||||
'hermes fallback manages the fallback_model chain interactively — no hand-editing config.yaml.',
|
||||
'hermes pairing rotates the DM pairing token — the first messager after rotation claims access to the bot.',
|
||||
'hermes setup walks first-time users through provider, keys, and platform wiring in one interactive flow.',
|
||||
'hermes status --deep runs the full health sweep across every component; plain hermes status is the quick view.',
|
||||
|
||||
# --- Agent Behavior Env Vars ---
|
||||
'HERMES_AGENT_TIMEOUT=0 disables the gateway inactivity kill for a running agent — use for long research runs.',
|
||||
'HERMES_ENABLE_PROJECT_PLUGINS=1 auto-loads repo-local plugins from ./.hermes/plugins/ — trust-gated by design.',
|
||||
"HERMES_DISABLE_FILE_STATE_GUARD=1 turns off the 'file changed since you read it' guard on patch and write_file.",
|
||||
'HERMES_ALLOW_PRIVATE_URLS=true lets web tools hit localhost and private networks — off by default in gateway mode.',
|
||||
'HERMES_OPTIONAL_SKILLS=name1,name2 auto-installs extra optional-catalog skills on first run per profile.',
|
||||
'HERMES_BUNDLED_SKILLS points at a custom bundled-skill tree — used by Homebrew and Nix packaging.',
|
||||
'HERMES_DUMP_REQUEST_STDOUT=1 dumps every API request payload to stdout instead of log files.',
|
||||
'HERMES_OAUTH_TRACE=1 logs redacted OAuth token exchange and refresh attempts for debugging provider auth.',
|
||||
'HERMES_STREAM_RETRIES (default 3) controls mid-stream reconnect attempts on transient network errors.',
|
||||
|
||||
# --- Gateway Behavior Env Vars ---
|
||||
'HERMES_GATEWAY_BUSY_ACK_ENABLED=false silences the ⚡/⏳/⏩ ack messages when a user messages a busy agent.',
|
||||
'HERMES_AGENT_NOTIFY_INTERVAL (default 180s) sets how often the gateway pings with progress on long turns.',
|
||||
'HERMES_RESTART_DRAIN_TIMEOUT (default 900s) caps how long /restart waits for in-flight runs before forcing.',
|
||||
'HERMES_CHECKPOINT_TIMEOUT (default 30s) caps filesystem checkpoint creation — raise it on huge monorepos.',
|
||||
|
||||
# --- Auxiliary Tasks & Image Generation ---
|
||||
'image_gen.model in config.yaml picks the FAL model: flux-2/klein, gpt-image-2, nano-banana-pro, and more.',
|
||||
'image_gen.provider routes image generation through a plugin (OpenAI Images, Codex, FAL) instead of the default.',
|
||||
'AUXILIARY_VISION_BASE_URL + AUXILIARY_VISION_API_KEY point vision analysis at any OpenAI-compatible endpoint.',
|
||||
'auxiliary.session_search.max_concurrency bounds how many matched sessions are summarized in parallel (default 3).',
|
||||
'auxiliary.session_search.extra_body forwards provider-specific OpenAI-compatible fields on summarization calls.',
|
||||
|
||||
# --- Security ---
|
||||
'security.tirith_fail_open: false makes Hermes block commands when the tirith scanner itself errors out.',
|
||||
'TIRITH_FAIL_OPEN env var overrides the tirith_fail_open config — a quick toggle without editing config.yaml.',
|
||||
|
||||
# --- Sessions & Source Tags ---
|
||||
'--source tool chats are excluded from hermes sessions list by default — set --source explicitly to see them.',
|
||||
'Session IDs are timestamp-prefixed (20250305_091523_abcd) so sorting works naturally in ls and jq.',
|
||||
|
||||
# --- Misc ---
|
||||
'API_SERVER_MODEL_NAME customizes the model name on /v1/models — essential for multi-profile Open WebUI setups.',
|
||||
'Dashboard plugins are served from /dashboard-plugins/<name>/ — drop files into ~/.hermes/dashboard-plugins/.',
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user