@hank-warren/pi-loop 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @hank-warren/pi-loop
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b642391: Removing goal-bound loops is breaking for anyone holding one; it lands as a 0.x minor per this repo's convention.
8
+
9
+ **Start a loop from an inline `/loop` invocation.** Pi dispatches `/loop` only when it is the first thing in the message, so `quick check /loop 10m get CI green` used to arrive as prose and vanish. A new `loop_start` tool, pointed at by a one-turn `<system-reminder>`, now starts the loop and begins working in the same turn. `loop: <objective>` at line start works the same way. The message itself is never rewritten, split, re-sent, or annotated: an `input` handler only records it, and `before_agent_start` appends the hint when the starting prompt is that same message.
10
+
11
+ `loop_start` **refuses unless the inline hint armed for that turn.** A loop is self-continuing, so a spurious start does not produce one unwanted answer, it produces turns until a cap — that gate is enforcement rather than prompt guidance. It also refuses when a loop is already active, when `loop_complete` is missing from the active tool set, and on an unparsable objective, interval, or expiry.
12
+
13
+ New settings: `inlineInvocation` (default `true`) and `defaultInterval` (default `"10m"`, used when an invocation names no interval).
14
+
15
+ **Goal-bound loops are gone,** along with every `goal-state` reader, the completion-race clear-scan, and the goal outcomes in the tick and continuation decisions. A loop owns its objective, ledger, post-compaction re-anchor, and evidence-gated completion, so delegating completion to a second extension bought nothing and cost a coupling. `plan-mode-state` is now the only sibling entry pi-loop reads.
16
+
17
+ A loop persisted before this release with no objective of its own adopts its focus text as one on restore, and pauses with an explanation when there is nothing to adopt.
18
+
19
+ `@hank-warren/pi-goal` is deprecated in favour of this package. Its published versions stay installable.
20
+
21
+ ## 0.5.0
22
+
23
+ ### Minor Changes
24
+
25
+ - 55eab7e: pi-loop v2: long-running work, not just interval wakeups.
26
+
27
+ - **Settle-paced.** A standalone loop now continues from the settled idle boundary instead of the clock: `/loop` fires its first working turn immediately, and the interval is demoted to a fallback heartbeat that only fires after a whole interval of genuine idleness. Consecutive wasted wakes back it off up to 4x. A new `automaticTurns` cap (default 25) bounds a loop that may now run many turns per wake; `maxIterations` keeps counting delivered wakes.
28
+ - **A durable ledger.** Each loop gets `~/.pi/agent/loop/<loop-id>/` with `criteria.json` (derived from the objective; the model may change only `passes`) and `PROGRESS.md` (four fixed sections, including failed approaches and why). A kickoff anchor stores the objective in the transcript so it outlives the loop. Compaction instructions no longer carry summaries forward cumulatively, and the loop now owns its post-compaction re-anchor: one pointer-sized continuation that re-reads the ledger and carries the next actions out of the summary.
29
+ - **`loop_wait`.** The model can declare an external wait with a reason and an optional deadline, clamped to [60s, 1h]. It holds both drivers without pausing the loop or cancelling the pacemaker, survives restarts, and its wake counts against the cap so a re-arming model cannot run forever.
30
+ - **Breakers.** Consecutive tool-free loop turns with identical output pause the loop (default 3, tunable) while keeping it configured. Interrupted turns are classified: usage limits and unrecoverable errors pause, `Esc` pauses rather than re-sending, a context overflow compacts and continues, and a transient error simply continues. Deliveries that never become a turn, and a session with no `loop_complete` tool, also pause instead of spinning.
31
+ - **Evidence-gated completion.** `loop_complete` requires a citation per criterion and refuses missing, unknown, or asserted-not-cited evidence. Expiry buys one final turn to write state into the ledger before stopping, and `--expires` sets a per-loop lifetime.
32
+ - **`LOOP_OK`.** Wakes ask for a one-token acknowledgement when nothing needs attention; it renders as a chip and feeds the backoff. The stored bytes of every loop message are now pinned by tests, because they are part of the provider's cached prefix.
33
+ - **`/schedule`.** Recurring prompts and headless `pi -p` runs, with once/interval/cron schedules, a single-writer lease so a task fires once rather than once per open session, coalesced catch-up, per-task run caps and a 90-day expiry, and a manager TUI. User-typed only: the model gets no scheduling tools.
34
+ - **Goal-bound loops are deprecated.** They still work and warn; a restored one migrates itself to standalone unless its goal is still active. `@hank-warren/pi-goal` carries a matching deprecation banner.
35
+
3
36
  ## 0.4.1
4
37
 
5
38
  ### Patch Changes
package/README.md CHANGED
@@ -1,69 +1,195 @@
1
- # pi-loop — interval wakeups for the Pi coding agent
1
+ # pi-loop — long-running work for the Pi coding agent
2
2
 
3
- Inspired by Claude Code's `/loop`, adapted to Pi: wake the session on an interval to keep work moving, and keep long loops coherent across context compaction.
3
+ Inspired by Claude Code's `/loop`, adapted to Pi: keep work moving across many turns, and keep long loops coherent across context compaction.
4
4
 
5
- A loop is a **pacemaker**: it owns *when* the session wakes. What it wakes the session *for* comes in two modes, chosen automatically when the loop starts:
5
+ A loop is a **pacemaker** that owns its own work: it carries an objective and completion criteria, and ends when the model calls `loop_complete` with cited evidence, a cap is reached, its expiry arrives, or you stop it. **No other extension is required.**
6
6
 
