feat: add multi-skill cron editing and docs

This commit is contained in:
teknium1 2026-03-14 19:18:10 -07:00
parent df5c61b37c
commit c3ea620796
19 changed files with 968 additions and 407 deletions

View file

@ -516,7 +516,8 @@ def get_cute_tool_message(
if tool_name == "cronjob": if tool_name == "cronjob":
action = args.get("action", "?") action = args.get("action", "?")
if action == "create": if action == "create":
label = args.get("name") or args.get("skill") or args.get("prompt", "task") skills = args.get("skills") or ([] if not args.get("skill") else [args.get("skill")])
label = args.get("name") or (skills[0] if skills else None) or args.get("prompt", "task")
return _wrap(f"┊ ⏰ cron create {_trunc(label, 24)} {dur}") return _wrap(f"┊ ⏰ cron create {_trunc(label, 24)} {dur}")
if action == "list": if action == "list":
return _wrap(f"┊ ⏰ cron listing {dur}") return _wrap(f"┊ ⏰ cron listing {dur}")

334
cli.py
View file

@ -429,7 +429,7 @@ from hermes_cli import callbacks as _callbacks
from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset
# Cron job system for scheduled tasks (execution is handled by the gateway) # Cron job system for scheduled tasks (execution is handled by the gateway)
from cron import create_job, list_jobs, remove_job, get_job, pause_job, resume_job, trigger_job from cron import get_job
# Resource cleanup imports for safe shutdown (terminal VMs, browser sessions) # Resource cleanup imports for safe shutdown (terminal VMs, browser sessions)
from tools.terminal_tool import cleanup_all_environments as _cleanup_all_terminals from tools.terminal_tool import cleanup_all_environments as _cleanup_all_terminals
@ -2588,162 +2588,248 @@ class HermesCLI:
def _handle_cron_command(self, cmd: str): def _handle_cron_command(self, cmd: str):
"""Handle the /cron command to manage scheduled tasks.""" """Handle the /cron command to manage scheduled tasks."""
parts = cmd.split(maxsplit=2) import shlex
from tools.cronjob_tools import cronjob as cronjob_tool
if len(parts) == 1:
# /cron - show help and list def _cron_api(**kwargs):
return json.loads(cronjob_tool(**kwargs))
def _normalize_skills(values):
normalized = []
for value in values:
text = str(value or "").strip()
if text and text not in normalized:
normalized.append(text)
return normalized
def _parse_flags(tokens):
opts = {
"name": None,
"deliver": None,
"repeat": None,
"skills": [],
"add_skills": [],
"remove_skills": [],
"clear_skills": False,
"all": False,
"prompt": None,
"schedule": None,
"positionals": [],
}
i = 0
while i < len(tokens):
token = tokens[i]
if token == "--name" and i + 1 < len(tokens):
opts["name"] = tokens[i + 1]
i += 2
elif token == "--deliver" and i + 1 < len(tokens):
opts["deliver"] = tokens[i + 1]
i += 2
elif token == "--repeat" and i + 1 < len(tokens):
try:
opts["repeat"] = int(tokens[i + 1])
except ValueError:
print("(._.) --repeat must be an integer")
return None
i += 2
elif token == "--skill" and i + 1 < len(tokens):
opts["skills"].append(tokens[i + 1])
i += 2
elif token == "--add-skill" and i + 1 < len(tokens):
opts["add_skills"].append(tokens[i + 1])
i += 2
elif token == "--remove-skill" and i + 1 < len(tokens):
opts["remove_skills"].append(tokens[i + 1])
i += 2
elif token == "--clear-skills":
opts["clear_skills"] = True
i += 1
elif token == "--all":
opts["all"] = True
i += 1
elif token == "--prompt" and i + 1 < len(tokens):
opts["prompt"] = tokens[i + 1]
i += 2
elif token == "--schedule" and i + 1 < len(tokens):
opts["schedule"] = tokens[i + 1]
i += 2
else:
opts["positionals"].append(token)
i += 1
return opts
tokens = shlex.split(cmd)
if len(tokens) == 1:
print() print()
print("+" + "-" * 60 + "+") print("+" + "-" * 68 + "+")
print("|" + " " * 18 + "(^_^) Scheduled Tasks" + " " * 19 + "|") print("|" + " " * 22 + "(^_^) Scheduled Tasks" + " " * 23 + "|")
print("+" + "-" * 60 + "+") print("+" + "-" * 68 + "+")
print() print()
print(" Commands:") print(" Commands:")
print(" /cron - List scheduled jobs") print(" /cron list")
print(" /cron list - List scheduled jobs") print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]')
print(' /cron add <schedule> <prompt> - Add a new job') print(' /cron edit <job_id> --schedule "every 4h" --prompt "New task"')
print(" /cron pause <job_id> - Pause a job") print(" /cron edit <job_id> --skill blogwatcher --skill find-nearby")
print(" /cron resume <job_id> - Resume a job") print(" /cron edit <job_id> --remove-skill blogwatcher")
print(" /cron run <job_id> - Run a job on the next tick") print(" /cron edit <job_id> --clear-skills")
print(" /cron remove <job_id> - Remove a job") print(" /cron pause <job_id>")
print(" /cron resume <job_id>")
print(" /cron run <job_id>")
print(" /cron remove <job_id>")
print() print()
print(" Schedule formats:") result = _cron_api(action="list")
print(" 30m, 2h, 1d - One-shot delay") jobs = result.get("jobs", []) if result.get("success") else []
print(' "every 30m", "every 2h" - Recurring interval')
print(' "0 9 * * *" - Cron expression')
print()
# Show current jobs
jobs = list_jobs()
if jobs: if jobs:
print(" Current Jobs:") print(" Current Jobs:")
print(" " + "-" * 55) print(" " + "-" * 63)
for job in jobs: for job in jobs:
# Format repeat status repeat_str = job.get("repeat", "?")
times = job["repeat"].get("times") print(f" {job['job_id'][:12]:<12} | {job['schedule']:<15} | {repeat_str:<8}")
completed = job["repeat"].get("completed", 0) if job.get("skills"):
if times is None: print(f" Skills: {', '.join(job['skills'])}")
repeat_str = "forever" print(f" {job.get('prompt_preview', '')}")
else:
repeat_str = f"{completed}/{times}"
print(f" {job['id'][:12]:<12} | {job['schedule_display']:<15} | {repeat_str:<8}")
prompt_preview = job['prompt'][:45] + "..." if len(job['prompt']) > 45 else job['prompt']
print(f" {prompt_preview}")
if job.get("next_run_at"): if job.get("next_run_at"):
from datetime import datetime print(f" Next: {job['next_run_at']}")
next_run = datetime.fromisoformat(job["next_run_at"])
print(f" Next: {next_run.strftime('%Y-%m-%d %H:%M')}")
print() print()
else: else:
print(" No scheduled jobs. Use '/cron add' to create one.") print(" No scheduled jobs. Use '/cron add' to create one.")
print() print()
return return
subcommand = parts[1].lower() subcommand = tokens[1].lower()
opts = _parse_flags(tokens[2:])
if opts is None:
return
if subcommand == "list": if subcommand == "list":
# /cron list - just show jobs result = _cron_api(action="list", include_disabled=opts["all"])
jobs = list_jobs() jobs = result.get("jobs", []) if result.get("success") else []
if not jobs: if not jobs:
print("(._.) No scheduled jobs.") print("(._.) No scheduled jobs.")
return return
print() print()
print("Scheduled Jobs:") print("Scheduled Jobs:")
print("-" * 70) print("-" * 80)
for job in jobs: for job in jobs:
times = job["repeat"].get("times") print(f" ID: {job['job_id']}")
completed = job["repeat"].get("completed", 0)
repeat_str = "forever" if times is None else f"{completed}/{times}"
print(f" ID: {job['id']}")
print(f" Name: {job['name']}") print(f" Name: {job['name']}")
print(f" Schedule: {job['schedule_display']} ({repeat_str})") print(f" State: {job.get('state', '?')}")
print(f" Schedule: {job['schedule']} ({job.get('repeat', '?')})")
print(f" Next run: {job.get('next_run_at', 'N/A')}") print(f" Next run: {job.get('next_run_at', 'N/A')}")
print(f" Prompt: {job['prompt'][:80]}{'...' if len(job['prompt']) > 80 else ''}") if job.get("skills"):
print(f" Skills: {', '.join(job['skills'])}")
print(f" Prompt: {job.get('prompt_preview', '')}")
if job.get("last_run_at"): if job.get("last_run_at"):
print(f" Last run: {job['last_run_at']} ({job.get('last_status', '?')})") print(f" Last run: {job['last_run_at']} ({job.get('last_status', '?')})")
print() print()
return
elif subcommand == "add":
# /cron add <schedule> <prompt> if subcommand in {"add", "create"}:
if len(parts) < 3: positionals = opts["positionals"]
if not positionals:
print("(._.) Usage: /cron add <schedule> <prompt>") print("(._.) Usage: /cron add <schedule> <prompt>")
print(" Example: /cron add 30m Remind me to take a break")
print(' Example: /cron add "every 2h" Check server status at 192.168.1.1')
return return
schedule = opts["schedule"] or positionals[0]
# Parse schedule and prompt prompt = opts["prompt"] or " ".join(positionals[1:])
rest = parts[2].strip() skills = _normalize_skills(opts["skills"])
if not prompt and not skills:
# Handle quoted schedule (e.g., "every 30m" or "0 9 * * *") print("(._.) Please provide a prompt or at least one skill")
if rest.startswith('"'): return
# Find closing quote result = _cron_api(
close_quote = rest.find('"', 1) action="create",
if close_quote == -1: schedule=schedule,
print("(._.) Unmatched quote in schedule") prompt=prompt or None,
return name=opts["name"],
schedule = rest[1:close_quote] deliver=opts["deliver"],
prompt = rest[close_quote + 1:].strip() repeat=opts["repeat"],
skills=skills or None,
)
if result.get("success"):
print(f"(^_^)b Created job: {result['job_id']}")
print(f" Schedule: {result['schedule']}")
if result.get("skills"):
print(f" Skills: {', '.join(result['skills'])}")
print(f" Next run: {result['next_run_at']}")
else: else:
# First word is schedule print(f"(x_x) Failed to create job: {result.get('error')}")
schedule_parts = rest.split(maxsplit=1) return
schedule = schedule_parts[0]
prompt = schedule_parts[1] if len(schedule_parts) > 1 else ""
if not prompt:
print("(._.) Please provide a prompt for the job")
return
try:
job = create_job(prompt=prompt, schedule=schedule)
print(f"(^_^)b Created job: {job['id']}")
print(f" Schedule: {job['schedule_display']}")
print(f" Next run: {job['next_run_at']}")
except Exception as e:
print(f"(x_x) Failed to create job: {e}")
elif subcommand in {"pause", "resume", "run", "remove", "rm", "delete"}:
if len(parts) < 3:
print(f"(._.) Usage: /cron {subcommand} <job_id>")
return
job_id = parts[2].strip() if subcommand == "edit":
job = get_job(job_id) positionals = opts["positionals"]
if not positionals:
if not job: print("(._.) Usage: /cron edit <job_id> [--schedule ...] [--prompt ...] [--skill ...]")
return
job_id = positionals[0]
existing = get_job(job_id)
if not existing:
print(f"(._.) Job not found: {job_id}") print(f"(._.) Job not found: {job_id}")
return return
if subcommand == "pause": final_skills = None
updated = pause_job(job_id, reason="paused from /cron") replacement_skills = _normalize_skills(opts["skills"])
if updated: add_skills = _normalize_skills(opts["add_skills"])
print(f"(^_^)b Paused job: {updated['name']} ({job_id})") remove_skills = set(_normalize_skills(opts["remove_skills"]))
else: existing_skills = list(existing.get("skills") or ([] if not existing.get("skill") else [existing.get("skill")]))
print(f"(x_x) Failed to pause job: {job_id}") if opts["clear_skills"]:
elif subcommand == "resume": final_skills = []
updated = resume_job(job_id) elif replacement_skills:
if updated: final_skills = replacement_skills
print(f"(^_^)b Resumed job: {updated['name']} ({job_id})") elif add_skills or remove_skills:
print(f" Next run: {updated.get('next_run_at')}") final_skills = [skill for skill in existing_skills if skill not in remove_skills]
else: for skill in add_skills:
print(f"(x_x) Failed to resume job: {job_id}") if skill not in final_skills:
elif subcommand == "run": final_skills.append(skill)
updated = trigger_job(job_id)
if updated:
print(f"(^_^)b Triggered job: {updated['name']} ({job_id})")
print(" It will run on the next scheduler tick.")
else:
print(f"(x_x) Failed to trigger job: {job_id}")
else:
if remove_job(job_id):
print(f"(^_^)b Removed job: {job['name']} ({job_id})")
else:
print(f"(x_x) Failed to remove job: {job_id}")
else: result = _cron_api(
print(f"(._.) Unknown cron command: {subcommand}") action="update",
print(" Available: list, add, pause, resume, run, remove") job_id=job_id,
schedule=opts["schedule"],
prompt=opts["prompt"],
name=opts["name"],
deliver=opts["deliver"],
repeat=opts["repeat"],
skills=final_skills,
)
if result.get("success"):
job = result["job"]
print(f"(^_^)b Updated job: {job['job_id']}")
print(f" Schedule: {job['schedule']}")
if job.get("skills"):
print(f" Skills: {', '.join(job['skills'])}")
else:
print(" Skills: none")
else:
print(f"(x_x) Failed to update job: {result.get('error')}")
return
if subcommand in {"pause", "resume", "run", "remove", "rm", "delete"}:
positionals = opts["positionals"]
if not positionals:
print(f"(._.) Usage: /cron {subcommand} <job_id>")
return
job_id = positionals[0]
action = "remove" if subcommand in {"remove", "rm", "delete"} else subcommand
result = _cron_api(action=action, job_id=job_id, reason="paused from /cron" if action == "pause" else None)
if not result.get("success"):
print(f"(x_x) Failed to {action} job: {result.get('error')}")
return
if action == "pause":
print(f"(^_^)b Paused job: {result['job']['name']} ({job_id})")
elif action == "resume":
print(f"(^_^)b Resumed job: {result['job']['name']} ({job_id})")
print(f" Next run: {result['job'].get('next_run_at')}")
elif action == "run":
print(f"(^_^)b Triggered job: {result['job']['name']} ({job_id})")
print(" It will run on the next scheduler tick.")
else:
removed = result.get("removed_job", {})
print(f"(^_^)b Removed job: {removed.get('name', job_id)} ({job_id})")
return
print(f"(._.) Unknown cron command: {subcommand}")
print(" Available: list, add, edit, pause, resume, run, remove")
def _handle_skills_command(self, cmd: str): def _handle_skills_command(self, cmd: str):
"""Handle /skills slash command — delegates to hermes_cli.skills_hub.""" """Handle /skills slash command — delegates to hermes_cli.skills_hub."""

