From 796babaaed22a5da07df9ff02d2af90fdcb8c4df Mon Sep 17 00:00:00 2001 From: oxngon <98992931+oxngon@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:39:31 +0200 Subject: [PATCH] feat(loop): add --start-now to fire the first wakeup immediately /loop [interval] currently schedules the first wakeup one full interval after the command runs (next_due_at = now + interval). When the user just told Hermes what to check, waiting the whole interval before any output feels like the command was ignored. Add an opt-in --start-now flag that keeps Claude Code parity as the default but lets the user run the first iteration immediately, then continue on the cadence: /loop 1h check the deploy status # first run in 1h (unchanged) /loop 1h --start-now check the deploy # first run now, then hourly - parse_loop_args(): parse and strip --start-now (leading or trailing) - LoopState: new persisted start_now field (default False, survives serialization round-trip and old rows missing the field) - LoopManager.set(): next_due_at = now when start_now, for both fixed interval and self-paced modes - dispatch_loop_command(): wire start_now through, update help text, and report "First wakeup fires now" in the confirmation - website/docs: document the flag in the /loop guide - tests: parse (trailing/leading/absent/self-paced/combo/prompt-word), tick lifecycle (due immediately vs after interval), serde round-trip, and dispatch-level confirmation --- hermes_cli/loops.py | 43 ++++++++--- tests/hermes_cli/test_loops.py | 94 +++++++++++++++++++++++ website/docs/user-guide/features/loops.md | 15 +++- 3 files changed, 142 insertions(+), 10 deletions(-) diff --git a/hermes_cli/loops.py b/hermes_cli/loops.py index 0ee3c31b1c..9823888c19 100644 --- a/hermes_cli/loops.py +++ b/hermes_cli/loops.py @@ -149,11 +149,14 @@ def parse_loop_args(text: str) -> Dict[str, Any]: /loop keep fixing the failing test until the suite passes /loop 2m poll CI --times 30 /loop 5m watch the queue --until queue depth reaches zero + /loop 1h --start-now check the deploy status Returns ``{"interval_seconds": int|None, "prompt": str, "times": int, - "until": str, "error": str|None}``. ``interval_seconds`` None means - self-paced. ``error`` is set for unusable input (empty prompt, - interval-only, bad --times). + "until": str, "start_now": bool, "error": str|None}``. + ``interval_seconds`` None means self-paced. ``start_now`` is True when + the user passed ``--start-now`` (first wakeup fires immediately). + ``error`` is set for unusable input (empty prompt, interval-only, + bad --times). """ raw = (text or "").strip() result: Dict[str, Any] = { @@ -161,6 +164,7 @@ def parse_loop_args(text: str) -> Dict[str, Any]: "prompt": "", "times": 0, "until": "", + "start_now": False, "error": None, } if not raw: @@ -170,9 +174,16 @@ def parse_loop_args(text: str) -> Dict[str, Any]: # Pull trailing flags first so an interval-looking token inside the # --until clause can't confuse the front parse. Flags may appear in # either order at the end of the line; --until consumes to end-of-line - # (or to a following --times). + # (or to a following --times). --start-now is a bare boolean flag. times = 0 until = "" + start_now = False + + # --start-now is a bare boolean flag; it may lead the args or trail. + m_start_now = re.search(r"(?:^|\s)--start-now\b", raw) + if m_start_now: + start_now = True + raw = (raw[: m_start_now.start()] + raw[m_start_now.end():]).strip() m_times = re.search(r"\s--times\s+(\S+)", raw) if m_times: @@ -211,6 +222,7 @@ def parse_loop_args(text: str) -> Dict[str, Any]: result["prompt"] = raw result["times"] = times result["until"] = until + result["start_now"] = start_now return result @@ -295,6 +307,7 @@ class LoopState: current_delay: float = 0.0 # live cadence (self-paced backoff) times: int = 0 # user cap (--times N); 0 = none until: str = "" # judged stop condition; "" = none + start_now: bool = False # --start-now: first wakeup fires immediately max_ticks: int = DEFAULT_MAX_TICKS # config backstop; 0 = unlimited ticks_fired: int = 0 created_at: float = 0.0 @@ -329,6 +342,7 @@ class LoopState: current_delay=float(data.get("current_delay", 0.0) or 0.0), times=int(data.get("times", 0) or 0), until=str(data.get("until", "") or ""), + start_now=bool(data.get("start_now", False)), max_ticks=int(data.get("max_ticks", DEFAULT_MAX_TICKS) or 0), ticks_fired=int(data.get("ticks_fired", 0) or 0), created_at=float(data.get("created_at", 0.0) or 0.0), @@ -597,9 +611,14 @@ class LoopManager: interval_seconds: Optional[int] = None, times: int = 0, until: str = "", + start_now: bool = False, route: Optional[Dict[str, str]] = None, ) -> LoopState: - """Start a new loop (replaces any existing one for the session).""" + """Start a new loop (replaces any existing one for the session). + + With ``start_now=True`` the first wakeup is due immediately (next + idle poll / gateway watcher scan) instead of one interval out. + """ prompt = (prompt or "").strip() if not prompt: raise ValueError("loop prompt is empty") @@ -612,7 +631,7 @@ class LoopManager: mode="interval", interval_seconds=float(interval), current_delay=float(interval), - next_due_at=now + interval, + next_due_at=now if start_now else now + interval, ) else: floor = self_paced_floor_seconds() @@ -621,10 +640,11 @@ class LoopManager: mode="self_paced", interval_seconds=0.0, current_delay=float(floor), - next_due_at=now + floor, + next_due_at=now if start_now else now + floor, ) state.times = max(0, int(times or 0)) state.until = (until or "").strip() + state.start_now = bool(start_now) state.max_ticks = max_ticks_default() state.created_at = now state.route = dict(route or {}) @@ -890,12 +910,13 @@ def dispatch_loop_command( if lower in {"help", "--help", "-h"}: return { "output": ( - "Usage: /loop [interval] [--times N] [--until ]\n" + "Usage: /loop [interval] [--times N] [--until ] [--start-now]\n" " /loop 5m check the deploy status — fixed cadence\n" " /loop every 10m /recap — loop a slash command\n" " /loop keep fixing tests until green — self-paced (backs off while output is unchanged)\n" " /loop 2m poll CI --times 30 — stop after 30 runs\n" " /loop 5m watch the queue --until queue is empty\n" + " /loop 1h --start-now check deploy — first wakeup fires immediately\n" "Controls: /loop status · /loop pause · /loop resume · /loop stop\n" "The loop also stops itself when the agent replies with " f"{LOOP_COMPLETE_MARKER}." @@ -916,6 +937,7 @@ def dispatch_loop_command( interval_seconds=parsed["interval_seconds"], times=parsed["times"], until=parsed["until"], + start_now=parsed["start_now"], route=route, ) except ValueError as exc: @@ -938,7 +960,10 @@ def dispatch_loop_command( lines.append(f"Stops when: {state.until}") if not state.times and state.max_ticks: lines.append(f"Backstop budget: {state.max_ticks} ticks (loops.max_ticks; 0 = unlimited).") - lines.append(f"First wakeup {state.remaining_label()}. Controls: /loop status · pause · resume · stop.") + if state.start_now and state.status == "active": + lines.append("First wakeup fires now (--start-now), then on the cadence above.") + else: + lines.append(f"First wakeup {state.remaining_label()}. Controls: /loop status · pause · resume · stop.") if replacing: lines.insert(1, "(replaced the previous loop for this session)") return {"output": "\n".join(lines), "created": True} diff --git a/tests/hermes_cli/test_loops.py b/tests/hermes_cli/test_loops.py index 99dc26d4ec..c428e6e7e4 100644 --- a/tests/hermes_cli/test_loops.py +++ b/tests/hermes_cli/test_loops.py @@ -133,6 +133,53 @@ class TestParseLoopArgs: p = parse_loop_args("2m poll --times zero") assert p["error"] is not None + def test_start_now_flag_trailing(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("1h --start-now check the deploy status") + assert p["start_now"] is True + assert p["interval_seconds"] == 3600 + assert p["prompt"] == "check the deploy status" + assert p["error"] is None + + def test_start_now_flag_leading(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("--start-now 1h check the deploy status") + assert p["start_now"] is True + assert p["interval_seconds"] == 3600 + assert p["prompt"] == "check the deploy status" + + def test_start_now_flag_absent_by_default(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("1h check the deploy status") + assert p["start_now"] is False + + def test_start_now_self_paced(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("keep refining --start-now the tests") + assert p["start_now"] is True + assert p["interval_seconds"] is None + assert p["prompt"] == "keep refining the tests" + + def test_start_now_with_times_and_until(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("2m poll CI --times 30 --start-now --until it is green") + assert p["start_now"] is True + assert p["times"] == 30 + assert p["until"] == "it is green" + assert p["prompt"] == "poll CI" + + def test_start_now_word_in_prompt_not_a_flag(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("1h read the file called start-now.md") + assert p["start_now"] is False + assert p["prompt"] == "read the file called start-now.md" + def test_interval_only_is_error(self): from hermes_cli.loops import parse_loop_args @@ -207,6 +254,7 @@ class TestLoopStateSerde: current_delay=300.0, times=5, until="ci is green", + start_now=True, ticks_fired=2, route={"platform": "telegram", "chat_id": "123"}, ) @@ -215,6 +263,7 @@ class TestLoopStateSerde: assert s2.interval_seconds == 300.0 assert s2.times == 5 assert s2.until == "ci is green" + assert s2.start_now is True assert s2.ticks_fired == 2 assert s2.route == {"platform": "telegram", "chat_id": "123"} @@ -224,6 +273,7 @@ class TestLoopStateSerde: s = LoopState.from_json('{"prompt": "p"}') assert s.prompt == "p" assert s.status == "active" + assert s.start_now is False assert s.route == {} @@ -305,6 +355,31 @@ class TestTickLifecycle: state.next_due_at = time.time() - 1 assert mgr.is_due() is True + def test_start_now_due_immediately(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t2a") + state = mgr.set("poll", interval_seconds=300, start_now=True) + assert state.start_now is True + assert mgr.is_due() is True + + def test_start_now_default_not_due(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t2b") + state = mgr.set("poll", interval_seconds=300) + assert state.start_now is False + assert mgr.is_due() is False + + def test_start_now_self_paced_due_immediately(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t2c") + state = mgr.set("keep refining", start_now=True) + assert state.mode == "self_paced" + assert state.start_now is True + assert mgr.is_due() is True + def test_fire_marks_awaiting_and_blocks_refire(self, hermes_home): from hermes_cli.loops import LoopManager @@ -579,6 +654,25 @@ class TestDispatchLoopCommand: assert result["created"] is True assert "Self-paced" in result["output"] + def test_create_start_now(self, hermes_home): + from hermes_cli.loops import LoopManager, dispatch_loop_command + + mgr = LoopManager(session_id="d2a") + result = dispatch_loop_command(mgr, "1h --start-now check the deploy") + assert result["created"] is True + assert "Loop set" in result["output"] + assert "fires now" in result["output"] + assert mgr.is_due() is True + + def test_create_without_start_now_waits_interval(self, hermes_home): + from hermes_cli.loops import LoopManager, dispatch_loop_command + + mgr = LoopManager(session_id="d2b") + result = dispatch_loop_command(mgr, "1h check the deploy") + assert result["created"] is True + assert "First wakeup" in result["output"] + assert mgr.is_due() is False + def test_status_empty(self, hermes_home): from hermes_cli.loops import LoopManager, dispatch_loop_command diff --git a/website/docs/user-guide/features/loops.md b/website/docs/user-guide/features/loops.md index 7b066b44ba..99e11aee9e 100644 --- a/website/docs/user-guide/features/loops.md +++ b/website/docs/user-guide/features/loops.md @@ -37,6 +37,19 @@ Loop a slash command just as easily: /loop 10m /recap ``` +### Run the first iteration immediately + +By default the first wakeup waits one full interval. Pass `--start-now` to run +the first iteration right away, then continue on the cadence: + +``` +/loop 1h --start-now check the deploy status and tell me if it's live yet +``` + +The first wakeup fires on the next idle poll (gateway: the next 15s watcher +scan); subsequent wakeups follow the interval as usual. Works in both cadence +modes and with `/proactive`. + ## The two cadence modes **Fixed interval — you set the clock.** Give an interval (`30s`, `5m`, `2h`, `1h30m`) and the loop fires on that schedule. Use it when the thing you're watching changes on its own timeline: @@ -76,7 +89,7 @@ Examples: | Command | What it does | |---|---| -| `/loop [interval] [--times N] [--until ]` | Start (or replace) the loop for this session. | +| `/loop [interval] [--times N] [--until ] [--start-now]` | Start (or replace) the loop for this session. `--start-now` fires the first wakeup immediately. | | `/loop` or `/loop status` | Show cadence, ticks fired, and time to the next wakeup. | | `/loop pause` | Stop firing without losing the loop. | | `/loop resume` | Pick it back up. |