@hank-warren/pi-loop 0.5.0 → 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,23 @@
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
+
3
21
  ## 0.5.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -2,30 +2,16 @@
2
2
 
3
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 works. What it works *on* 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**, 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.
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 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.
19
-
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.
9
+ pi-plan-mode's `plan-mode-state` is read fail-open the only sibling state left so a loop never injects into a planning conversation.
21
10
 
22
11
  ## Usage
23
12
 
24
13
  ```
25
- /loop 30m until all tests pass and CI is green # standalone: the loop owns the objective
26
- /goal get CI green # ...or bind to a goal
27
- /loop 30m # poke that goal every 30m if the session stalls
28
- /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
29
15
  /loop # manager TUI (status, pause/resume, edit, settings, stop)
30
16
  /loop status | pause | resume | stop | settings
31
17
  /loop --max 20 --compact-at 60% --expires 3d 10m # per-loop overrides
@@ -33,7 +19,26 @@ pi-plan-mode's `plan-mode-state` is read the same fail-open way in both modes, s
33
19
  ```
34
20
 
35
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).
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.)
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`.
37
42
 
38
43
  ## What paces a loop
39
44
 
@@ -55,8 +60,6 @@ A reply that starts or ends with `LOOP_OK` and carries at most **300 characters*
55
60
 
56
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.
57
62
 
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
63
  ### Two counters
61
64
 
62
65
  One wake now yields many turns, so a single counter cannot bound a loop:
@@ -70,28 +73,25 @@ Either cap trips independently and stops the loop; `null` on either means unlimi
70
73
 
71
74
  Each tick — fallback heartbeat or settled boundary — evaluates, in order:
72
75
 
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.
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.
74
77
  2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
75
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.
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).
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.
77
80
  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
-
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.
81
-
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`.
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.
83
83
 
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.
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`.
85
85
 
86
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.
87
87
 
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.
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.
89
89
 
90
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.
91
91
 
92
- ## Standalone loops: objective, injection, and `loop_complete`
92
+ ## Objective, injection, and `loop_complete`
93
93
 
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:
94
+ A loop puts its own objective in front of the model, under a cache-safe split:
95
95
 
96
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
97
 
@@ -151,7 +151,7 @@ The system append carries the objective only while the loop is *active*, and con
151
151
  Long loops die by context exhaustion, not by failing. pi-loop owns the compaction path:
152
152
 
153
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.)
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.
155
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).
156
156
 
157
157
  ## `/schedule`: recurring prompts and headless runs
@@ -200,6 +200,8 @@ Flags: `--run` (headless instead of in-session), `--cwd <path>`, `--max <n|unlim
200
200
  "automaticTurns": 25,
201
201
  "noProgressTurns": 3,
202
202
  "maxLoopDuration": "7d",