View file

@ -32,6 +32,32 @@ JOBS_FILE = CRON_DIR / "jobs.json"
OUTPUT_DIR = CRON_DIR / "output" OUTPUT_DIR = CRON_DIR / "output"
def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]:
"""Normalize legacy/single-skill and multi-skill inputs into a unique ordered list."""
if skills is None:
raw_items = [skill] if skill else []
elif isinstance(skills, str):
raw_items = [skills]
else:
raw_items = list(skills)
normalized: List[str] = []
for item in raw_items:
text = str(item or "").strip()
if text and text not in normalized:
normalized.append(text)
return normalized
def _apply_skill_fields(job: Dict[str, Any]) -> Dict[str, Any]:
"""Return a job dict with canonical `skills` and legacy `skill` fields aligned."""
normalized = dict(job)
skills = _normalize_skill_list(normalized.get("skill"), normalized.get("skills"))
normalized["skills"] = skills
normalized["skill"] = skills[0] if skills else None
return normalized
def _secure_dir(path: Path): def _secure_dir(path: Path):
"""Set directory to owner-only access (0700). No-op on Windows.""" """Set directory to owner-only access (0700). No-op on Windows."""
try: try:
@ -265,6 +291,7 @@ def create_job(
deliver: Optional[str] = None, deliver: Optional[str] = None,
origin: Optional[Dict[str, Any]] = None, origin: Optional[Dict[str, Any]] = None,
skill: Optional[str] = None, skill: Optional[str] = None,
skills: Optional[List[str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create a new cron job. Create a new cron job.
@ -276,7 +303,8 @@ def create_job(
repeat: How many times to run (None = forever, 1 = once) repeat: How many times to run (None = forever, 1 = once)
deliver: Where to deliver output ("origin", "local", "telegram", etc.) deliver: Where to deliver output ("origin", "local", "telegram", etc.)
origin: Source info where job was created (for "origin" delivery) origin: Source info where job was created (for "origin" delivery)
skill: Optional skill name to load before running the prompt skill: Optional legacy single skill name to load before running the prompt
skills: Optional ordered list of skills to load before running the prompt
Returns: Returns:
The created job dict The created job dict
@ -294,12 +322,14 @@ def create_job(
job_id = uuid.uuid4().hex[:12] job_id = uuid.uuid4().hex[:12]
now = _hermes_now().isoformat() now = _hermes_now().isoformat()
label_source = skill or prompt or "cron job" normalized_skills = _normalize_skill_list(skill, skills)
label_source = (normalized_skills[0] if normalized_skills else prompt) or "cron job"
job = { job = {
"id": job_id, "id": job_id,
"name": name or label_source[:50].strip(), "name": name or label_source[:50].strip(),
"prompt": prompt, "prompt": prompt,
"skill": skill, "skills": normalized_skills,
"skill": normalized_skills[0] if normalized_skills else None,
"schedule": parsed_schedule, "schedule": parsed_schedule,
"schedule_display": parsed_schedule.get("display", schedule), "schedule_display": parsed_schedule.get("display", schedule),
"repeat": { "repeat": {
@ -332,13 +362,13 @@ def get_job(job_id: str) -> Optional[Dict[str, Any]]:
jobs = load_jobs() jobs = load_jobs()
for job in jobs: for job in jobs:
if job["id"] == job_id: if job["id"] == job_id:
return job return _apply_skill_fields(job)
return None return None
def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]: def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]:
"""List all jobs, optionally including disabled ones.""" """List all jobs, optionally including disabled ones."""
jobs = load_jobs() jobs = [_apply_skill_fields(j) for j in load_jobs()]
if not include_disabled: if not include_disabled:
jobs = [j for j in jobs if j.get("enabled", True)] jobs = [j for j in jobs if j.get("enabled", True)]
return jobs return jobs
@ -351,9 +381,14 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
if job["id"] != job_id: if job["id"] != job_id:
continue continue
updated = {**job, **updates} updated = _apply_skill_fields({**job, **updates})
schedule_changed = "schedule" in updates schedule_changed = "schedule" in updates
if "skills" in updates or "skill" in updates:
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
updated["skills"] = normalized_skills
updated["skill"] = normalized_skills[0] if normalized_skills else None
if schedule_changed: if schedule_changed:
updated_schedule = updated["schedule"] updated_schedule = updated["schedule"]
updated["schedule_display"] = updates.get( updated["schedule_display"] = updates.get(
@ -368,7 +403,7 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
jobs[i] = updated jobs[i] = updated
save_jobs(jobs) save_jobs(jobs)
return jobs[i] return _apply_skill_fields(jobs[i])
return None return None
@ -479,21 +514,21 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None):
def get_due_jobs() -> List[Dict[str, Any]]: def get_due_jobs() -> List[Dict[str, Any]]:
"""Get all jobs that are due to run now.""" """Get all jobs that are due to run now."""
now = _hermes_now() now = _hermes_now()
jobs = load_jobs() jobs = [_apply_skill_fields(j) for j in load_jobs()]
due = [] due = []
for job in jobs: for job in jobs:
if not job.get("enabled", True): if not job.get("enabled", True):
continue continue
next_run = job.get("next_run_at") next_run = job.get("next_run_at")
if not next_run: if not next_run:
continue continue
next_run_dt = _ensure_aware(datetime.fromisoformat(next_run)) next_run_dt = _ensure_aware(datetime.fromisoformat(next_run))
if next_run_dt <= now: if next_run_dt <= now:
due.append(job) due.append(job)
return due return due

View file

@ -149,25 +149,37 @@ def _deliver_result(job: dict, content: str) -> None:
def _build_job_prompt(job: dict) -> str: def _build_job_prompt(job: dict) -> str:
"""Build the effective prompt for a cron job, optionally loading a skill first.""" """Build the effective prompt for a cron job, optionally loading one or more skills first."""
prompt = job.get("prompt", "") prompt = job.get("prompt", "")
skill_name = job.get("skill") skills = job.get("skills")
if not skill_name: if skills is None:
legacy = job.get("skill")
skills = [legacy] if legacy else []
skill_names = [str(name).strip() for name in skills if str(name).strip()]
if not skill_names:
return prompt return prompt
from tools.skills_tool import skill_view from tools.skills_tool import skill_view
loaded = json.loads(skill_view(skill_name)) parts = []
if not loaded.get("success"): for skill_name in skill_names:
error = loaded.get("error") or f"Failed to load skill '{skill_name}'" loaded = json.loads(skill_view(skill_name))
raise RuntimeError(error) if not loaded.get("success"):
error = loaded.get("error") or f"Failed to load skill '{skill_name}'"
raise RuntimeError(error)
content = str(loaded.get("content") or "").strip()
if parts:
parts.append("")
parts.extend(
[
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
"",
content,
]
)
content = str(loaded.get("content") or "").strip()
parts = [
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
"",
content,
]
if prompt: if prompt:
parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"]) parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"])
return "\n".join(parts) return "\n".join(parts)

View file

@ -315,7 +315,7 @@ def build_delivery_context_for_tool(
origin: Optional[SessionSource] = None origin: Optional[SessionSource] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Build context for the schedule_cronjob tool to understand delivery options. Build context for the unified cronjob tool to understand delivery options.
This is passed to the tool so it can validate and explain delivery targets. This is passed to the tool so it can validate and explain delivery targets.
""" """

View file

@ -1,15 +1,14 @@
""" """
Cron subcommand for hermes CLI. Cron subcommand for hermes CLI.
Handles: hermes cron [list|status|tick] Handles standalone cron management commands like list, create, edit,
pause/resume/run/remove, status, and tick.
Cronjobs are executed automatically by the gateway daemon (hermes gateway).
Install the gateway as a service for background execution:
hermes gateway install
""" """
import json
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Iterable, List, Optional
PROJECT_ROOT = Path(__file__).parent.parent.resolve() PROJECT_ROOT = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
@ -17,58 +16,82 @@ sys.path.insert(0, str(PROJECT_ROOT))
from hermes_cli.colors import Colors, color from hermes_cli.colors import Colors, color
def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
if skills is None:
if single_skill is None:
return None
raw_items = [single_skill]
else:
raw_items = list(skills)
normalized: List[str] = []
for item in raw_items:
text = str(item or "").strip()
if text and text not in normalized:
normalized.append(text)
return normalized
def _cron_api(**kwargs):
from tools.cronjob_tools import cronjob as cronjob_tool
return json.loads(cronjob_tool(**kwargs))
def cron_list(show_all: bool = False): def cron_list(show_all: bool = False):
"""List all scheduled jobs.""" """List all scheduled jobs."""
from cron.jobs import list_jobs from cron.jobs import list_jobs
jobs = list_jobs(include_disabled=show_all) jobs = list_jobs(include_disabled=show_all)
if not jobs: if not jobs:
print(color("No scheduled jobs.", Colors.DIM)) print(color("No scheduled jobs.", Colors.DIM))
print(color("Create one with the /cron add command in chat, or via Telegram.", Colors.DIM)) print(color("Create one with 'hermes cron create ...' or the /cron command in chat.", Colors.DIM))
return return
print() print()
print(color("┌─────────────────────────────────────────────────────────────────────────┐", Colors.CYAN)) print(color("┌─────────────────────────────────────────────────────────────────────────┐", Colors.CYAN))
print(color("│ Scheduled Jobs │", Colors.CYAN)) print(color("│ Scheduled Jobs │", Colors.CYAN))
print(color("└─────────────────────────────────────────────────────────────────────────┘", Colors.CYAN)) print(color("└─────────────────────────────────────────────────────────────────────────┘", Colors.CYAN))
print() print()
for job in jobs: for job in jobs:
job_id = job.get("id", "?")[:8] job_id = job.get("id", "?")[:8]
name = job.get("name", "(unnamed)") name = job.get("name", "(unnamed)")
schedule = job.get("schedule_display", job.get("schedule", {}).get("value", "?")) schedule = job.get("schedule_display", job.get("schedule", {}).get("value", "?"))
enabled = job.get("enabled", True) state = job.get("state", "scheduled" if job.get("enabled", True) else "paused")
next_run = job.get("next_run_at", "?") next_run = job.get("next_run_at", "?")
repeat_info = job.get("repeat", {}) repeat_info = job.get("repeat", {})
repeat_times = repeat_info.get("times") repeat_times = repeat_info.get("times")
repeat_completed = repeat_info.get("completed", 0) repeat_completed = repeat_info.get("completed", 0)
repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else ""
if repeat_times:
repeat_str = f"{repeat_completed}/{repeat_times}"
else:
repeat_str = ""
deliver = job.get("deliver", ["local"]) deliver = job.get("deliver", ["local"])
if isinstance(deliver, str): if isinstance(deliver, str):
deliver = [deliver] deliver = [deliver]
deliver_str = ", ".join(deliver) deliver_str = ", ".join(deliver)
if not enabled: skills = job.get("skills") or ([job["skill"]] if job.get("skill") else [])
status = color("[disabled]", Colors.RED) if state == "paused":
else: status = color("[paused]", Colors.YELLOW)
elif state == "completed":
status = color("[completed]", Colors.BLUE)
elif job.get("enabled", True):
status = color("[active]", Colors.GREEN) status = color("[active]", Colors.GREEN)
else:
status = color("[disabled]", Colors.RED)
print(f" {color(job_id, Colors.YELLOW)} {status}") print(f" {color(job_id, Colors.YELLOW)} {status}")
print(f" Name: {name}") print(f" Name: {name}")
print(f" Schedule: {schedule}") print(f" Schedule: {schedule}")
print(f" Repeat: {repeat_str}") print(f" Repeat: {repeat_str}")
print(f" Next run: {next_run}") print(f" Next run: {next_run}")
print(f" Deliver: {deliver_str}") print(f" Deliver: {deliver_str}")
if skills:
print(f" Skills: {', '.join(skills)}")
print() print()
# Warn if gateway isn't running
from hermes_cli.gateway import find_gateway_pids from hermes_cli.gateway import find_gateway_pids
if not find_gateway_pids(): if not find_gateway_pids():
print(color(" ⚠ Gateway is not running — jobs won't fire automatically.", Colors.YELLOW)) print(color(" ⚠ Gateway is not running — jobs won't fire automatically.", Colors.YELLOW))
@ -86,9 +109,9 @@ def cron_status():
"""Show cron execution status.""" """Show cron execution status."""
from cron.jobs import list_jobs from cron.jobs import list_jobs
from hermes_cli.gateway import find_gateway_pids from hermes_cli.gateway import find_gateway_pids
print() print()
pids = find_gateway_pids() pids = find_gateway_pids()
if pids: if pids:
print(color("✓ Gateway is running — cron jobs will fire automatically", Colors.GREEN)) print(color("✓ Gateway is running — cron jobs will fire automatically", Colors.GREEN))
@ -99,9 +122,9 @@ def cron_status():
print(" To enable automatic execution:") print(" To enable automatic execution:")
print(" hermes gateway install # Install as system service (recommended)") print(" hermes gateway install # Install as system service (recommended)")
print(" hermes gateway # Or run in foreground") print(" hermes gateway # Or run in foreground")
print() print()
jobs = list_jobs(include_disabled=False) jobs = list_jobs(include_disabled=False)
if jobs: if jobs:
next_runs = [j.get("next_run_at") for j in jobs if j.get("next_run_at")] next_runs = [j.get("next_run_at") for j in jobs if j.get("next_run_at")]
@ -110,25 +133,131 @@ def cron_status():
print(f" Next run: {min(next_runs)}") print(f" Next run: {min(next_runs)}")
else: else:
print(" No active jobs") print(" No active jobs")
print() print()
def cron_create(args):
result = _cron_api(
action="create",
schedule=args.schedule,
prompt=args.prompt,
name=getattr(args, "name", None),
deliver=getattr(args, "deliver", None),
repeat=getattr(args, "repeat", None),
skill=getattr(args, "skill", None),
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
)
if not result.get("success"):
print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED))
return 1
print(color(f"Created job: {result['job_id']}", Colors.GREEN))
print(f" Name: {result['name']}")
print(f" Schedule: {result['schedule']}")
if result.get("skills"):
print(f" Skills: {', '.join(result['skills'])}")
print(f" Next run: {result['next_run_at']}")
return 0
def cron_edit(args):
from cron.jobs import get_job
job = get_job(args.job_id)
if not job:
print(color(f"Job not found: {args.job_id}", Colors.RED))
return 1
existing_skills = list(job.get("skills") or ([] if not job.get("skill") else [job.get("skill")]))
replacement_skills = _normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None))
add_skills = _normalize_skills(None, getattr(args, "add_skills", None)) or []
remove_skills = set(_normalize_skills(None, getattr(args, "remove_skills", None)) or [])
final_skills = None
if getattr(args, "clear_skills", False):
final_skills = []
elif replacement_skills is not None:
final_skills = replacement_skills
elif add_skills or remove_skills:
final_skills = [skill for skill in existing_skills if skill not in remove_skills]
for skill in add_skills:
if skill not in final_skills:
final_skills.append(skill)
result = _cron_api(
action="update",
job_id=args.job_id,
schedule=getattr(args, "schedule", None),
prompt=getattr(args, "prompt", None),
name=getattr(args, "name", None),
deliver=getattr(args, "deliver", None),
repeat=getattr(args, "repeat", None),
skills=final_skills,
)
if not result.get("success"):
print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED))
return 1
updated = result["job"]
print(color(f"Updated job: {updated['job_id']}", Colors.GREEN))
print(f" Name: {updated['name']}")
print(f" Schedule: {updated['schedule']}")
if updated.get("skills"):
print(f" Skills: {', '.join(updated['skills'])}")
else:
print(" Skills: none")
return 0
def _job_action(action: str, job_id: str, success_verb: str) -> int:
result = _cron_api(action=action, job_id=job_id)
if not result.get("success"):
print(color(f"Failed to {action} job: {result.get('error', 'unknown error')}", Colors.RED))
return 1
job = result.get("job") or result.get("removed_job") or {}
print(color(f"{success_verb} job: {job.get('name', job_id)} ({job_id})", Colors.GREEN))
if action in {"resume", "run"} and result.get("job", {}).get("next_run_at"):
print(f" Next run: {result['job']['next_run_at']}")
if action == "run":
print(" It will run on the next scheduler tick.")
return 0
def cron_command(args): def cron_command(args):
"""Handle cron subcommands.""" """Handle cron subcommands."""
subcmd = getattr(args, 'cron_command', None) subcmd = getattr(args, 'cron_command', None)
if subcmd is None or subcmd == "list": if subcmd is None or subcmd == "list":
show_all = getattr(args, 'all', False) show_all = getattr(args, 'all', False)
cron_list(show_all) cron_list(show_all)
return 0
elif subcmd == "tick":
cron_tick() if subcmd == "status":
elif subcmd == "status":
cron_status() cron_status()
return 0
else:
print(f"Unknown cron command: {subcmd}") if subcmd == "tick":
print("Usage: hermes cron [list|status|tick]") cron_tick()
sys.exit(1) return 0
if subcmd in {"create", "add"}:
return cron_create(args)
if subcmd == "edit":
return cron_edit(args)
if subcmd == "pause":
return _job_action("pause", args.job_id, "Paused")
if subcmd == "resume":
return _job_action("resume", args.job_id, "Resumed")
if subcmd == "run":
return _job_action("run", args.job_id, "Triggered")
if subcmd in {"remove", "rm", "delete"}:
return _job_action("remove", args.job_id, "Removed")
print(f"Unknown cron command: {subcmd}")
print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|status|tick]")
sys.exit(1)

