feat(loop): add --start-now to fire the first wakeup immediately

/loop [interval] <prompt> 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
This commit is contained in:
oxngon
2026-08-29 16:39:31 +02:00
committed by Teknium
parent 4209d371aa
commit 796babaaed
3 changed files with 142 additions and 10 deletions
+34 -9
View File
@@ -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] <prompt> [--times N] [--until <condition>]\n"
"Usage: /loop [interval] <prompt> [--times N] [--until <condition>] [--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}
+94
View File
@@ -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
+14 -1
View File
@@ -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] <prompt> [--times N] [--until <cond>]` | Start (or replace) the loop for this session. |
| `/loop [interval] <prompt> [--times N] [--until <cond>] [--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. |