7
- - **Standalone** the loop carries its own objective and completion criteria. It ends when the model calls `loop_complete`, a cap is reached, or you stop it. No other extension required.
8
- - **Goal-bound** — an active [pi-goal](../pi-goal) goal is present, so the loop binds to it and pi-goal owns *whether the work is done*. Its safety states pause the loop and its completion stops it. Coupling stays read-only: fail-open reads of pi-goal's `goal-state` entries.
7
+ Earlier versions could instead bind a loop to a [pi-goal](https://www.npmjs.com/package/@hank-warren/pi-goal) goal and let that extension own *whether the work is done*. That mode is gone as of 0.6.0, along with every `goal-state` reader: a loop now does everything the pairing did — objective, durable ledger, post-compaction re-anchor, evidence-gated completion — so the coupling bought nothing. A loop persisted before 0.6.0 that carries no objective adopts its focus text as one on restore, and pauses with an explanation when there is nothing to adopt.
9
8
 
10
- **An active goal wins.** Start a loop while a goal is running and it binds to that goal, with any trailing text kept as a per-wake focus exactly as it behaved before standalone mode existed. With no active goal, the trailing text becomes the loop's own objective.
11
-
12
- pi-plan-mode's `plan-mode-state` is read the same fail-open way in both modes, so a loop never injects into a planning conversation.
9
+ pi-plan-mode's `plan-mode-state` is read fail-openthe only sibling state left so a loop never injects into a planning conversation.
13
10
 
14
11
  ## Usage
15
12
 
16
13
  ```
17
- /loop 30m until all tests pass and CI is green # standalone: the loop owns the objective
18
- /goal get CI green # ...or bind to a goal
19
- /loop 30m # poke that goal every 30m if the session stalls
20
- /loop 10m recheck the pipeline # trailing text is a per-wake focus when goal-bound
14
+ /loop 30m until all tests pass and CI is green # the trailing text is the objective
21
15
  /loop # manager TUI (status, pause/resume, edit, settings, stop)
22
16
  /loop status | pause | resume | stop | settings
23
- /loop --max 20 --compact-at 60% 10m # per-loop overrides
17
+ /loop --max 20 --compact-at 60% --expires 3d 10m # per-loop overrides
18
+ /loop 10m --max 20 <objective> # ...or after the interval; both work
24
19
  ```
25
20
 
26
21
  - **Intervals** are `<number><unit>` with unit `s`/`m`/`h`/`d`, parsed by the extension (never the model), minimum 1 minute (smaller values clamp, and the effective value is echoed).
27
- - `/loop` is deliberately **user-typed only** the model never starts or stops loops. (Inline `/goal` invocation is the [pi-goal fork](https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-goal)'s `goal_start` tool.)
22
+ - Loops are **user-driven only**: the model may start one *you* invoked inline (below), and never on its own initiative.
23
+
24
+ ### Inline invocation
25
+
26
+ Pi dispatches `/loop` only when it is the first thing in the message, so `quick check /loop 10m get CI green` arrives as ordinary prose and nothing happens. That is the common way to lose an invocation, so pi-loop detects it:
27
+
28
+ ```
29
+ quick check /loop 10m get CI green on main, verified by a passing run
30
+ loop: get CI green on main, verified by a passing run
31
+ ```
32
+
33
+ Both make the model call the `loop_start` tool with the objective and then begin working, in the same turn.
34
+
35
+ **Your message is never touched** — not cut, split, re-sent, or annotated. An `input` handler only *records* the text; `before_agent_start` appends a one-turn `<system-reminder>` when the starting prompt is that same message. The hint is a per-turn system-prompt append, never a stored message, so "call loop_start now" cannot linger and fire on a later turn.
36
+
37
+ Detection is deliberately narrow. A token at position 0 is ignored (that is Pi's own dispatch), as is one inside backticks or a quoted span — `` `/loop` `` and `"/loop 5m ship it"` are how people *discuss* the command — and a bare trailing `/loop` with no remainder. The `loop:` form needs to start a line, so "my loop: is unclear" is prose. Extension-sent prompts never arm, and neither does steered or queued input, whose delivery order would leave a stale arm behind.
38
+
39
+ **`loop_start` refuses unless the turn was armed.** This is enforcement, not guidance. A loop is *self-continuing*: a spurious start does not produce one unwanted answer, it produces turns until a cap. So the tool checks a flag the inline hooks set for exactly one turn, and refuses outright otherwise, along with a loop already being active, a missing `loop_complete`, or an unparsable interval or expiry.
40
+
41
+ An invocation that names no interval uses the `defaultInterval` setting (`10m`). In a settle-paced loop the interval is only a fallback heartbeat, so the value matters far less than it looks. Turn the whole feature off with `"inlineInvocation": false`.
42
+
43
+ ## What paces a loop
44
+
45
+ A **standalone** loop is paced by the session settling, not by the clock:
46
+
47
+ 1. `/loop <interval> <objective>` dispatches the **first working turn immediately** — the loop never burns its first interval sitting idle.
48
+ 2. Every `agent_end` with the loop still active records a continuation *intent*; the next fully settled idle boundary (`isIdle()` and no pending messages) dispatches it. Recording at `agent_end` and delivering at `agent_settled` is what lets the intent survive Pi's own retries and auto-compaction, which happen between the two.
49
+ 3. A delivery Pi refuses keeps the intent, so the next settle retries it.
50
+
51
+ The interval is therefore a **fallback heartbeat**, not the pacemaker: it is re-armed from the last settle and can only fire after a whole interval of genuine idleness — a lost continuation, or an external wait. When it does fire it delivers a poke, exactly as before. A continuation always supersedes a coalesced wake rather than delivering both.
52
+
53
+ Consecutive fallback wakes that produce a no-op turn **double the next fallback delay**, capped at 4× the base interval; any user turn, or any loop-caused turn that did real work, resets it. Waking an idle loop harder than it needs is the failure mode that costs tokens for nothing.
54
+
55
+ ### The `LOOP_OK` acknowledgement
56
+
57
+ A woken loop with nothing to do still costs a full turn, and the paragraph explaining that nothing needed doing is pure cost — nobody reads it, and the engine cannot distinguish it from work. So every wake ends with *"If nothing needs attention, reply `LOOP_OK` and stop."*
58
+
59
+ A reply that starts or ends with `LOOP_OK` and carries at most **300 characters** of remainder renders as a one-line chip (`✓ loop ok · queue still empty`) and counts as a wasted wake for the backoff — even when the model used a tool to check first, because looking and finding nothing is still nothing. The budget is fixed rather than configurable: an acknowledgement with a paragraph attached is just a turn, and a knob would let the protocol decay back into prose.
60
+
61
+ The chip is **display-only**. The stored message keeps its exact bytes, because rewriting them would break the prompt cache this whole design is built around.
62
+
63
+ ### Two counters
64
+
65
+ One wake now yields many turns, so a single counter cannot bound a loop:
66
+
67
+ - `maxIterations` (default 25, `--max`, settings) counts **delivered wakes** — fallback pokes only.
68
+ - `automaticTurns` (default 25, settings) counts **turns the loop caused** — continuations plus pokes. This is the cap that actually bounds a settle-paced loop, which can run its whole life without a single wake.
69
+
70
+ Either cap trips independently and stops the loop; `null` on either means unlimited.
28
71
 
29
72
  ## What a wakeup does
30
73
 
31
- Each tick evaluates, in order:
74
+ Each tick — fallback heartbeat or settled boundary — evaluates, in order:
32
75
 
33
- 1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days) — a forgotten loop is bounded.
76
+ 1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days, or per loop with `--expires 3d`, echoed at start) — a forgotten loop is bounded. The loop gets **one final turn** first: "write the current state into the ledger, start no new work, claim no completion", and the settle after it stops the loop. A loop that simply vanished at its deadline would leave its most recent state only in a conversation about to be closed. If that final wake cannot be delivered, the loop stops immediately rather than living past its deadline.
34
77
  2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
35
78
  3. **Agent busy?** Never interrupt: coalesce into a single pending wake delivered at the next fully-settled idle boundary. N missed ticks collapse into one poke.
36
- 4. **Mode-specific stop criteria.** *Standalone*: none — the loop reads no goal state at all, so pi-goal being absent, complete, or paused is irrelevant to it; it ends only via `loop_complete`, a cap, or you. *Goal-bound*: a missing goal (cleared mid-loop) **pauses** the loop; completion **stops** it — including through the clear that follows it, since pi-goal persists the finished goal and *then* clears the entry, so the newest entry at completion is a clear and the loop reads back past it; a safety pause (`paused`/`blocked`/`usage_limited`/`budget_limited`, or any unknown status) **pauses** it — pi-loop never pokes past pi-goal's circuit breakers. An `active` or `goal_wait`-waiting goal in an idle session is exactly the stall this extension exists for, so it pokes toward the goal (a tick is the external wake `goal_wait` arranges).
37
- 5. **Iteration cap** (default 25 delivered pokes, `--max`/settings, explicit `unlimited` opt-in): stop.
38
- 6. **Poke**: a goal wake message — the wake header, why it fired (stalled or the external wake for a waiting goal), and the loop focus when set. Every poke carries a marker (`<!-- pi-loop-poke:<id>:<n> -->`) so a wakeup is identifiable as loop-injected rather than user-typed. The marker is **provenance only** — pi-loop coalesces wakes in its own state and never reads the marker back to drop a delivery.
79
+ 4. **A declared wait?** A `loop_wait` whose deadline has not passed holds both drivers: the loop is not stalled, it is waiting on the world.
80
+ 5. **Caps** (see [Two counters](#two-counters)): stop.
81
+ 6. **Settled boundary:** dispatch the recorded continuation a pointer-sized message (`⟳ loop continue #6`) that points at the system prompt for the objective, exactly as the pokes do.
82
+ 7. **Fallback heartbeat: poke.** The wake header, why it fired (stalled, or a wait that has elapsed), and the loop focus when set. Every poke carries a marker (`<!-- pi-loop-poke:<id>:<n> -->`) so a wakeup is identifiable as loop-injected rather than user-typed. The marker is **provenance only** — pi-loop coalesces wakes in its own state and never reads the marker back to drop a delivery.
39
83
 
40
- **A poke never restates the objective**, in either mode — the objective always reaches the model through a byte-stable system append on the same turn, and duplicating it in the message would store another copy on every wake. The two modes differ only in who provides that append: pi-goal's, on every active goal turn, for a goal-bound loop; this extension's own, for a standalone one. Both work because pokes are delivered as ordinary user messages that pass through `before_agent_start`.
41
-
42
- That makes the goal-bound case a genuine **cross-extension assumption**: if pokes were ever delivered by a path that bypasses `before_agent_start` (for example `pi.sendMessage({triggerTurn})`, which calls the agent directly), a goal-bound poke would arrive with no objective anywhere and would have to carry it again. The token-lean contract is pinned in `test/messages.test.ts`; the matching cache-stability contract lives in [pi-goal](https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-goal#fork-feature-cache-safe-token-lean-injections).
84
+ **A poke never restates the objective.** The objective reaches the model through a byte-stable system append on the same turn, and duplicating it in the message would store another copy on every wake. That works because pokes are delivered as ordinary user messages, which pass through `before_agent_start`; a delivery path that bypassed it (for example `pi.sendMessage({triggerTurn})`, which calls the agent directly) would arrive with no append and would have to carry the objective again. The token-lean contract is pinned in `test/messages.test.ts`.
43
85
 
44
86
  In the transcript, a poke renders as a one-line chip (`⏰ loop wake 4/25 · stalled`) via a markdown transformer. That hook is display-only by Pi's contract — the stored message and the model's context are untouched.
45
87
 
46
- Expiry, completion, and pi-goal's safety states are also evaluated whenever the session settles, so a loop stops as soon as its goal does rather than at the next scheduled tick. Only the timer pokes.
88
+ Expiry and the caps are evaluated whenever the session settles, so a loop ends as soon as the work does rather than at the next scheduled tick. Only the fallback heartbeat pokes; a settle continues.
47
89
 
48
90
  The footer status shows `loop 5m · 3/25 · next 14:32`, and a widget above the editor shows the same state with the loop focus beneath it; `/loop status` shows the full card including the last tick's decision and reason.
49
91
 
50
- ## Standalone loops: objective, injection, and `loop_complete`
92
+ ## Objective, injection, and `loop_complete`
93
+
94
+ A loop puts its own objective in front of the model, under a cache-safe split:
95
+
96
+ - **Static per loop — the system prompt.** The objective, `loop_id`, ledger contract, and loop-mode rules are appended to the system prompt, **byte-identically on every turn of that loop**. Anthropic caches `tools → system → messages` as one prefix, so a moving value there (iteration, next wake) would invalidate the cache for the whole conversation every wake. It changes only when the loop does.
97
+
98
+ `test/bytes.test.ts` pins the exact stored bytes of every loop message — anchor, continuations, re-anchor, wakes, expiry — with exact-equality assertions, checks none of them ends in trailing whitespace, and replays a loop through its persisted session-entry form to prove the rebuilt messages are byte-identical. The rest of the suite asserts with `match`, which a silently reworded message passes; a changed cache prefix is exactly the kind of regression that costs money without failing anything.
99
+ - **Dynamic per wake — the poke or continuation.** The tail message carries only the wake or turn number, the interval, and the focus, and points at the system prompt for the rest.
100
+
101
+ ### `loop_complete` and the evidence gate
102
+
103
+ `loop_complete` was once deliberately thin, on the argument that stopping a pacemaker has a small blast radius. That argument does not survive the loop becoming the *only* long-work mechanism: a premature completion now abandons autonomous work outright, and the model doing the abandoning is the one that decided the work was done.
104
+
105
+ So completion is gated on the loop's own `criteria.json`. The tool takes a required **`evidence`** map of criterion id to a cited citation, and refuses when:
106
+
107
+ - a criterion has no entry (the refusal names each one, and marks those `criteria.json` still records as unmet);
108
+ - an entry cites an id that is not in the file (inventing ids does not satisfy the gate);
109
+ - an entry asserts completion instead of citing it ("done", "verified", anything under a dozen characters).
51
110
 
52
- A standalone loop has to put its objective in front of the model itself, since no pi-goal append is doing it. It uses the same cache-safe split pi-goal uses:
111
+ The gate is deliberately **mechanical**: it cannot judge whether evidence is *good*, only that the model was made to look at every requirement and say something specific about each. The rules that make the citation worth anything — audit requirement by requirement, authoritative state over transcript, weak or merely consistent evidence is not enough, **effort exhaustion is not completion** — live in the tool description and the system append. With no readable `criteria.json` the gate degrades to "cite at least one specific thing", because the ledger is fail-open everywhere else too.
53
112
 
54
- - **Static per loop the system prompt.** The objective, `loop_id`, and loop-mode rules are appended to the system prompt, **byte-identically on every turn of that loop**. Anthropic caches `tools system messages` as one prefix, so a moving value there (iteration, next wake) would invalidate the cache for the whole conversation every wake. It changes only when the loop does.
55
- - **Dynamic per wake — the poke.** The poke carries only the wake number, the interval, and the focus, and points at the system prompt for the rest.
113
+ The `loop_id` match is retained, so a stale turn cannot stop a newer loop. The tool is registered **unconditionally**, never toggled with loop state, because tools are part of the cached prefix and mutating the tool set mid-session invalidates the conversation cache; with no standalone loop active it simply refuses.
56
114
 
57
- `loop_complete` is how a standalone loop ends early. It is deliberately **thin** compared to pi-goal's `goal_complete`: one `loop_id` match to stop a stale turn from ending a newer loop, and no evidence-audit rules block. Stopping a loop only stops the pacemaker it asserts nothing about whether the wider task is done — so the blast radius does not justify duplicating pi-goal's hardening. It is registered **unconditionally**, never toggled with loop state, because tools are part of the cached prefix and mutating the tool set mid-session invalidates the conversation cache; with no standalone loop active it simply refuses.
115
+ There is no judge model: a second model grading the first is a bigger change than the criteria/evidence gate, and this is the rung that ships.
116
+
117
+ ## `loop_wait`: the adaptive wake
118
+
119
+ Without it a loop has exactly one answer to "progress depends on something outside this session": keep continuing, and burn turns re-checking. `loop_wait` lets the model say what it is waiting for and roughly how long:
120
+
121
+ - **`reason`** (required, one sentence) is shown in the widget and `/loop status`, and is the only record of what the loop was waiting for.
122
+ - **`resume_after_ms`** is optional and clamped to **[60s, 1h]**, with the clamped value echoed back. Below a minute a "wait" is polling, which is what the tool replaces; above an hour it stops being a wait and the fallback heartbeat covers it better. Omitting it keeps the loop quiet until something else wakes the session.
123
+ - The tool description carries the **cache-window guidance**: never poll for work Pi already notifies about, avoid ~300s (the prompt-cache dead zone, where the cache has just expired and the next turn re-reads the conversation at full price), use ≤270s only when actively polling external state, otherwise commit to 1200s+.
124
+
125
+ A wait **holds both drivers** — no settle continuation, no fallback poke — but does **not** pause the loop and does **not** cancel the pacemaker: it supersedes the next fallback wake, so a wait whose event never arrives still ends in a wake rather than in silence. The deadline timer is generation-guarded and re-armed on session start, so a deadline that passed while the session was away is due immediately.
126
+
127
+ A wake delivered for an elapsed wait **counts against `maxIterations`**, so a model that keeps re-arming a wait cannot run forever.
128
+
129
+ There is deliberately **no cancel tool**. The events that legitimately cancel a wait (you typing, an earlier wake arriving) are not the model's to report — so when one of them ends a wait, its reason rides along once on the next loop message as `Previous wait (cancelled): …` and is then dropped.
130
+
131
+ ## Breakers
132
+
133
+ - **No progress.** The characteristic failure of an autonomous loop is not crashing, it is *restating*: the same paragraph of "here is what I would do next", turn after turn, calling no tools. pi-loop fingerprints the visible assistant text (SHA-256 over NFKC-normalised, case- and whitespace-folded text) of every tool-free loop-caused turn; `noProgressTurns` consecutive repeats (default 3, settings-tunable, `null` disables) **pause** the loop rather than stopping it — it stays configured, the widget says why, and `/loop resume` or your next message continues it with a fresh safety epoch. A turn that called **any** tool, including `loop_wait`, is progress by definition and resets the counter; counting a declared wait is the false positive that made this class of breaker infamous.
134
+ - **Interruption classification.** A loop that answers every provider failure with "continue" retries into exhausted quotas and re-sends requests too large to succeed. So each class gets its own answer: usage/billing exhaustion **pauses** (retrying a quota window that has not reset just burns the caps), an unrecoverable auth error **pauses**, an aborted loop turn (`Esc`) **pauses**, a context overflow **compacts and then continues** regardless of what the usage gauge says — the failed request just disproved that reading — and a transient error simply continues, because the next continuation *is* the retry.
135
+
136
+ ## The loop ledger
137
+
138
+ A multi-day loop cannot keep its state in the conversation: compaction is lossy by construction, and a summary of a summary drifts further from what happened every time. So the conversation stays the working memory, and two files become the record — under `~/.pi/agent/loop/<loop-id>/` (keyed by **loop id**: session ids are not stably exposed to extensions, and one session can run several loops in sequence):
139
+
140
+ - **`criteria.json`** — this loop's completion criteria, derived from the objective when the loop starts (bullets if you wrote a list, otherwise sentences, otherwise one implicit criterion) and echoed back to you so you can see what `loop_complete` will answer for. JSON deliberately, not Markdown: models rewrite prose they are asked to maintain far more readily than they rewrite a structured file. The model may change **only** the `passes` field, only with cited evidence, and may never add, remove, or reword an entry — a model allowed to rewrite its own acceptance criteria eventually rewrites them into something it has already achieved.
141
+ - **`PROGRESS.md`** — the agent-maintained ledger, created with a fixed four-section schema (current status / completed / **failed approaches and why** / next actions) so "update the ledger" means the same thing on every turn. Failed approaches matter most: nothing else remembers them once the conversation is compacted.
142
+
143
+ Both are **best-effort**. An unwritable home directory, a full disk, or a file hand-edited into invalid JSON degrades the loop to "no ledger" with a single warning; it never breaks the loop. `PROGRESS.md` is created and then never overwritten, so a session restart cannot erase days of ledger.
144
+
145
+ ### The kickoff anchor
146
+
147
+ The system append carries the objective only while the loop is *active*, and contributes nothing once it stops. So `/loop` also stores **one** ordinary message per loop holding the objective data — trust boundary, `<loop_objective>`, `<loop_id>`, ledger path — which survives the loop stopping, a resume, and (as ordinary transcript) a compaction. It repeats the objective *data*, never the loop-mode *rules*: those govern active turns, which always get the append. Paid once per loop, not per wake.
58
148
 
59
149
  ## Loop-aware compaction
60
150
 
61
151
  Long loops die by context exhaustion, not by failing. pi-loop owns the compaction path:
62
152
 
63
- - **Proactive compact at a threshold** (default 70% of the context window, `--compact-at` / settings): at an idle boundary, pi-loop triggers `/compact` itself with loop-specific instructions — preserve the objective and acceptance criteria verbatim, decisions and dead-ends, files modified, commands and unresolved errors, the next 1-3 actions, and carry prior summaries forward cumulatively. Pending pokes are held until the compaction completes. Pi's reserve-token auto-compaction remains as the fault handler.
64
- - **No post-compaction continuation of its own**: pi-goal already re-prompts the session after a compaction for an active goal, and a loop requires an active goal, so a second follow-up from pi-loop would only duplicate it doubling queued messages and tokens. pi-goal owns that message; pi-loop owns the compaction trigger and its instructions.
153
+ - **Proactive compact at a threshold** (default 70% of the context window, `--compact-at` / settings): at an idle boundary, pi-loop triggers `/compact` itself with loop-specific instructions — preserve the objective and acceptance criteria verbatim, **every failed approach and the reason it failed**, decisions and rationale, files modified, commands and unresolved errors, and the next 1-3 actions. The instructions explicitly **stop carrying prior summaries forward wholesale** and tell the next turn to re-derive status from the ledger and authoritative state instead: cumulative carry-forward grows the text while the information in it decays. Pending pokes are held until the compaction completes. Pi's reserve-token auto-compaction remains as the fault handler.
154
+ - **Loop-owned re-anchor**: when a compaction completes mid-loop, pi-loop dispatches one pointer-sized continuation at the next settle re-read `PROGRESS.md` and `criteria.json`, continue from authoritative state, plus the next 1-3 actions lifted out of the summary that just replaced the conversation. The loop no longer goes quiet until the next wake. A re-anchor supersedes an ordinary continuation already queued: after a compaction, "re-read the ledger" is strictly the better instruction.
65
155
  - Loop state itself lives in custom session entries, which compaction never touches, and survives session restarts (the timer re-arms on resume; expired loops are dropped with a notice).
66
156
 
157
+ ## `/schedule`: recurring prompts and headless runs
158
+
159
+ The same extension also schedules work, because the machinery is the same machinery: an idle-gated delivery path, coalescing, caps, and an expiry.
160
+
161
+ ```
162
+ /schedule manager TUI
163
+ /schedule list
164
+ /schedule every 30m check the release queue in-session prompt, every 30 minutes
165
+ /schedule at +2h remind me to cut the RC once, two hours from now
166
+ /schedule at 2026-01-31T09:00 monthly report once, at an ISO timestamp
167
+ /schedule cron "0 9 * * 1" weekly triage Monday mornings
168
+ /schedule every 6h --run --cwd /srv/app sync headless `pi -p` run
169
+ /schedule pause|resume|run|status|delete <id>
170
+ ```
171
+
172
+ Flags: `--run` (headless instead of in-session), `--cwd <path>`, `--max <n|unlimited>`, `--wake always|failure|success|never`, `--name <text>`.
173
+
174
+ **Two task kinds, deliberately different lifetimes:**
175
+
176
+ - **`prompt`** injects a prompt into the owning session, delivered exactly like a loop wake — only at a settled idle boundary, queued while the agent is busy. It is **session-scoped**: it lives in memory and dies with the session, because a prompt with no session to arrive in is not a task, it is a leak.
177
+ - **`run`** spawns a headless `pi -p "<prompt>"` in a working directory, tees stdout and stderr to `~/.pi/agent/loop/runs/<task-id>/<timestamp>.log`, and records the exit code. It never touches the conversation unless `wakeOn` says to report back (default: only failures). These are the only tasks persisted, in `~/.pi/agent/loop/schedules.json`.
178
+
179
+ A headless run is a **fresh `pi` invocation**, so it uses your *default* model and settings, not the model the scheduling session happens to be using. If a run needs a specific model, say so in the prompt's environment — or check the run log, which records the command, cwd, prompt, and exit code precisely so a surprise like this is one `cat` away.
180
+
181
+ **One fire per occurrence, not one per open session.** Headless firing is arbitrated by a lockfile lease (`schedules.lease`) holding a pid and a heartbeat: without it, a task scheduled for 09:00 fires once in every Pi session that happens to be open. A holder that dies stops renewing and the next session takes over after 90 seconds. It is not a distributed lock — the failure it must prevent is duplicate work, and the worst it can produce is one skipped tick.
182
+
183
+ **Missed occurrences coalesce into a single fire.** A laptop asleep for a weekend wakes to one catch-up, never one turn per missed interval.
184
+
185
+ **Every task is bounded twice**: `maxRuns` (default 25, `--max unlimited` is an explicit opt-in) and a hard 90-day expiry.
186
+
187
+ **Cron** is five numeric fields at minute granularity (`minute hour day-of-month month day-of-week`), supporting `*`, `n`, `a-b`, `a,b`, and `/step`. No names, no `@daily`, no seconds, no timezones beyond the host's local clock — each of those is a place where two implementations disagree, and a scheduler with debatable semantics is worse than one that refuses the expression. When both day fields are restricted, a day matching *either* fires, as in every crontab in the world.
188
+
189
+ `/schedule` is **user-typed only**, exactly like `/loop`: the model gets no scheduling tools. A model that can schedule its own future turns can schedule its way around every limit the loop imposes.
190
+
191
+ > **Never co-install [`@jl1990/pi-scheduler`](https://www.npmjs.com/package/@jl1990/pi-scheduler).** Both register `/schedule`; the commands and the concepts collide.
192
+
67
193
  ## Settings
68
194
 
69
195
  `~/.pi/agent/pi-loop.json` (absent file = defaults, never created implicitly; saves are atomic and preserve unknown fields), or `/loop settings`:
@@ -71,7 +197,11 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
71
197
  ```json
72
198
  {
73
199
  "maxIterations": 25,
200
+ "automaticTurns": 25,
201
+ "noProgressTurns": 3,
74
202
  "maxLoopDuration": "7d",
203
+ "inlineInvocation": true,
204
+ "defaultInterval": "10m",
75
205
  "compaction": {
76
206
  "enabled": true,
77
207
  "threshold": 0.7,
@@ -80,7 +210,15 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
80
210
  }
81
211
  ```
82
212
 
83
- `maxIterations: null` means unlimited. `compaction.instructions` overrides the built-in template.
213
+ `maxIterations: null` and `automaticTurns: null` mean unlimited; `noProgressTurns: null` disables the breaker. `inlineInvocation: false` turns off mid-prompt `/loop` detection; `defaultInterval` is the fallback heartbeat an inline invocation gets when it names none.
214
+
215
+ ## Deliberate omissions
216
+
217
+ These were considered and cut, and the reasoning is recorded so they are not silently re-added:
218
+
219
+ - **No token or time budget accounting.** The only lifetime bound is the expiry. Budgets interact badly with compaction (which resets nothing in the accounting), and a loop that stops mid-task because it ran out of tokens is worse than one that stops because its deadline arrived and it wrote its state down.
220
+ - **No judge model.** Grading completion with a second model is a larger, more expensive change than the criteria/evidence gate; the gate is the rung that ships.
221
+ - **No `loop_blocked` tool.** `loop_wait` covers a real external dependency, and the no-progress breaker covers an impasse the model does not recognise as one. A third "I give up" tool mostly gives a model a way to stop early. `compaction.instructions` overrides the built-in template.
84
222
 
85
223
  ## Install
86
224
 
@@ -88,7 +226,7 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
88
226
  pi install npm:@hank-warren/pi-loop
89
227
  ```
90
228
 
91
- Requires a goal extension for its session entries: `npm:@hank-warren/pi-goal` (recommended) or upstream `@narumitw/pi-goal`.
229
+ **No other extension is required.** The only sibling state pi-loop reads is [pi-plan-mode](https://www.npmjs.com/package/@hank-warren/pi-plan-mode)'s, fail-open, so a loop never injects into a planning conversation — and it works fine without it.
92
230
 
93
231
  ## License
94
232
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-loop",
3
- "version": "0.4.1",
4
- "description": "Interval wakeups for Pi: recurring prompt re-runs, stall rescue toward an active pi-goal goal, and loop-aware compaction that survives long sessions.",
3
+ "version": "0.6.0",
4
+ "description": "Long-running work for Pi: settle-paced loops with a durable ledger, adaptive waits, no-progress breakers, evidence-gated completion, and a task scheduler.",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
@@ -9,6 +9,7 @@
9
9
  "pi",
10
10
  "loop",
11
11
  "scheduler",
12
+ "cron",
12
13
  "automation"
13
14
  ],
14
15
  "author": "Hank Warren",
@@ -38,6 +39,7 @@
38
39
  "CHANGELOG.md"
39
40
  ],
40
41
  "peerDependencies": {
42
+ "@earendil-works/pi-ai": "*",
41
43
  "@earendil-works/pi-coding-agent": "*",
42
44
  "@earendil-works/pi-tui": "*",
43
45
  "typebox": "*"
package/src/ack.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The no-op acknowledgement protocol.
3
+ *
4
+ * A woken loop with nothing to do still costs a full turn: the model re-reads
5
+ * the ledger, confirms there is nothing to act on, and writes a paragraph
6
+ * explaining that. The paragraph is pure cost — nobody reads it, and it is
7
+ * indistinguishable, to the loop, from a turn that did work.
8
+ *
9
+ * So the pokes ask for a fixed token instead: reply `LOOP_OK` when nothing
10
+ * needs attention. That gives two things a prose answer cannot. The
11
+ * transcript collapses it to a one-line chip (display only — the stored bytes
12
+ * are untouched, because rewriting them would break the prompt cache), and
13
+ * the engine gets a *deterministic* signal that the wake was wasted, which is
14
+ * what feeds the fallback backoff.
15
+ *
16
+ * The remainder budget is fixed at 300 characters rather than configurable:
17
+ * an acknowledgement with a paragraph attached is not an acknowledgement, and
18
+ * a knob here would only let the protocol decay into ordinary prose.
19
+ */
20
+
21
+ export const LOOP_OK_TOKEN = "LOOP_OK";
22
+ export const ACK_REMAINDER_LIMIT = 300;
23
+
24
+ /**
25
+ * The acknowledgement's remainder (a short note the model may attach), or
26
+ * undefined when the text is not an acknowledgement.
27
+ */
28
+ export function parseLoopOkAck(text: string): { remainder: string } | undefined {
29
+ const trimmed = text.trim();
30
+ if (!trimmed.startsWith(LOOP_OK_TOKEN) && !trimmed.endsWith(LOOP_OK_TOKEN)) return undefined;
31
+ const remainder = (
32
+ trimmed.startsWith(LOOP_OK_TOKEN)
33
+ ? trimmed.slice(LOOP_OK_TOKEN.length)
34
+ : trimmed.slice(0, -LOOP_OK_TOKEN.length)
35
+ )
36
+ .replace(/^[\s.:;,\-—·|]+|[\s.:;,\-—·|]+$/gu, "")
37
+ .trim();
38
+ // A "LOOP_OK" with an essay attached is a normal turn that happens to
39
+ // mention the token, not an acknowledgement.
40
+ return remainder.length <= ACK_REMAINDER_LIMIT ? { remainder } : undefined;
41
+ }
42
+
43
+ /** Whether the run's visible assistant text is a no-op acknowledgement. */
44
+ export function isLoopOkAck(messages: readonly unknown[]): boolean {
45
+ const text = finalAssistantText(messages);
46
+ return text === undefined ? false : parseLoopOkAck(text) !== undefined;
47
+ }
48
+
49
+ function finalAssistantText(messages: readonly unknown[]): string | undefined {
50
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
51
+ const message = messages[index];
52
+ if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
53
+ continue;
54
+ }
55
+ const text = message.content
56
+ .filter((block): block is Record<string, unknown> => isRecord(block) && block.type === "text")
57
+ .map((block) => (typeof block.text === "string" ? block.text : ""))
58
+ .join("\n")
59
+ .trim();
60
+ if (text) return text;
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ function isRecord(value: unknown): value is Record<string, unknown> {
66
+ return typeof value === "object" && value !== null && !Array.isArray(value);
67
+ }
package/src/command.ts CHANGED
@@ -3,13 +3,17 @@
3
3
  *
4
4
  * /loop -> show (manager TUI / status)
5
5
  * /loop status|pause|resume|stop|settings -> subcommand
6
- * /loop [--max N] [--compact-at X] <interval> [prompt...]
6
+ * /loop [flags] <interval> [flags] [prompt...]
7
7
  *
8
- * Flags precede the interval; the interval is the first non-flag token;
9
- * everything after it (raw, newlines preserved) is the prompt.
8
+ * Flags (`--max N`, `--compact-at X`, `--expires 3d`) may appear on either
9
+ * side of the interval; the interval is the first non-flag token, and
10
+ * everything after the trailing flags (raw, newlines preserved) is the
11
+ * prompt. Accepting them on both sides is not politeness: when they were
12
+ * positional, `/loop 5m --max 3 fix the tests` silently folded the flag into
13
+ * the objective and applied the default instead.
10
14
  */
11
15
 
12
- import { parseInterval } from "./interval.js";
16
+ import { parseDuration, parseInterval } from "./interval.js";
13
17
 
14
18
  export const LOOP_SUBCOMMANDS = ["status", "pause", "resume", "stop", "settings"] as const;
15
19
  export type LoopSubcommand = (typeof LOOP_SUBCOMMANDS)[number];
@@ -23,6 +27,8 @@ export interface LoopStartArguments {
23
27
  maxIterations?: number | null;
24
28
  /** undefined = use settings default; null = disabled for this loop. */
25
29
  compactAt?: number | null;
30
+ /** Per-loop lifetime in ms; undefined = use the settings default. */
31
+ expiresInMs?: number;
26
32
  prompt?: string;
27
33
  }
28
34
 
@@ -43,35 +49,11 @@ export function parseLoopCommand(args: string): LoopCommand {
43
49
  text: match[0],
44
50
  index: match.index,
45
51
  }));
46
- let maxIterations: number | null | undefined;
47
- let compactAt: number | null | undefined;
48
- let position = 0;
49
- while (position < tokens.length) {
50
- const token = tokens[position];
51
- if (token === undefined || !token.text.startsWith("--")) break;
52
- const [flag, inlineValue] = splitFlag(token.text);
53
- const next = tokens[position + 1];
54
- const value = inlineValue ?? next?.text;
55
- const consumed = inlineValue !== undefined ? 1 : 2;
56
- if (flag === "--max") {
57
- if (value === undefined) return { kind: "error", message: "--max needs a value (a positive number, or unlimited)." };
58
- maxIterations = parseMax(value);
59
- if (maxIterations === undefined) {
60
- return { kind: "error", message: `Invalid --max value: ${value}. Use a positive whole number or unlimited.` };
61
- }
62
- } else if (flag === "--compact-at") {
63
- if (value === undefined) return { kind: "error", message: "--compact-at needs a value (e.g. 60% or off)." };
64
- compactAt = parseCompactAt(value);
65
- if (compactAt === undefined) {
66
- return { kind: "error", message: `Invalid --compact-at value: ${value}. Use a percentage between 1% and 99% (e.g. 60%), a fraction (0.6), or off.` };
67
- }
68
- } else {
69
- return { kind: "error", message: `Unknown flag: ${flag}. Known flags: --max, --compact-at.` };
70
- }
71
- position += consumed;
72
- }
52
+ const flags: LoopFlags = {};
53
+ const beforeInterval = scanFlags(tokens, 0, flags);
54
+ if (typeof beforeInterval !== "number") return beforeInterval;
73
55
 
74
- const intervalToken = tokens[position];
56
+ const intervalToken = tokens[beforeInterval];
75
57
  if (intervalToken === undefined) {
76
58
  return { kind: "error", message: "An interval is required to start a loop, e.g. /loop 5m <prompt>." };
77
59
  }
@@ -82,7 +64,15 @@ export function parseLoopCommand(args: string): LoopCommand {
82
64
  message: `Invalid interval: ${intervalToken.text}. Use <number><unit> with unit s, m, h, or d, e.g. 5m.`,
83
65
  };
84
66
  }
85
- const promptToken = tokens[position + 1];
67
+ // Flags are also accepted *after* the interval. They used to be positional,
68
+ // which meant `/loop 5m --max 3 fix the tests` silently made "--max 3 fix
69
+ // the tests" the objective: no error, a polluted objective, and a setting
70
+ // that quietly did not apply.
71
+ const afterInterval = scanFlags(tokens, beforeInterval + 1, flags);
72
+ if (typeof afterInterval !== "number") return afterInterval;
73
+ const { maxIterations, compactAt, expiresInMs } = flags;
74
+
75
+ const promptToken = tokens[afterInterval];
86
76
  const prompt = promptToken === undefined ? undefined : args.slice(promptToken.index).trim();
87
77
  return {
88
78
  kind: "start",
@@ -91,10 +81,79 @@ export function parseLoopCommand(args: string): LoopCommand {
91
81
  clamped: interval.clamped,
92
82
  ...(maxIterations === undefined ? {} : { maxIterations }),
93
83
  ...(compactAt === undefined ? {} : { compactAt }),
84
+ ...(expiresInMs === undefined ? {} : { expiresInMs }),
94
85
  ...(prompt ? { prompt } : {}),
95
86
  };
96
87
  }
97
88
 
89
+ interface LoopFlags {
90
+ maxIterations?: number | null;
91
+ compactAt?: number | null;
92
+ expiresInMs?: number;
93
+ }
94
+
95
+ /**
96
+ * Consume leading `--flag` tokens starting at `position`, filling `flags`.
97
+ * Returns the index of the first non-flag token, or the parse error.
98
+ */
99
+ function scanFlags(
100
+ tokens: ReadonlyArray<{ text: string; index: number }>,
101
+ position: number,
102
+ flags: LoopFlags,
103
+ ): number | { kind: "error"; message: string } {
104
+ while (position < tokens.length) {
105
+ const token = tokens[position];
106
+ if (token === undefined || !token.text.startsWith("--")) break;
107
+ const [flag, inlineValue] = splitFlag(token.text);
108
+ const value = inlineValue ?? tokens[position + 1]?.text;
109
+ const consumed = inlineValue !== undefined ? 1 : 2;
110
+ if (flag === "--max") {
111
+ if (value === undefined) {
112
+ return { kind: "error", message: "--max needs a value (a positive number, or unlimited)." };
113
+ }
114
+ const parsed = parseMax(value);
115
+ if (parsed === undefined) {
116
+ return {
117
+ kind: "error",
118
+ message: `Invalid --max value: ${value}. Use a positive whole number or unlimited.`,
119
+ };
120
+ }
121
+ flags.maxIterations = parsed;
122
+ } else if (flag === "--compact-at") {
123
+ if (value === undefined) {
124
+ return { kind: "error", message: "--compact-at needs a value (e.g. 60% or off)." };
125
+ }
126
+ const parsed = parseCompactAt(value);
127
+ if (parsed === undefined) {
128
+ return {
129
+ kind: "error",
130
+ message: `Invalid --compact-at value: ${value}. Use a percentage between 1% and 99% (e.g. 60%), a fraction (0.6), or off.`,
131
+ };
132
+ }
133
+ flags.compactAt = parsed;
134
+ } else if (flag === "--expires") {
135
+ if (value === undefined) {
136
+ return { kind: "error", message: "--expires needs a duration (e.g. 3d)." };
137
+ }
138
+ const parsed = parseDuration(value);
139
+ if (parsed === undefined) {
140
+ return {
141
+ kind: "error",
142
+ message: `Invalid --expires value: ${value}. Use <number><unit> with unit s, m, h, or d, e.g. 3d.`,
143
+ };
144
+ }
145
+ flags.expiresInMs = parsed;
146
+ } else {
147
+ return {
148
+ kind: "error",
149
+ message: `Unknown flag: ${flag}. Known flags: --max, --compact-at, --expires.`,
150
+ };
151
+ }
152
+ position += consumed;
153
+ }
154
+ return position;
155
+ }
156
+
98
157
  function splitFlag(token: string): [string, string | undefined] {
99
158
  const equals = token.indexOf("=");
100
159
  if (equals === -1) return [token, undefined];