View file

@ -2589,13 +2589,48 @@ For more help on a command:
# cron list # cron list
cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs") cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs")
cron_list.add_argument("--all", action="store_true", help="Include disabled jobs") cron_list.add_argument("--all", action="store_true", help="Include disabled jobs")
# cron create/add
cron_create = cron_subparsers.add_parser("create", aliases=["add"], help="Create a scheduled job")
cron_create.add_argument("schedule", help="Schedule like '30m', 'every 2h', or '0 9 * * *'")
cron_create.add_argument("prompt", nargs="?", help="Optional self-contained prompt or task instruction")
cron_create.add_argument("--name", help="Optional human-friendly job name")
cron_create.add_argument("--deliver", help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id")
cron_create.add_argument("--repeat", type=int, help="Optional repeat count")
cron_create.add_argument("--skill", dest="skills", action="append", help="Attach a skill. Repeat to add multiple skills.")
# cron edit
cron_edit = cron_subparsers.add_parser("edit", help="Edit an existing scheduled job")
cron_edit.add_argument("job_id", help="Job ID to edit")
cron_edit.add_argument("--schedule", help="New schedule")
cron_edit.add_argument("--prompt", help="New prompt/task instruction")
cron_edit.add_argument("--name", help="New job name")
cron_edit.add_argument("--deliver", help="New delivery target")
cron_edit.add_argument("--repeat", type=int, help="New repeat count")
cron_edit.add_argument("--skill", dest="skills", action="append", help="Replace the job's skills with this set. Repeat to attach multiple skills.")
cron_edit.add_argument("--add-skill", dest="add_skills", action="append", help="Append a skill without replacing the existing list. Repeatable.")
cron_edit.add_argument("--remove-skill", dest="remove_skills", action="append", help="Remove a specific attached skill. Repeatable.")
cron_edit.add_argument("--clear-skills", action="store_true", help="Remove all attached skills from the job")
# lifecycle actions
cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")
cron_pause.add_argument("job_id", help="Job ID to pause")
cron_resume = cron_subparsers.add_parser("resume", help="Resume a paused job")
cron_resume.add_argument("job_id", help="Job ID to resume")
cron_run = cron_subparsers.add_parser("run", help="Run a job on the next scheduler tick")
cron_run.add_argument("job_id", help="Job ID to trigger")
cron_remove = cron_subparsers.add_parser("remove", aliases=["rm", "delete"], help="Remove a scheduled job")
cron_remove.add_argument("job_id", help="Job ID to remove")
# cron status # cron status
cron_subparsers.add_parser("status", help="Check if cron scheduler is running") cron_subparsers.add_parser("status", help="Check if cron scheduler is running")
# cron tick (mostly for debugging) # cron tick (mostly for debugging)
cron_subparsers.add_parser("tick", help="Run due jobs once and exit") cron_subparsers.add_parser("tick", help="Run due jobs once and exit")
cron_parser.set_defaults(func=cmd_cron) cron_parser.set_defaults(func=cmd_cron)
# ========================================================================= # =========================================================================

View file

@ -91,7 +91,7 @@ CONFIGURABLE_TOOLSETS = [
("session_search", "🔎 Session Search", "search past conversations"), ("session_search", "🔎 Session Search", "search past conversations"),
("clarify", "❓ Clarifying Questions", "clarify"), ("clarify", "❓ Clarifying Questions", "clarify"),
("delegation", "👥 Task Delegation", "delegate_task"), ("delegation", "👥 Task Delegation", "delegate_task"),
("cronjob", "⏰ Cron Jobs", "create, list, update, pause, resume, remove, run"), ("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"),
("rl", "🧪 RL Training", "Tinker-Atropos training tools"), ("rl", "🧪 RL Training", "Tinker-Atropos training tools"),
("homeassistant", "🏠 Home Assistant", "smart home device control"), ("homeassistant", "🏠 Home Assistant", "smart home device control"),
] ]