203
+ "inlineInvocation": true,
204
+ "defaultInterval": "10m",
203
205
  "compaction": {
204
206
  "enabled": true,
205
207
  "threshold": 0.7,
@@ -208,7 +210,7 @@ Flags: `--run` (headless instead of in-session), `--cwd <path>`, `--max <n|unlim
208
210
  }
209
211
  ```
210
212
 
211
- `maxIterations: null` and `automaticTurns: null` mean unlimited; `noProgressTurns: null` disables the breaker.
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.
212
214
 
213
215
  ## Deliberate omissions
214
216
 
@@ -224,7 +226,7 @@ These were considered and cut, and the reasoning is recorded so they are not sil
224
226
  pi install npm:@hank-warren/pi-loop
225
227
  ```
226
228
 
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.
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.
228
230
 
229
231
  ## License
230
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-loop",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
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": [
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `loop_complete`: the standalone loop's terminal tool.
2
+ * `loop_complete`: the loop's terminal tool.
3
3
  *
4
4
  * It used to be deliberately thin, on the argument that stopping a pacemaker
5
5
  * has a small blast radius: the user just restarts it. That argument does not
@@ -94,7 +94,7 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
94
94
  if (!loop || loop.objective === undefined) {
95
95
  return {
96
96
  content: toolContent(
97
- "No standalone /loop is active. loop_complete only applies to a loop started with its own objective; a goal-bound loop is ended through pi-goal.",
97
+ "No /loop with an objective is active, so there is nothing to complete. Start one with /loop <interval> <objective>.",
98
98
  ),
99
99
  details: { loopId: params.loop_id },
100
100
  isError: true,
package/src/decide.ts CHANGED
@@ -9,17 +9,13 @@
9
9
  * handler for a lost continuation or an external wait, not the pacemaker.
10
10
  *
11
11
  * Both share one precedence prefix: loop liveness → expiry → plan mode →
12
- * compaction → busy → mode-specific stop criteria → caps → act.
12
+ * compaction → busy → wait → caps → act.
13
13
  *
14
- * The mode-specific step is the whole difference between the two kinds of
15
- * loop. A goal-bound loop delegates "is the work done" to pi-goal and reads
16
- * its `goal-state`, so a missing goal pauses it and a safety state holds it;
17
- * pi-goal also owns its settle continuations, so `decideContinuation` never
18
- * acts for one. A standalone loop owns its own objective, reads no goal state
19
- * at all, and ends only through `loop_complete`, a cap, or the user.
14
+ * A loop owns its own objective and reads no other extension's state: it ends
15
+ * only through `loop_complete`, a cap, an expiry, or the user.
20
16
  */
21
17
 
22
- import { GOAL_SAFETY_STATUSES, type GoalSnapshot, isStandaloneLoop, type LoopState } from "./state.js";
18
+ import type { LoopState } from "./state.js";
23
19
 
24
20
  export interface TickEnvironment {
25
21
  now: number;
@@ -28,7 +24,6 @@ export interface TickEnvironment {
28
24
  /** A loop-owned proactive compaction is in flight; hold pokes. */
29
25
  compacting: boolean;
30
26
  planModeEnabled: boolean;
31
- goal: GoalSnapshot | undefined;
32
27
  }
33
28
 
34
29
  /** A `loop_wait` whose deadline has passed is due, not waiting. */
@@ -39,8 +34,8 @@ function isWaiting(loop: LoopState, now: number): boolean {
39
34
  }
40
35
 
41
36
  /**
42
- * An expiring standalone loop gets one last turn to write its state into the
43
- * ledger before it stops; every other expiry stops immediately.
37
+ * An expiring loop gets one last turn to write its state into the ledger
38
+ * before it stops; a loop already spending that turn stops immediately.
44
39
  */
45
40
  export type ExpiryReason = "loop-expired" | "expiry-final-wake";
46
41
 
@@ -54,36 +49,26 @@ export type TickDecision =
54
49
  | { action: "none"; reason: "loop-not-active" }
55
50
  | { action: "expire"; reason: ExpiryReason }
56
51
  | { action: "skip"; reason: SkipReason }
57
- | { action: "stop"; reason: "goal-complete" | "max-iterations" | "max-automatic-turns" }
58
- | { action: "pause"; reason: "goal-safety"; cause: string }
59
- | { action: "pause"; reason: "goal-missing" }
60
- | {
61
- action: "poke";
62
- reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed";
63
- };
52
+ | { action: "stop"; reason: "max-iterations" | "max-automatic-turns" }
53
+ | { action: "poke"; reason: "objective-stalled" | "wait-elapsed" };
64
54
 
65
55
  export type ContinuationDecision =
66
- | { action: "none"; reason: "loop-not-active" | "goal-bound" }
56
+ | { action: "none"; reason: "loop-not-active" }
67
57
  | { action: "expire"; reason: ExpiryReason }
68
58
  | { action: "skip"; reason: SkipReason }
69
59
  | { action: "stop"; reason: "max-iterations" | "max-automatic-turns" }
70
60
  | { action: "continue"; reason: "settled-idle" };
71
61
 
72
- /** Shared prefix: everything that holds or ends a loop before mode matters. */
62
+ /** Shared prefix: everything that holds or ends a loop before caps matter. */
73
63
  function decideCommonPrefix(
74
64
  loop: LoopState,
75
65
  env: TickEnvironment,
76
66
  ): Extract<TickDecision, { action: "none" | "expire" | "skip" }> | undefined {
77
67
  if (loop.status !== "active") return { action: "none", reason: "loop-not-active" };
78
68
  if (env.now >= loop.expiresAt) {
79
- // The final wake is a standalone loop's own summarise-and-stop turn; a
80
- // goal-bound loop has no ledger of its own to write, and pi-goal owns
81
- // that conversation.
82
- return {
83
- action: "expire",
84
- reason:
85
- isStandaloneLoop(loop) && !loop.expiring ? "expiry-final-wake" : "loop-expired",
86
- };
69
+ // The final wake is the loop's own summarise-and-stop turn; a loop already
70
+ // spending it has nothing left to buy.
71
+ return { action: "expire", reason: loop.expiring ? "loop-expired" : "expiry-final-wake" };
87
72
  }
88
73
  if (env.planModeEnabled) return { action: "skip", reason: "plan-mode-active" };
89
74
  if (env.compacting) return { action: "skip", reason: "compaction-in-flight" };
@@ -111,16 +96,13 @@ function decideCaps(
111
96
  }
112
97
 
113
98
  /**
114
- * The settled-idle boundary of a standalone loop: the session finished a turn
115
- * with the objective unfinished, so the loop continues immediately instead of
116
- * waiting out an interval of idle wall time.
99
+ * The settled-idle boundary: the session finished a turn with the objective
100
+ * unfinished, so the loop continues immediately instead of waiting out an
101
+ * interval of idle wall time.
117
102
  */
118
103
  export function decideContinuation(loop: LoopState, env: TickEnvironment): ContinuationDecision {
119
104
  const prefix = decideCommonPrefix(loop, env);
120
105
  if (prefix) return prefix;
121
- // pi-goal drives its own settle continuations for a goal-bound loop; two
122
- // extensions continuing the same session would double every turn.
123
- if (!isStandaloneLoop(loop)) return { action: "none", reason: "goal-bound" };
124
106
  const capped = decideCaps(loop);
125
107
  if (capped) return capped;
126
108
  return { action: "continue", reason: "settled-idle" };
@@ -129,40 +111,10 @@ export function decideContinuation(loop: LoopState, env: TickEnvironment): Conti
129
111
  export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
130
112
  const prefix = decideCommonPrefix(loop, env);
131
113
  if (prefix) return prefix;
132
-
133
- // A standalone loop carries its own objective, so it never consults
134
- // pi-goal: it runs until loop_complete stops it, a cap is reached, or the
135
- // user intervenes.
136
- if (isStandaloneLoop(loop)) {
137
- const capped = decideCaps(loop);
138
- if (capped) return capped;
139
- // The prefix already let a still-waiting loop skip, so a wait surviving
140
- // to here is one whose deadline has come due: this wake is the wake it
141
- // asked for, and it counts against the wake cap like any other.
142
- return { action: "poke", reason: loop.waiting ? "wait-elapsed" : "objective-stalled" };
143
- }
144
-
145
- const goal = env.goal;
146
- // Goal-bound loops require a goal to operate: the goal evaluator owns the
147
- // stop criterion, so such a loop with no goal has nothing to decide when it
148
- // is done.
149
- if (!goal) return { action: "pause", reason: "goal-missing" };
150
- if (goal.status === "complete") return { action: "stop", reason: "goal-complete" };
151
- if ((GOAL_SAFETY_STATUSES as readonly string[]).includes(goal.status)) {
152
- return { action: "pause", reason: "goal-safety", cause: goal.status };
153
- }
154
- if (goal.status !== "active") {
155
- // Unknown status from a newer pi-goal: treat like a safety state
156
- // rather than poking past a guard we do not understand.
157
- return { action: "pause", reason: "goal-safety", cause: goal.status };
158
- }
159
-
160
114
  const capped = decideCaps(loop);
161
115
  if (capped) return capped;
162
-
163
- // An idle session with an active goal is exactly the stall/wait case:
164
- // pi-goal continues on its own at every idle boundary, so idleness at
165
- // tick time means its continuation was lost, or the goal is waiting on
166
- // an external event — which this wakeup is.
167
- return { action: "poke", reason: goal.waiting ? "goal-waiting" : "goal-stalled" };
116
+ // The prefix already let a still-waiting loop skip, so a wait surviving to
117
+ // here is one whose deadline has come due: this wake is the wake it asked
118
+ // for, and it counts against the wake cap like any other.
119
+ return { action: "poke", reason: loop.waiting ? "wait-elapsed" : "objective-stalled" };
168
120
  }
package/src/index.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  /**
2
- * pi-loop: Claude-Code-/loop-inspired pacemaker for Pi. A loop wakes the
3
- * session on an interval to keep an active pi-goal goal moving (stall rescue
4
- * and goal_wait wakes) with loop-aware compaction. Loops require an active
5
- * goal: the loop owns *when* the session wakes; @narumitw/pi-goal (or the
6
- * @hank-warren/pi-goal fork) owns *whether the work is done*, read through
7
- * its `goal-state` session entries only.
2
+ * pi-loop: Claude-Code-/loop-inspired long-running work for Pi. A loop
3
+ * carries its own objective and completion criteria, is paced by the session
4
+ * settling, keeps a durable ledger, compacts itself, and ends through
5
+ * `loop_complete`, a cap, its expiry, or the user. It depends on no other
6
+ * extension; the only sibling state it reads is pi-plan-mode's, fail-open, so
7
+ * a loop never injects into a planning conversation.
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
11
  import { completeLoopArguments, parseLoopCommand } from "./command.js";
12
12
  import { registerLoopCompleteTool } from "./complete-tool.js";
13
+ import { InlineInvocationState, registerInlineInvocation } from "./inline-invocation.js";
14
+ import { registerLoopStartTool } from "./start-tool.js";
13
15
  import { registerLoopWaitTool } from "./wait-tool.js";
14
16
  import { LoopController, type LoopControllerOptions } from "./loop.js";
15
17
  import { showLoopManager, showLoopSettings } from "./manager.js";
@@ -28,12 +30,19 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
28
30
  });
29
31
  // Registered unconditionally and never toggled with loop state: tools are
30
32
  // part of the cached request prefix, so mutating the tool set mid-session
31
- // would invalidate the whole conversation cache. It refuses when no
32
- // standalone loop is active.
33
+ // would invalidate the whole conversation cache. It refuses when no loop is
34
+ // active.
33
35
  registerLoopCompleteTool(pi, controller);
34
36
  // Registered on the same terms and for the same reason: the tool set is
35
37
  // part of the cached prefix, so it never changes with loop state.
36
38
  registerLoopWaitTool(pi, controller);
39
+ // Inline invocation: an `input` handler arms a one-turn system-prompt hint
40
+ // for a mid-prompt `/loop` token, `before_agent_start` appends it, and
41
+ // loop_start is the model-invoked start it points at — refused on any turn
42
+ // the hint did not arm. The user's message is never transformed.
43
+ const invocation = new InlineInvocationState();
44
+ registerInlineInvocation(pi, controller, invocation);
45
+ registerLoopStartTool(pi, controller, invocation);
37
46
  // Collapse loop pokes into one-line transcript chips (display-only; the
38
47
  // stored message and model context are untouched).
39
48
  registerLoopMessageRendering(pi);
@@ -84,7 +93,8 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
84
93
  return;
85
94
  }
86
95
  }
87
- controller.startLoop(ctx, command);
96
+ const result = controller.startLoop(ctx, command);
97
+ if (!result.ok) ctx.ui.notify(result.message, "error");
88
98
  return;
89
99
  }
90
100
  }
@@ -169,8 +179,8 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
169
179
  controller.onSessionShutdown();
170
180
  scheduler.onSessionShutdown();
171
181
  });
172
- // The pacemaker of a standalone loop: agent_end records the intent to
173
- // continue, agent_settled delivers it once Pi will accept a message.
182
+ // The pacemaker: agent_end records the intent to continue, agent_settled
183
+ // delivers it once Pi will accept a message.
174
184
  pi.on("agent_start", async (_event, ctx) => {
175
185
  controller.onAgentStart(ctx);
176
186
  });
@@ -181,10 +191,9 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
181
191
  controller.onAgentSettled(ctx);
182
192
  scheduler.onAgentSettled(ctx);
183
193
  });
184
- // A standalone loop carries its own objective, so it injects it the way
185
- // pi-goal does for a goal-bound one: a byte-stable system append, which is
186
- // what lets the poke messages stay pointer-sized. A goal-bound loop adds
187
- // nothing here — pi-goal already owns that turn's append.
194
+ // A loop carries its own objective and injects it as a byte-stable system
195
+ // append, which is what lets the poke and continuation messages stay
196
+ // pointer-sized.
188
197
  pi.on("before_agent_start", (event) => {
189
198
  const loop = controller.state;
190
199
  if (!loop || loop.status !== "active") return;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Inline slash-command detection.
3
+ *
4
+ * Pi dispatches extension commands only when a message *starts* with the
5
+ * command token; a command mentioned mid-prompt arrives as plain text. This
6
+ * helper finds inline occurrences of a `/cmd` token (and `cmd:` prefix lines)
7
+ * so the caller can react — here, by appending a reminder that tells the model
8
+ * to invoke the corresponding tool. It never rewrites or re-sends the user's
9
+ * message.
10
+ *
11
+ * Ported from @hank-warren/pi-goal, which proved the mechanism before pi-loop
12
+ * absorbed it; the helper was always generic over the command name.
13
+ */
14
+
15
+ export interface InlineCommandSplit {
16
+ /** Text before the command token, trailing whitespace removed. */
17
+ prose: string;
18
+ /** Everything after the command token to end of message, trimmed. */
19
+ commandArgs: string;
20
+ }
21
+
22
+ /**
23
+ * Find the first inline `/<commandName>` occurrence in `text`.
24
+ *
25
+ * Detection rules:
26
+ * - The token must be preceded by start-of-line or whitespace and followed by
27
+ * whitespace plus a non-empty remainder. A bare trailing `/cmd` mention or a
28
+ * path-like `foo/cmd` never matches.
29
+ * - A token at position 0 is ignored: that is Pi's native dispatch position.
30
+ * - Occurrences inside backtick code (inline spans and fenced blocks) or
31
+ * inside a single/double-quoted span are ignored. Code regions are
32
+ * approximated by the CommonMark backtick-run rule: a run of N backticks
33
+ * opens a region closed by the next run of exactly N backticks.
34
+ *
35
+ * Returns undefined when no qualifying occurrence exists.
36
+ */
37
+ export function extractInlineCommand(
38
+ text: string,
39
+ commandName: string,
40
+ ): InlineCommandSplit | undefined {
41
+ const token = `/${commandName}`;
42
+ const ignored = ignoredRegions(text);
43
+ let searchFrom = 0;
44
+ while (searchFrom < text.length) {
45
+ const index = text.indexOf(token, searchFrom);
46
+ if (index === -1) return undefined;
47
+ searchFrom = index + 1;
48
+ if (index === 0) continue;
49
+ const before = text[index - 1];
50
+ if (before !== undefined && !/\s/.test(before)) continue;
51
+ const afterToken = text[index + token.length];
52
+ if (afterToken === undefined || !/\s/.test(afterToken)) continue;
53
+ if (insideRegion(ignored, index)) continue;
54
+ const commandArgs = text.slice(index + token.length).trim();
55
+ if (!commandArgs) continue;
56
+ return {
57
+ prose: text.slice(0, index).trimEnd(),
58
+ commandArgs,
59
+ };
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ /**
65
+ * True when the message inline-invokes the command: a mid-message `/cmd`
66
+ * token with a remainder, or a `cmd:` prefix at the start of any line — both
67
+ * outside backtick code and quoted spans.
68
+ */
69
+ export function detectsInlineInvocation(text: string, commandName: string): boolean {
70
+ if (extractInlineCommand(text, commandName) !== undefined) return true;
71
+ const ignored = ignoredRegions(text);
72
+ const prefixPattern = new RegExp(`^[ \\t]*${escapeRegExpText(commandName)}:[ \\t]+\\S`, "gim");
73
+ for (
74
+ let match = prefixPattern.exec(text);
75
+ match;
76
+ match = prefixPattern.exec(text)
77
+ ) {
78
+ if (!insideRegion(ignored, match.index)) return true;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ type Region = readonly [start: number, end: number];
84
+
85
+ /**
86
+ * Backtick code regions plus single/double-quoted spans. Quoting a command is
87
+ * how people discuss one (`use "/loop 10m ship it" to start`), so a quoted
88
+ * token is a mention, not an invocation.
89
+ */
90
+ function ignoredRegions(text: string): Region[] {
91
+ return [...codeRegions(text), ...quoteRegions(text)];
92
+ }
93
+
94
+ function codeRegions(text: string): Region[] {
95
+ const regions: Region[] = [];
96
+ const runs: Array<{ index: number; length: number }> = [];
97
+ const runPattern = /`+/g;
98
+ for (let match = runPattern.exec(text); match; match = runPattern.exec(text)) {
99
+ runs.push({ index: match.index, length: match[0].length });
100
+ }
101
+ for (let open = 0; open < runs.length; open += 1) {
102
+ const opener = runs[open];
103
+ if (opener === undefined) continue;
104
+ for (let close = open + 1; close < runs.length; close += 1) {
105
+ const closer = runs[close];
106
+ if (closer === undefined || closer.length !== opener.length) continue;
107
+ regions.push([opener.index, closer.index + closer.length]);
108
+ open = close;
109
+ break;
110
+ }
111
+ }
112
+ return regions;
113
+ }
114
+
115
+ /**
116
+ * Single- and double-quoted spans, matched conservatively so an apostrophe
117
+ * inside a word (`don't`) never opens one: an opening quote follows nothing,
118
+ * whitespace, or an opening bracket and precedes a non-space; its closing
119
+ * quote is on the same line, follows a non-space, and precedes end-of-line,
120
+ * whitespace, or closing punctuation.
121
+ */
122
+ function quoteRegions(text: string): Region[] {
123
+ const regions: Region[] = [];
124
+ for (let index = 0; index < text.length; index += 1) {
125
+ const quote = text[index];
126
+ if (quote !== '"' && quote !== "'") continue;
127
+ const before = text[index - 1];
128
+ const after = text[index + 1];
129
+ if (before !== undefined && !/[\s([{<]/.test(before)) continue;
130
+ if (after === undefined || /\s/.test(after)) continue;
131
+ const close = closingQuoteIndex(text, index, quote);
132
+ if (close === undefined) continue;
133
+ regions.push([index, close + 1]);
134
+ index = close;
135
+ }
136
+ return regions;
137
+ }
138
+
139
+ function closingQuoteIndex(text: string, openIndex: number, quote: string): number | undefined {
140
+ for (let index = openIndex + 1; index < text.length; index += 1) {
141
+ const char = text[index];
142
+ if (char === "\n") return undefined;
143
+ if (char !== quote) continue;
144
+ const before = text[index - 1];
145
+ const after = text[index + 1];
146
+ if (before === undefined || /\s/.test(before)) continue;
147
+ if (after !== undefined && !/[\s.,;:!?)\]}>]/.test(after)) continue;
148
+ return index;
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ function insideRegion(regions: readonly Region[], index: number): boolean {
154
+ return regions.some(([start, end]) => index >= start && index < end);
155
+ }
156
+
157
+ function escapeRegExpText(value: string) {
158
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
+ }