@hank-warren/pi-loop 0.4.0 → 0.5.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,26 @@
1
1
  # @hank-warren/pi-loop
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 55eab7e: pi-loop v2: long-running work, not just interval wakeups.
8
+
9
+ - **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.
10
+ - **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.
11
+ - **`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.
12
+ - **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.
13
+ - **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.
14
+ - **`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.
15
+ - **`/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.
16
+ - **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.
17
+
18
+ ## 0.4.1
19
+
20
+ ### Patch Changes
21
+
22
+ - 3fbf632: Clear `nextWakeAt` when the wake timer fires, so `/loop status` can no longer report a "Next wake" clock time that has already passed. A tick that coalesces instead of firing — a busy or compacting session — left the old deadline in place, printing it directly above the line saying a wake is pending at the next idle boundary. Display only; no scheduling behaviour changes.
23
+
3
24
  ## 0.4.0
4
25
 
5
26
  ### Minor Changes
package/README.md CHANGED
@@ -1,13 +1,21 @@
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**: it owns *when* the session works. What it works *on* comes in two modes, chosen automatically when the loop starts:
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
+ - **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**, and this is the mode to use.
8
+ - **Goal-bound** (**deprecated**) — an active [pi-goal](../pi-goal) goal is present, so the loop binds to it and pi-goal owns *whether the work is done*. Coupling stays read-only: fail-open reads of pi-goal's `goal-state` entries.
9
9
 
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.
10
+ **An active goal still wins**, for now: start a loop while a goal is running and it binds to that goal, with any trailing text kept as a per-wake focus — with a deprecation warning. With no active goal, the trailing text becomes the loop's own objective.
11
+
12
+ ### Goal-bound loops are going away
13
+
14
+ A standalone loop now does everything the pairing did — it owns its objective, keeps a durable ledger, re-anchors itself after compaction, and gates completion on cited evidence — so delegating "is the work done" to a second extension buys nothing and costs a coupling. [pi-goal](../pi-goal) carries the matching deprecation banner.
15
+
16
+ This release keeps the goal-bound path working, warns when you start one, and **migrates a restored goal-bound loop to standalone** by adopting the goal's objective text (falling back to the loop's own focus text). The one case it deliberately does not migrate is a **still-active** goal: pi-goal is driving that session's continuations, and a standalone loop driving them too would send two messages at every settle — that loop keeps its old behaviour and gets the warning instead. If there is nothing to adopt at all, the loop pauses and says so rather than pretending.
17
+
18
+ The following release removes the goal-bound branch, the `goal-state` readers, and their fixtures.
11
19
 
12
20
  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.
13
21
 
@@ -20,30 +28,64 @@ pi-plan-mode's `plan-mode-state` is read the same fail-open way in both modes, s
20
28
  /loop 10m recheck the pipeline # trailing text is a per-wake focus when goal-bound
21
29
  /loop # manager TUI (status, pause/resume, edit, settings, stop)
22
30
  /loop status | pause | resume | stop | settings
23
- /loop --max 20 --compact-at 60% 10m # per-loop overrides
31
+ /loop --max 20 --compact-at 60% --expires 3d 10m # per-loop overrides
32
+ /loop 10m --max 20 <objective> # ...or after the interval; both work
24
33
  ```
25
34
 
26
35
  - **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
36
  - `/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.)
28
37
 
38
+ ## What paces a loop
39
+
40
+ A **standalone** loop is paced by the session settling, not by the clock:
41
+
42
+ 1. `/loop <interval> <objective>` dispatches the **first working turn immediately** — the loop never burns its first interval sitting idle.
43
+ 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.
44
+ 3. A delivery Pi refuses keeps the intent, so the next settle retries it.
45
+
46
+ 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.
47
+
48
+ 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.
49
+
50
+ ### The `LOOP_OK` acknowledgement
51
+
52
+ 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."*
53
+
54
+ 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.
55
+
56
+ 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.
57
+
58
+ A **goal-bound** loop is unchanged: pi-goal drives its own settle continuations, so pi-loop would only double every turn, and the interval stays that loop's only driver.
59
+
60
+ ### Two counters
61
+
62
+ One wake now yields many turns, so a single counter cannot bound a loop:
63
+
64
+ - `maxIterations` (default 25, `--max`, settings) counts **delivered wakes** — fallback pokes only.
65
+ - `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.
66
+
67
+ Either cap trips independently and stops the loop; `null` on either means unlimited.
68
+
29
69
  ## What a wakeup does
30
70
 
31
- Each tick evaluates, in order:
71
+ Each tick — fallback heartbeat or settled boundary — evaluates, in order:
32
72
 
33
- 1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days) — a forgotten loop is bounded.
73
+ 1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days, or per loop with `--expires 3d`, echoed at start) — a forgotten loop is bounded. A standalone 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
74
  2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
35
75
  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
76
  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.
77
+ 5. **Caps** (see [Two counters](#two-counters)): stop.
78
+ 6. **Settled boundary, standalone loop:** 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.
79
+
38
80
  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.
39
81
 
40
82
  **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
83
 
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
+ That makes the goal-bound case a genuine **cross-extension assumption**, and the last one left: 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. It disappears with the goal-bound path. 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), whose README records the same assumption from the other side.
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, completion, and pi-goal's safety states are evaluated whenever the session settles, so a loop stops as soon as its goal 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
 
@@ -51,19 +93,103 @@ The footer status shows `loop 5m · 3/25 · next 14:32`, and a widget above the
51
93
 
52
94
  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:
53
95
 
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.
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).
110
+
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.
112
+
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.
114
+
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:
56
120
 
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.
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. A standalone loop no longer goes quiet until the next wake, and nothing is delegated to pi-goal. A re-anchor supersedes an ordinary continuation already queued: after a compaction, "re-read the ledger" is strictly the better instruction. (A goal-bound loop still leaves that message to pi-goal, which owns its continuations.)
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,6 +197,8 @@ 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",
75
203
  "compaction": {
76
204
  "enabled": true,
@@ -80,7 +208,15 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
80
208
  }
81
209
  ```
82
210
 
83
- `maxIterations: null` means unlimited. `compaction.instructions` overrides the built-in template.
211
+ `maxIterations: null` and `automaticTurns: null` mean unlimited; `noProgressTurns: null` disables the breaker.
212
+
213
+ ## Deliberate omissions
214
+
215
+ These were considered and cut, and the reasoning is recorded so they are not silently re-added:
216
+
217
+ - **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.
218
+ - **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.
219
+ - **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
220
 
85
221
  ## Install
86
222
 
@@ -88,7 +224,7 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
88
224
  pi install npm:@hank-warren/pi-loop
89
225
  ```
90
226
 
91
- Requires a goal extension for its session entries: `npm:@hank-warren/pi-goal` (recommended) or upstream `@narumitw/pi-goal`.
227
+ **No other extension is required.** A standalone loop stands alone; a goal extension (`npm:@hank-warren/pi-goal` or upstream `@narumitw/pi-goal`) is only needed for the deprecated goal-bound mode, and that mode is being removed.
92
228
 
93
229
  ## License
94
230
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-loop",
3
- "version": "0.4.0",
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.5.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];