View file

@ -248,3 +248,49 @@ class TestRunJobSkillBacked:
assert "blogwatcher" in prompt_arg assert "blogwatcher" in prompt_arg
assert "Follow this skill" in prompt_arg assert "Follow this skill" in prompt_arg
assert "Check the feeds and summarize anything new." in prompt_arg assert "Check the feeds and summarize anything new." in prompt_arg
def test_run_job_loads_multiple_skills_in_order(self, tmp_path):
job = {
"id": "multi-skill-job",
"name": "multi skill test",
"prompt": "Combine the results.",
"skills": ["blogwatcher", "find-nearby"],
}
fake_db = MagicMock()
def _skill_view(name):
return json.dumps({"success": True, "content": f"# {name}\nInstructions for {name}."})
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("dotenv.load_dotenv"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
"api_key": "***",
"base_url": "https://example.invalid/v1",
"provider": "openrouter",
"api_mode": "chat_completions",
},
), \
patch("tools.skills_tool.skill_view", side_effect=_skill_view) as skill_view_mock, \
patch("run_agent.AIAgent") as mock_agent_cls:
mock_agent = MagicMock()
mock_agent.run_conversation.return_value = {"final_response": "ok"}
mock_agent_cls.return_value = mock_agent
success, output, final_response, error = run_job(job)
assert success is True
assert error is None
assert final_response == "ok"
assert skill_view_mock.call_count == 2
assert [call.args[0] for call in skill_view_mock.call_args_list] == ["blogwatcher", "find-nearby"]
prompt_arg = mock_agent.run_conversation.call_args.args[0]
assert prompt_arg.index("blogwatcher") < prompt_arg.index("find-nearby")
assert "Instructions for blogwatcher." in prompt_arg
assert "Instructions for find-nearby." in prompt_arg
assert "Combine the results." in prompt_arg

