# pi-loop

A [pi](https://github.com/earendil-works/pi) extension that runs a prompt **repeatedly** — on a fixed timer, when a pi event fires, or at the agent's own pace. Modelled on Claude Code's `/loop`.

## Overview

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/kolt-mcb/pi-loop/blob/main/LICENSE)
[![pi-package](https://img.shields.io/badge/pi-package-orange.svg)](https://pi.dev/packages)
[![Version](https://img.shields.io/badge/version-%40v0.4.1-blue.svg)](https://github.com/kolt-mcb/pi-loop/releases/tag/v0.4.1)

Schedule a prompt to run repeatedly inside pi — on a fixed timer, when a pi event fires, or **self-paced**, where the model itself continues the loop each turn and ends it by stopping.

## What changed in 0.4.1

Three ways a self-paced loop could die or stall, all found by driving real sessions against a local 35B model:

- **Unrelated turns ended loops.** Omit-to-end was applied on *every* turn end, to every self-paced loop — so a loop waiting on a delayed wakeup, a second self-paced loop, or one sharing a session with a cron loop was silently deleted mid-wait. It now applies only to a loop whose iteration ran in the turn that just finished.
- **The wakeup spin.** A model that calls `schedule_loop_wakeup` without ending its turn calls it again — 297 times in one turn, measured, each with its own notification. The turn never ends, so the iteration never arms and every other loop starves. A repeat call in the same turn now answers with a plain instruction to end the turn.
- **Postponed forever.** Scheduling a loop that was already waiting re-armed its timer. Since the tool defaults to the last self-paced loop, a call from an unrelated turn pushed the iteration out indefinitely — live, a 60s loop fired once in four and a half minutes. Such calls are now refused.

The fire hint also told the model it "will not stop unless specificly indicated to", one sentence after telling it to omit the call to end the loop; that's gone. On the scenario that previously livelocked, the same model now runs a steady ~47s cadence alongside a cron loop, with one call per iteration.

## What changed in 0.4

Self-paced `/loop <prompt>` is now **model-driven**, faithfully matching Claude Code's `/loop`: the model does one iteration's work, then calls `schedule_loop_wakeup` at the end of its turn to run the next one — and ends the loop simply by **not** calling it (omit-to-end). There is no harness auto-continue.

One mechanism, three natural shapes:
- **Indefinite** — the model calls the wakeup every turn (e.g. *keep incrementing count.txt*).
- **Goal-bound** — it continues until the goal is met, then omits the call (e.g. *count to 10, then stop*).
- **Stochastic** — it continues an unpredictable number of times, stopping on a runtime condition (e.g. *roll a die until a 6*).

The path here is the point. Earlier 0.3.x versions removed the model's control entirely (harness auto-continue, indefinite-only) on the assumption a weaker local model couldn't drive the loop at all. That was too pessimistic — the earlier failures were mostly a *bad prompt*. With a clean, de-jargoned version of Claude Code's wakeup prompt, a local model drives all three shapes (validated by hand and by `npm run test:e2e`). The remaining caveat is honest: model-driven continuation still relies on the model calling the wakeup **and then ending its turn**. A weaker model can call it and keep going — measured at ~300 calls in a single turn on a local 35B, which never reaches turn-end, so the iteration never arms and other loops starve waiting for the agent to go idle. A repeat call in the same turn is now answered with a plain instruction to end the turn (and no second notification), which is what breaks that spin: the same run then took one call and armed normally. If you need a loop that cannot stall at all, the cron form (`/loop 15m …`) is harness-driven and immune.

### 0.2.x–0.3.x foundations

0.2.0 made a parsed interval **authoritative and timer-driven** (`/loop 15m …` → cron on a self-re-arming timer), and added **event**/**hybrid** triggers, **multiple concurrent loops**, **persistence** across resume, per-session jitter, and a climbing iteration display.

## Features

- **Fixed-interval loops** — `/loop 15m <prompt>` parses the interval into cron and runs it on a self-re-arming timer. Continuation is the default.
- **Model-driven self-paced loops** — `/loop <prompt>` (no interval): the model does each iteration, then calls `schedule_loop_wakeup` to run the next one, or omits it to end the loop. Naturally handles indefinite, goal-bound, and stochastic loops. You can always take over (`/loop stop`, or just type).
- **Event & hybrid triggers** — fire on a pi event (e.g. `tool_execution_end`, `turn_end`, `monitor:done`) instead of polling, or combine cron + event with debounce.
- **Multiple loops** — run several at once; manage with `LoopCreate` / `LoopList` / `LoopDelete` or `/loop list`.
- **Persistence** — loops are stored under `.pi/loops` and restored, if unexpired, on `--resume`/`--continue`.
- **Safety caps** — per-loop `maxFires` and an automatic 7-day expiry; jittered fire times avoid API stampedes.
- **Read-only mode** — restrict a loop's fires to read/inspection tools.
- **Live status** — a footer indicator and widget list active loops with next-fire countdowns. A self-paced loop leads with its climbing iteration count (`⟳ #2 … · next in 0s`); the loop id is shown in `/loop list`.

## Installation

```bash
pi install npm:@koltmcbride/pi-loop
# or
pi install git:github.com/kolt-mcb/pi-loop@v0.4.1
```

Verify it's loaded with `pi list`.

## Quick start

```
/loop 5m check if the deployment finished and report what happened
```
Fixed 5-minute loop. Runs until you stop it, 7 days pass, or it hits a fire cap.

```
/loop check whether CI passed and address review comments
```
Self-paced: the model works an iteration, then continues by calling `schedule_loop_wakeup` — and stops on its own when the task is done (or you `/loop stop` / type to take over).

```
/loop stop          # stop all active loops
/loop stop 3        # stop loop #3
/loop list          # show / manage active loops
```

## Usage

### `/loop` command

| Input | Behaviour |
|---|---|
| `/loop 15m <prompt>` | Fixed-interval (cron) loop. Interval may also trail: `<prompt> every 2 hours`. |
| `/loop 0 9 * * 1-5 <prompt>` | Full 5-field cron schedule. |
| `/loop <prompt>` | Self-paced loop — the model continues each turn via `schedule_loop_wakeup`, and ends it by omitting the call (or you `/loop stop`). |
| `/loop list` | List/manage active loops. |
| `/loop stop [id]` | Stop all loops, or one by id. |

Intervals use `s` / `m` / `h` / `d`. Sub-minute rounds up to one minute (cron's floor); odd intervals like `7m` snap to the nearest clean cron step and the loop tells you what it picked.

### Tools (for the agent)

| Tool | What it does |
|---|---|
| `LoopCreate` | Schedule a loop on a cron timer, a pi event, or a hybrid of both. Supports `recurring`, `readOnly`, `maxFires`, `filter`. |
| `LoopList` | List loops with ids, triggers, fire counts, next-fire times. |
| `LoopDelete` | Delete a loop, or `action="pause"` to keep it without firing. |
| `schedule_loop_wakeup` | Continue a self-paced `/loop`: call at the end of a turn to run the next iteration (optional `delaySeconds`; `0` = immediately). Omit it to end the loop. |

Trigger types: `cron` (`5m`, `1h`, `0 9 * * 1-5`), `event` (any pi event-bus channel; lifecycle events `tool_execution_start/end`, `turn_start/end`, `agent_start/end`, `message_end` are bridged through), or `hybrid` (both, debounced).

## Behaviour notes

- **Cron fires wait for idle.** A tick that lands while the agent is mid-turn marks the loop **due** (shown in the status widget) instead of queueing a stale prompt; the fire is delivered fresh the moment the agent goes idle. Ticks landing while already due collapse into that one fire — so when turns run longer than the interval, the effective cadence is one fire per turn, and `fireCount` only counts fires the agent actually received.
- **Event fires land between turns.** An event/hybrid fire is delivered as a follow-up to the turn that caused it; a recurring fire is skipped while a message is already queued, so ticks never stack.
- **Takeover.** Typing while a self-paced loop is running ends it (you took over). Cron/event loops keep running across your messages until you `/loop stop` them.
- **Only the loop that ran can end.** Omit-to-end applies to a self-paced loop whose iteration ran in the turn that just finished. A loop waiting on a delayed wakeup, or a second loop this turn never touched, keeps running — so a cron fire, another self-paced loop, or another extension driving a turn can't silently delete it. Continuing two self-paced loops in one turn means one `schedule_loop_wakeup` call each (the repeat-call guard is per loop id).
- **Ending a self-paced loop.** The model ends it by *not* calling `schedule_loop_wakeup` at the end of a turn (omit-to-end) — so it can stop itself when a goal is met or a condition is hit. You can always end it immediately with `/loop stop [id]` or by typing. The continuation is recorded during the turn and the next iteration is armed at turn-end, so a `delay:0` call can't fire mid-turn and double up.
- **No catch-up.** If fires were missed while busy, the loop fires once when idle, not once per missed interval.
- **Session binding.** Loops arm at session start (a `--resume`d loop fires without you having to type first), and re-bind when the session changes (`/new`, fork), so a new session never inherits the old session's timers. Note each session has its own store — a loop started in one terminal isn't visible to `/loop stop` in another.

## Configuration

| Variable | Effect | Default |
|---|---|---|
| `PI_LOOP` | `off` disables persistence (in-memory only); an absolute or relative path sets a custom store file | `.pi/loops/loops-<sessionId>.json` |

Constants at the top of `loop.ts` / `src/`: status tick interval, default hybrid debounce, and the bridged lifecycle event list. Caps: 25 active loops, 7-day expiry.

## Development

```bash
npm install
npm run typecheck   # tsc --noEmit
npm test            # node:test via tsx — covers parsing, cron, jitter
```

Source layout:

| File | Responsibility |
|---|---|
| `src/types.ts` | Loop/trigger types. |
| `src/loop-parse.ts` | `parseInterval`, `extractInterval`, cron math, jitter (pure, tested). |
| `src/store.ts` | Loop registry + JSON persistence. |
| `src/scheduler.ts` | Self-re-arming cron timers. |
| `src/triggers.ts` | Event/hybrid subscriptions + debounce. |
| `loop.ts` | Entry: command, tools, fire→message bridge, status widget, lifecycle. |

## License

[MIT](LICENSE)