View file

@ -0,0 +1,107 @@
"""Tests for hermes_cli.cron command handling."""
from argparse import Namespace
import pytest
from cron.jobs import create_job, get_job, list_jobs
from hermes_cli.cron import cron_command
@pytest.fixture()
def tmp_cron_dir(tmp_path, monkeypatch):
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
return tmp_path
class TestCronCommandLifecycle:
def test_pause_resume_run(self, tmp_cron_dir, capsys):
job = create_job(prompt="Check server status", schedule="every 1h")
cron_command(Namespace(cron_command="pause", job_id=job["id"]))
paused = get_job(job["id"])
assert paused["state"] == "paused"
cron_command(Namespace(cron_command="resume", job_id=job["id"]))
resumed = get_job(job["id"])
assert resumed["state"] == "scheduled"
cron_command(Namespace(cron_command="run", job_id=job["id"]))
triggered = get_job(job["id"])
assert triggered["state"] == "scheduled"
out = capsys.readouterr().out
assert "Paused job" in out
assert "Resumed job" in out
assert "Triggered job" in out
def test_edit_can_replace_and_clear_skills(self, tmp_cron_dir, capsys):
job = create_job(
prompt="Combine skill outputs",
schedule="every 1h",
skill="blogwatcher",
)
cron_command(
Namespace(
cron_command="edit",
job_id=job["id"],
schedule="every 2h",
prompt="Revised prompt",
name="Edited Job",
deliver=None,
repeat=None,
skill=None,
skills=["find-nearby", "blogwatcher"],
clear_skills=False,
)
)
updated = get_job(job["id"])
assert updated["skills"] == ["find-nearby", "blogwatcher"]
assert updated["name"] == "Edited Job"
assert updated["prompt"] == "Revised prompt"
assert updated["schedule_display"] == "every 120m"
cron_command(
Namespace(
cron_command="edit",
job_id=job["id"],
schedule=None,
prompt=None,
name=None,
deliver=None,
repeat=None,
skill=None,
skills=None,
clear_skills=True,
)
)
cleared = get_job(job["id"])
assert cleared["skills"] == []
assert cleared["skill"] is None
out = capsys.readouterr().out
assert "Updated job" in out
def test_create_with_multiple_skills(self, tmp_cron_dir, capsys):
cron_command(
Namespace(
cron_command="create",
schedule="every 1h",
prompt="Use both skills",
name="Skill combo",
deliver=None,
repeat=None,
skill=None,
skills=["blogwatcher", "find-nearby"],
)
)
out = capsys.readouterr().out
assert "Created job" in out
jobs = list_jobs()
assert len(jobs) == 1
assert jobs[0]["skills"] == ["blogwatcher", "find-nearby"]
assert jobs[0]["name"] == "Skill combo"

View file

@ -245,3 +245,35 @@ class TestUnifiedCronjobTool:
listing = json.loads(cronjob(action="list")) listing = json.loads(cronjob(action="list"))
assert listing["jobs"][0]["skill"] == "blogwatcher" assert listing["jobs"][0]["skill"] == "blogwatcher"
def test_create_multi_skill_job(self):
result = json.loads(
cronjob(
action="create",
skills=["blogwatcher", "find-nearby"],
prompt="Use both skills and combine the result.",
schedule="every 1h",
name="Combo job",
)
)
assert result["success"] is True
assert result["skills"] == ["blogwatcher", "find-nearby"]
listing = json.loads(cronjob(action="list"))
assert listing["jobs"][0]["skills"] == ["blogwatcher", "find-nearby"]
def test_update_can_clear_skills(self):
created = json.loads(
cronjob(
action="create",
skills=["blogwatcher", "find-nearby"],
prompt="Use both skills and combine the result.",
schedule="every 1h",
)
)
updated = json.loads(
cronjob(action="update", job_id=created["job_id"], skills=[])
)
assert updated["success"] is True
assert updated["job"]["skills"] == []
assert updated["job"]["skill"] is None

View file

@ -10,7 +10,7 @@ import os
import re import re
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
# Import from cron module (will be available when properly installed) # Import from cron module (will be available when properly installed)
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
@ -85,12 +85,31 @@ def _repeat_display(job: Dict[str, Any]) -> str:
return f"{completed}/{times}" if completed else f"{times} times" return f"{completed}/{times}" if completed else f"{times} times"
def _canonical_skills(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]:
if skills is None:
raw_items = [skill] if skill else []
elif isinstance(skills, str):
raw_items = [skills]
else:
raw_items = list(skills)
normalized: List[str] = []
for item in raw_items:
text = str(item or "").strip()
if text and text not in normalized:
normalized.append(text)
return normalized
def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
prompt = job.get("prompt", "") prompt = job.get("prompt", "")
skills = _canonical_skills(job.get("skill"), job.get("skills"))
return { return {
"job_id": job["id"], "job_id": job["id"],
"name": job["name"], "name": job["name"],
"skill": job.get("skill"), "skill": skills[0] if skills else None,
"skills": skills,
"prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt, "prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt,
"schedule": job.get("schedule_display"), "schedule": job.get("schedule_display"),
"repeat": _repeat_display(job), "repeat": _repeat_display(job),
@ -115,6 +134,7 @@ def cronjob(
deliver: Optional[str] = None, deliver: Optional[str] = None,
include_disabled: bool = False, include_disabled: bool = False,
skill: Optional[str] = None, skill: Optional[str] = None,
skills: Optional[List[str]] = None,
reason: Optional[str] = None, reason: Optional[str] = None,
task_id: str = None, task_id: str = None,
) -> str: ) -> str:
@ -127,8 +147,9 @@ def cronjob(
if normalized == "create": if normalized == "create":
if not schedule: if not schedule:
return json.dumps({"success": False, "error": "schedule is required for create"}, indent=2) return json.dumps({"success": False, "error": "schedule is required for create"}, indent=2)
if not prompt and not skill: canonical_skills = _canonical_skills(skill, skills)
return json.dumps({"success": False, "error": "create requires either prompt or skill"}, indent=2) if not prompt and not canonical_skills:
return json.dumps({"success": False, "error": "create requires either prompt or at least one skill"}, indent=2)
if prompt: if prompt:
scan_error = _scan_cron_prompt(prompt) scan_error = _scan_cron_prompt(prompt)
if scan_error: if scan_error:
@ -141,7 +162,7 @@ def cronjob(
repeat=repeat, repeat=repeat,
deliver=deliver, deliver=deliver,
origin=_origin_from_env(), origin=_origin_from_env(),
skill=skill, skills=canonical_skills,
) )
return json.dumps( return json.dumps(
{ {
@ -149,6 +170,7 @@ def cronjob(
"job_id": job["id"], "job_id": job["id"],
"name": job["name"], "name": job["name"],
"skill": job.get("skill"), "skill": job.get("skill"),
"skills": job.get("skills", []),
"schedule": job["schedule_display"], "schedule": job["schedule_display"],
"repeat": _repeat_display(job), "repeat": _repeat_display(job),
"deliver": job.get("deliver", "local"), "deliver": job.get("deliver", "local"),
@ -213,8 +235,10 @@ def cronjob(
updates["name"] = name updates["name"] = name
if deliver is not None: if deliver is not None:
updates["deliver"] = deliver updates["deliver"] = deliver
if skill is not None: if skills is not None or skill is not None:
updates["skill"] = skill canonical_skills = _canonical_skills(skill, skills)
updates["skills"] = canonical_skills
updates["skill"] = canonical_skills[0] if canonical_skills else None
if repeat is not None: if repeat is not None:
repeat_state = dict(job.get("repeat") or {}) repeat_state = dict(job.get("repeat") or {})
repeat_state["times"] = repeat repeat_state["times"] = repeat
@ -272,12 +296,13 @@ CRONJOB_SCHEMA = {
"name": "cronjob", "name": "cronjob",
"description": """Manage scheduled cron jobs with a single compressed tool. "description": """Manage scheduled cron jobs with a single compressed tool.
Use action='create' to schedule a new job from a prompt or a skill. Use action='create' to schedule a new job from a prompt or one or more skills.
Use action='list' to inspect jobs. Use action='list' to inspect jobs.
Use action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job. Use action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job.
Jobs run in a fresh session with no current-chat context, so prompts must be self-contained. Jobs run in a fresh session with no current-chat context, so prompts must be self-contained.
If skill is provided on create, the future cron run loads that skill first, then follows the prompt as the task instruction. If skill or skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction.
On update, passing skills=[] clears attached skills.
Important safety rule: cron-run sessions should not recursively schedule more cron jobs.""", Important safety rule: cron-run sessions should not recursively schedule more cron jobs.""",
"parameters": { "parameters": {
@ -293,7 +318,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
}, },
"prompt": { "prompt": {
"type": "string", "type": "string",
"description": "For create: the full self-contained prompt. If skill is also provided, this becomes the task instruction paired with that skill." "description": "For create: the full self-contained prompt. If skill or skills are also provided, this becomes the task instruction paired with those skills."
}, },
"schedule": { "schedule": {
"type": "string", "type": "string",
@ -317,7 +342,12 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
}, },
"skill": { "skill": {
"type": "string", "type": "string",
"description": "Optional skill name to load before executing the cron prompt" "description": "Optional single skill name to load before executing the cron prompt"
},
"skills": {
"type": "array",
"items": {"type": "string"},
"description": "Optional ordered list of skills to load before executing the cron prompt. On update, pass an empty array to clear attached skills."
}, },
"reason": { "reason": {
"type": "string", "type": "string",
@ -365,6 +395,7 @@ registry.register(
deliver=args.get("deliver"), deliver=args.get("deliver"),
include_disabled=args.get("include_disabled", False), include_disabled=args.get("include_disabled", False),
skill=args.get("skill"), skill=args.get("skill"),
skills=args.get("skills"),
reason=args.get("reason"), reason=args.get("reason"),
task_id=kw.get("task_id"), task_id=kw.get("task_id"),
), ),

View file

@ -1,7 +1,7 @@
--- ---
sidebar_position: 11 sidebar_position: 11
title: "Cron Internals" title: "Cron Internals"
description: "How Hermes stores, schedules, locks, and delivers cron jobs" description: "How Hermes stores, schedules, edits, pauses, skill-loads, and delivers cron jobs"
--- ---
# Cron Internals # Cron Internals
@ -10,7 +10,9 @@ Hermes cron support is implemented primarily in:
- `cron/jobs.py` - `cron/jobs.py`
- `cron/scheduler.py` - `cron/scheduler.py`
- `tools/cronjob_tools.py`
- `gateway/run.py` - `gateway/run.py`
- `hermes_cli/cron.py`
## Scheduling model ## Scheduling model
@ -21,9 +23,30 @@ Hermes supports:
- cron expressions - cron expressions
- explicit timestamps - explicit timestamps
The model-facing surface is a single `cronjob` tool with action-style operations:
- `create`
- `list`
- `update`
- `pause`
- `resume`
- `run`
- `remove`
## Job storage ## Job storage
Cron jobs are stored in Hermes-managed local state with atomic save/update semantics. Cron jobs are stored in Hermes-managed local state (`~/.hermes/cron/jobs.json`) with atomic write semantics.
Each job can carry:
- prompt
- schedule metadata
- repeat counters
- delivery target
- lifecycle state (`scheduled`, `paused`, `completed`, etc.)
- zero, one, or multiple attached skills
Backward compatibility is preserved for older jobs that only stored a legacy single `skill` field or none of the newer lifecycle fields.
## Runtime behavior ## Runtime behavior
@ -32,11 +55,22 @@ The scheduler:
- loads jobs - loads jobs
- computes due work - computes due work
- executes jobs in fresh agent sessions - executes jobs in fresh agent sessions
- optionally injects one or more skills before the prompt
- handles repeat counters - handles repeat counters
- updates next-run metadata - updates next-run metadata and state
In gateway mode, cron ticking is integrated into the long-running gateway loop. In gateway mode, cron ticking is integrated into the long-running gateway loop.
## Skill-backed jobs
A cron job may attach multiple skills. At runtime, Hermes loads those skills in order and then appends the job prompt as the task instruction.
This gives scheduled jobs reusable guidance without requiring the user to paste full skill bodies into the cron prompt.
## Recursion guard
Cron-run sessions disable the `cronjob` toolset. This prevents a scheduled job from recursively creating or mutating more cron jobs and accidentally exploding token usage or scheduler load.
## Delivery model ## Delivery model
Cron jobs can deliver to: Cron jobs can deliver to:
@ -48,7 +82,7 @@ Cron jobs can deliver to:
## Locking ## Locking
Hermes uses lock-based protections so concurrent cron ticks or overlapping scheduler processes do not corrupt job state. Hermes uses lock-based protections so overlapping scheduler ticks do not execute the same due-job batch twice.
## Related docs ## Related docs

View file

@ -99,7 +99,7 @@ and open source LLMs. Summarize the top 3 stories in a concise briefing
with links. Use a friendly, professional tone. Deliver to telegram. with links. Use a friendly, professional tone. Deliver to telegram.
``` ```
Hermes will create the cron job for you using the `schedule_cronjob` tool. Hermes will create the cron job for you using the unified `cronjob` tool.
### Option B: CLI Slash Command ### Option B: CLI Slash Command
@ -232,7 +232,7 @@ Or ask conversationally:
Remove my morning briefing cron job. Remove my morning briefing cron job.
``` ```
Hermes will use `list_cronjobs` to find it and `remove_cronjob` to delete it. Hermes will use `cronjob(action="list")` to find it and `cronjob(action="remove")` to delete it.
### Check Gateway Status ### Check Gateway Status

View file

@ -181,12 +181,18 @@ hermes status [--all] [--deep]
## `hermes cron` ## `hermes cron`
```bash ```bash
hermes cron <list|status|tick> hermes cron <list|create|edit|pause|resume|run|remove|status|tick>
``` ```
| Subcommand | Description | | Subcommand | Description |
|------------|-------------| |------------|-------------|
| `list` | Show scheduled jobs. | | `list` | Show scheduled jobs. |
| `create` / `add` | Create a scheduled job from a prompt, optionally attaching one or more skills via repeated `--skill`. |
| `edit` | Update a job's schedule, prompt, name, delivery, repeat count, or attached skills. Supports `--clear-skills`, `--add-skill`, and `--remove-skill`. |
| `pause` | Pause a job without deleting it. |
| `resume` | Resume a paused job and compute its next future run. |
| `run` | Trigger a job on the next scheduler tick. |
| `remove` | Delete a scheduled job. |
| `status` | Check whether the cron scheduler is running. | | `status` | Check whether the cron scheduler is running. |
| `tick` | Run due jobs once and exit. | | `tick` | Run due jobs once and exit. |

View file

@ -40,9 +40,7 @@ This page documents the built-in Hermes tool registry as it exists in code. Avai
| Tool | Description | Requires environment | | Tool | Description | Requires environment |
|------|-------------|----------------------| |------|-------------|----------------------|
| `list_cronjobs` | List all scheduled cronjobs with their IDs, schedules, and status. Use this to: - See what jobs are currently scheduled - Find job IDs for removal with remove_cronjob - Check job status and next run times Returns job_id, name, schedule, re… | — | | `cronjob` | Unified scheduled-task manager. Use `action="create"`, `"list"`, `"update"`, `"pause"`, `"resume"`, `"run"`, or `"remove"` to manage jobs. Supports skill-backed jobs with one or more attached skills, and `skills=[]` on update clears attached skills. Cron runs happen in fresh sessions with no current-chat context. | — |
| `remove_cronjob` | Remove a scheduled cronjob by its ID. Use list_cronjobs first to find the job_id of the job you want to remove. Jobs that have completed their repeat count are auto-removed, but you can use this to cancel a job before it completes. | — |
| `schedule_cronjob` | Schedule an automated task to run the agent on a schedule. ⚠️ CRITICAL: The cronjob runs in a FRESH SESSION with NO CONTEXT from this conversation. The prompt must be COMPLETELY SELF-CONTAINED with ALL necessary information including: - Fu… | — |
## `delegation` toolset ## `delegation` toolset

View file

@ -13,19 +13,19 @@ Toolsets are named bundles of tools that you can enable with `hermes chat --tool
| `browser` | core | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` | | `browser` | core | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` |
| `clarify` | core | `clarify` | | `clarify` | core | `clarify` |
| `code_execution` | core | `execute_code` | | `code_execution` | core | `execute_code` |
| `cronjob` | core | `list_cronjobs`, `remove_cronjob`, `schedule_cronjob` | | `cronjob` | core | `cronjob` |
| `debugging` | composite | `patch`, `process`, `read_file`, `search_files`, `terminal`, `web_extract`, `web_search`, `write_file` | | `debugging` | composite | `patch`, `process`, `read_file`, `search_files`, `terminal`, `web_extract`, `web_search`, `write_file` |
| `delegation` | core | `delegate_task` | | `delegation` | core | `delegate_task` |
| `file` | core | `patch`, `read_file`, `search_files`, `write_file` | | `file` | core | `patch`, `read_file`, `search_files`, `write_file` |
| `hermes-cli` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | | `hermes-cli` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-discord` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | | `hermes-discord` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-email` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | | `hermes-email` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-gateway` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | | `hermes-gateway` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-homeassistant` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | | `hermes-homeassistant` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-signal` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | | `hermes-signal` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-slack` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | | `hermes-slack` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-telegram` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | | `hermes-telegram` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-whatsapp` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | | `hermes-whatsapp` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `homeassistant` | core | `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services` | | `homeassistant` | core | `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services` |
| `honcho` | core | `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search` | | `honcho` | core | `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search` |
| `image_gen` | core | `image_generate` | | `image_gen` | core | `image_generate` |

View file

@ -1,68 +1,183 @@
--- ---
sidebar_position: 5 sidebar_position: 5
title: "Scheduled Tasks (Cron)" title: "Scheduled Tasks (Cron)"
description: "Schedule automated tasks with natural language — cron jobs, delivery options, and the gateway scheduler" description: "Schedule automated tasks with natural language, manage them with one cron tool, and attach one or more skills"
--- ---
# Scheduled Tasks (Cron) # Scheduled Tasks (Cron)
Schedule tasks to run automatically with natural language or cron expressions. The agent can self-schedule using the `schedule_cronjob` tool from any platform. Schedule tasks to run automatically with natural language or cron expressions. Hermes exposes cron management through a single `cronjob` tool with action-style operations instead of separate schedule/list/remove tools.
## Creating Scheduled Tasks ## What cron can do now
### In the CLI Cron jobs can:
Use the `/cron` slash command: - schedule one-shot or recurring tasks
- pause, resume, edit, trigger, and remove jobs
- attach zero, one, or multiple skills to a job
- deliver results back to the origin chat, local files, or configured platform targets
- run in fresh agent sessions with the normal static tool list
``` :::warning
Cron-run sessions cannot recursively create more cron jobs. Hermes disables cron management tools inside cron executions to prevent runaway scheduling loops.
:::
## Creating scheduled tasks
### In chat with `/cron`
```bash
/cron add 30m "Remind me to check the build" /cron add 30m "Remind me to check the build"
/cron add "every 2h" "Check server status" /cron add "every 2h" "Check server status"
/cron add "0 9 * * *" "Morning briefing" /cron add "every 1h" "Summarize new feed items" --skill blogwatcher
/cron list /cron add "every 1h" "Use both skills and combine the result" --skill blogwatcher --skill find-nearby
/cron remove <job_id>
``` ```
### Through Natural Conversation ### From the standalone CLI
Simply ask the agent on any platform:
```bash
hermes cron create "every 2h" "Check server status"
hermes cron create "every 1h" "Summarize new feed items" --skill blogwatcher
hermes cron create "every 1h" "Use both skills and combine the result" \
--skill blogwatcher \
--skill find-nearby \
--name "Skill combo"
``` ```
### Through natural conversation
Ask Hermes normally:
```text
Every morning at 9am, check Hacker News for AI news and send me a summary on Telegram. Every morning at 9am, check Hacker News for AI news and send me a summary on Telegram.
``` ```
The agent will use the `schedule_cronjob` tool to set it up. Hermes will use the unified `cronjob` tool internally.
## How It Works ## Skill-backed cron jobs
**Cron execution is handled by the gateway daemon.** The gateway ticks the scheduler every 60 seconds, running any due jobs in isolated agent sessions: A cron job can load one or more skills before it runs the prompt.
### Single skill
```python
cronjob(
action="create",
skill="blogwatcher",
prompt="Check the configured feeds and summarize anything new.",
schedule="0 9 * * *",
name="Morning feeds",
)
```
### Multiple skills
Skills are loaded in order. The prompt becomes the task instruction layered on top of those skills.
```python
cronjob(
action="create",
skills=["blogwatcher", "find-nearby"],
prompt="Look for new local events and interesting nearby places, then combine them into one short brief.",
schedule="every 6h",
name="Local brief",
)
```
This is useful when you want a scheduled agent to inherit reusable workflows without stuffing the full skill text into the cron prompt itself.
## Editing jobs
You do not need to delete and recreate jobs just to change them.
### Chat
```bash
/cron edit <job_id> --schedule "every 4h"
/cron edit <job_id> --prompt "Use the revised task"
/cron edit <job_id> --skill blogwatcher --skill find-nearby
/cron edit <job_id> --remove-skill blogwatcher
/cron edit <job_id> --clear-skills
```
### Standalone CLI
```bash
hermes cron edit <job_id> --schedule "every 4h"
hermes cron edit <job_id> --prompt "Use the revised task"
hermes cron edit <job_id> --skill blogwatcher --skill find-nearby
hermes cron edit <job_id> --add-skill find-nearby
hermes cron edit <job_id> --remove-skill blogwatcher
hermes cron edit <job_id> --clear-skills
```
Notes:
- repeated `--skill` replaces the job's attached skill list
- `--add-skill` appends to the existing list without replacing it
- `--remove-skill` removes specific attached skills
- `--clear-skills` removes all attached skills
## Lifecycle actions
Cron jobs now have a fuller lifecycle than just create/remove.
### Chat
```bash
/cron list
/cron pause <job_id>
/cron resume <job_id>
/cron run <job_id>
/cron remove <job_id>
```
### Standalone CLI
```bash
hermes cron list
hermes cron pause <job_id>
hermes cron resume <job_id>
hermes cron run <job_id>
hermes cron remove <job_id>
hermes cron status
hermes cron tick
```
What they do:
- `pause` — keep the job but stop scheduling it
- `resume` — re-enable the job and compute the next future run
- `run` — trigger the job on the next scheduler tick
- `remove` — delete it entirely
## How it works
**Cron execution is handled by the gateway daemon.** The gateway ticks the scheduler every 60 seconds, running any due jobs in isolated agent sessions.
```bash ```bash
hermes gateway install # Install as system service (recommended) hermes gateway install # Install as system service (recommended)
hermes gateway # Or run in foreground hermes gateway # Or run in foreground
hermes cron list # View scheduled jobs hermes cron list
hermes cron status # Check if gateway is running hermes cron status
``` ```
### The Gateway Scheduler ### Gateway scheduler behavior
The scheduler runs as a background thread inside the gateway process. On each tick (every 60 seconds): On each tick Hermes:
1. It loads all jobs from `~/.hermes/cron/jobs.json` 1. loads jobs from `~/.hermes/cron/jobs.json`
2. Checks each enabled job's `next_run_at` against the current time 2. checks `next_run_at` against the current time
3. For each due job, spawns a fresh `AIAgent` session with the job's prompt 3. starts a fresh `AIAgent` session for each due job
4. The agent runs to completion with full tool access 4. optionally injects one or more attached skills into that fresh session
5. The final response is delivered to the configured target 5. runs the prompt to completion
6. The job's run count is incremented and next run time computed 6. delivers the final response
7. Jobs that hit their repeat limit are auto-removed 7. updates run metadata and the next scheduled time
A **file-based lock** (`~/.hermes/cron/.tick.lock`) prevents duplicate execution if multiple processes overlap (e.g., gateway + manual tick). A file lock at `~/.hermes/cron/.tick.lock` prevents overlapping scheduler ticks from double-running the same job batch.
:::info ## Delivery options
Even if no messaging platforms are configured, the gateway stays running for cron. A file lock prevents duplicate execution if multiple processes overlap.
:::
## Delivery Options
When scheduling jobs, you specify where the output goes: When scheduling jobs, you specify where the output goes:
@ -70,48 +185,34 @@ When scheduling jobs, you specify where the output goes:
|--------|-------------|---------| |--------|-------------|---------|
| `"origin"` | Back to where the job was created | Default on messaging platforms | | `"origin"` | Back to where the job was created | Default on messaging platforms |
| `"local"` | Save to local files only (`~/.hermes/cron/output/`) | Default on CLI | | `"local"` | Save to local files only (`~/.hermes/cron/output/`) | Default on CLI |
| `"telegram"` | Telegram home channel | Uses `TELEGRAM_HOME_CHANNEL` env var | | `"telegram"` | Telegram home channel | Uses `TELEGRAM_HOME_CHANNEL` |
| `"discord"` | Discord home channel | Uses `DISCORD_HOME_CHANNEL` env var | | `"discord"` | Discord home channel | Uses `DISCORD_HOME_CHANNEL` |
| `"telegram:123456"` | Specific Telegram chat by ID | For directing output to a specific chat | | `"telegram:123456"` | Specific Telegram chat by ID | Direct delivery |
| `"discord:987654"` | Specific Discord channel by ID | For directing output to a specific channel | | `"discord:987654"` | Specific Discord channel by ID | Direct delivery |
**How `"origin"` works:** When a job is created from a messaging platform, Hermes records the source platform and chat ID. When the job runs and deliver is `"origin"`, the output is sent back to that exact platform and chat. If origin info isn't available (e.g., job created from CLI), delivery falls back to local. The agent's final response is automatically delivered. You do not need to call `send_message` in the cron prompt.
**How platform names work:** When you specify a bare platform name like `"telegram"`, Hermes first checks if the job's origin matches that platform and uses the origin chat ID. Otherwise, it falls back to the platform's home channel configured via environment variable (e.g., `TELEGRAM_HOME_CHANNEL`). ## Schedule formats
The agent's final response is automatically delivered — you do **not** need to include `send_message` in the cron prompt. ### Relative delays (one-shot)
The agent knows your connected platforms and home channels — it'll choose sensible defaults. ```text
## Schedule Formats
### Relative Delays (One-Shot)
Run once after a delay:
```
30m → Run once in 30 minutes 30m → Run once in 30 minutes
2h → Run once in 2 hours 2h → Run once in 2 hours
1d → Run once in 1 day 1d → Run once in 1 day
``` ```
Supported units: `m`/`min`/`minutes`, `h`/`hr`/`hours`, `d`/`day`/`days`. ### Intervals (recurring)
### Intervals (Recurring) ```text
Run repeatedly at fixed intervals:
```
every 30m → Every 30 minutes every 30m → Every 30 minutes
every 2h → Every 2 hours every 2h → Every 2 hours
every 1d → Every day every 1d → Every day
``` ```
### Cron Expressions ### Cron expressions
Standard 5-field cron syntax for precise scheduling: ```text
```
0 9 * * * → Daily at 9:00 AM 0 9 * * * → Daily at 9:00 AM
0 9 * * 1-5 → Weekdays at 9:00 AM 0 9 * * 1-5 → Weekdays at 9:00 AM
0 */6 * * * → Every 6 hours 0 */6 * * * → Every 6 hours
@ -119,155 +220,63 @@ Standard 5-field cron syntax for precise scheduling:
0 0 * * 0 → Every Sunday at midnight 0 0 * * 0 → Every Sunday at midnight
``` ```
#### Cron Expression Cheat Sheet ### ISO timestamps
``` ```text
┌───── minute (0-59)
│ ┌───── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌───── month (1-12)
│ │ │ │ ┌───── day of week (0-7, 0 and 7 = Sunday)
│ │ │ │ │
* * * * *
Special characters:
* Any value
, List separator (1,3,5)
- Range (1-5)
/ Step values (*/15 = every 15)
```
:::note
Cron expressions require the `croniter` Python package. Install with `pip install croniter` if not already available.
:::
### ISO Timestamps
Run once at a specific date/time:
```
2026-03-15T09:00:00 → One-time at March 15, 2026 9:00 AM 2026-03-15T09:00:00 → One-time at March 15, 2026 9:00 AM
``` ```
## Repeat Behavior ## Repeat behavior
The `repeat` parameter controls how many times a job runs: | Schedule type | Default repeat | Behavior |
| Schedule Type | Default Repeat | Behavior |
|--------------|----------------|----------| |--------------|----------------|----------|
| One-shot (`30m`, timestamp) | 1 (run once) | Runs once, then auto-deleted | | One-shot (`30m`, timestamp) | 1 | Runs once |
| Interval (`every 2h`) | Forever (`null`) | Runs indefinitely until removed | | Interval (`every 2h`) | forever | Runs until removed |
| Cron expression | Forever (`null`) | Runs indefinitely until removed | | Cron expression | forever | Runs until removed |
You can override the default: You can override it:
```python ```python
schedule_cronjob( cronjob(
action="create",
prompt="...", prompt="...",
schedule="every 2h", schedule="every 2h",
repeat=5 # Run exactly 5 times, then auto-delete repeat=5,
) )
``` ```
When a job hits its repeat limit, it is automatically removed from the job list. ## Managing jobs programmatically
## Real-World Examples The agent-facing API is one tool:
### Daily Standup Report
```
Schedule a daily standup report: Every weekday at 9am, check the GitHub
repository at github.com/myorg/myproject for:
1. Pull requests opened/merged in the last 24 hours
2. Issues created or closed
3. Any CI/CD failures on the main branch
Format as a brief standup-style summary. Deliver to telegram.
```
The agent creates:
```python
schedule_cronjob(
prompt="Check github.com/myorg/myproject for PRs, issues, and CI status from the last 24 hours. Format as a standup report.",
schedule="0 9 * * 1-5",
name="Daily Standup Report",
deliver="telegram"
)
```
### Weekly Backup Verification
```
Every Sunday at 2am, verify that backups exist in /data/backups/ for
each day of the past week. Check file sizes are > 1MB. Report any
gaps or suspiciously small files.
```
### Monitoring Alerts
```
Every 15 minutes, curl https://api.myservice.com/health and verify
it returns HTTP 200 with {"status": "ok"}. If it fails, include the
error details and response code. Deliver to telegram:123456789.
```
```python ```python
schedule_cronjob( cronjob(action="create", ...)
prompt="Run 'curl -s -o /dev/null -w \"%{http_code}\" https://api.myservice.com/health' and verify it returns 200. Also fetch the full response with 'curl -s https://api.myservice.com/health' and check for {\"status\": \"ok\"}. Report the result.", cronjob(action="list")
schedule="every 15m", cronjob(action="update", job_id="...")
name="API Health Check", cronjob(action="pause", job_id="...")
deliver="telegram:123456789" cronjob(action="resume", job_id="...")
) cronjob(action="run", job_id="...")
cronjob(action="remove", job_id="...")
``` ```
### Periodic Disk Usage Check For `update`, pass `skills=[]` to remove all attached skills.
```python ## Job storage
schedule_cronjob(
prompt="Check disk usage with 'df -h' and report any partitions above 80% usage. Also check Docker disk usage with 'docker system df' if Docker is installed.",
schedule="0 8 * * *",
name="Disk Usage Report",
deliver="origin"
)
```
## Managing Jobs Jobs are stored in `~/.hermes/cron/jobs.json`. Output from job runs is saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md`.
```bash The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind.
# CLI commands
hermes cron list # View all scheduled jobs
hermes cron status # Check if the scheduler is running
# Slash commands (inside chat) ## Self-contained prompts still matter
/cron list
/cron remove <job_id>
```
The agent can also manage jobs conversationally:
- `list_cronjobs` — Shows all jobs with IDs, schedules, repeat status, and next run times
- `remove_cronjob` — Removes a job by ID (use `list_cronjobs` to find the ID)
## Job Storage
Jobs are stored as JSON in `~/.hermes/cron/jobs.json`. Output from job runs is saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md`.
The storage uses atomic file writes (temp file + rename) to prevent corruption from concurrent access.
## Self-Contained Prompts
:::warning Important :::warning Important
Cron job prompts run in a **completely fresh agent session** with zero memory of any prior conversation. The prompt must contain **everything** the agent needs: Cron jobs run in a completely fresh agent session. The prompt must contain everything the agent needs that is not already provided by attached skills.
:::
- Full context and background
- Specific file paths, URLs, server addresses
- Clear instructions and success criteria
- Any credentials or configuration details
**BAD:** `"Check on that server issue"` **BAD:** `"Check on that server issue"`
**GOOD:** `"SSH into server 192.168.1.100 as user 'deploy', check if nginx is running with 'systemctl status nginx', and verify https://example.com returns HTTP 200."` **GOOD:** `"SSH into server 192.168.1.100 as user 'deploy', check if nginx is running with 'systemctl status nginx', and verify https://example.com returns HTTP 200."`
:::
## Security ## Security
:::warning Scheduled task prompts are scanned for prompt-injection and credential-exfiltration patterns at creation and update time. Prompts containing invisible Unicode tricks, SSH backdoor attempts, or obvious secret-exfiltration payloads are blocked.
Scheduled task prompts are scanned for instruction-override patterns (prompt injection). Jobs matching threat patterns like credential exfiltration, SSH backdoor attempts, or prompt injection are blocked at creation time. Content with invisible Unicode characters (zero-width spaces, directional overrides) is also rejected.
:::

View file

@ -22,7 +22,7 @@ High-level categories:
| **Media** | `vision_analyze`, `image_generate`, `text_to_speech` | Multimodal analysis and generation. | | **Media** | `vision_analyze`, `image_generate`, `text_to_speech` | Multimodal analysis and generation. |
| **Agent orchestration** | `todo`, `clarify`, `execute_code`, `delegate_task` | Planning, clarification, code execution, and subagent delegation. | | **Agent orchestration** | `todo`, `clarify`, `execute_code`, `delegate_task` | Planning, clarification, code execution, and subagent delegation. |
| **Memory & recall** | `memory`, `session_search`, `honcho_*` | Persistent memory, session search, and Honcho cross-session context. | | **Memory & recall** | `memory`, `session_search`, `honcho_*` | Persistent memory, session search, and Honcho cross-session context. |
| **Automation & delivery** | `schedule_cronjob`, `send_message` | Scheduled tasks and outbound messaging delivery. | | **Automation & delivery** | `cronjob`, `send_message` | Scheduled tasks with create/list/update/pause/resume/run/remove actions, plus outbound messaging delivery. |
| **Integrations** | `ha_*`, MCP server tools, `rl_*` | Home Assistant, MCP, RL training, and other integrations. | | **Integrations** | `ha_*`, MCP server tools, `rl_*` | Home Assistant, MCP, RL training, and other integrations. |
For the authoritative code-derived registry, see [Built-in Tools Reference](/docs/reference/tools-reference) and [Toolsets Reference](/docs/reference/toolsets-reference). For the authoritative code-derived registry, see [Built-in Tools Reference](/docs/reference/tools-reference) and [Toolsets Reference](/docs/reference/toolsets-reference).