@hank-warren/pi-loop 0.3.1 → 0.4.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,55 @@
1
1
  # @hank-warren/pi-loop
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e256c1f: loops can carry their own objective instead of requiring pi-goal
8
+
9
+ A loop used to refuse to start without an active pi-goal goal, because the goal
10
+ evaluator was the only stop criterion available to it. That made pi-loop
11
+ unusable on its own and turned a missing goal into a dead end.
12
+
13
+ A loop now has two modes, chosen when it starts:
14
+
15
+ - **Standalone** — `/loop 30m until all tests pass and CI is green`. The loop
16
+ carries its own objective and completion criteria, reads no goal state at
17
+ all, and ends when the model calls the new `loop_complete` tool, a cap is
18
+ reached, or you stop it.
19
+ - **Goal-bound** — unchanged. An active goal wins, trailing text stays a
20
+ per-wake focus, and pi-goal keeps owning completion, safety pauses, and
21
+ stopping the loop when the goal finishes.
22
+
23
+ Existing loops and commands behave exactly as before: with a goal active the
24
+ mode selection resolves to goal-bound, and persisted loop state without an
25
+ objective restores as goal-bound.
26
+
27
+ `loop_complete` is deliberately thin next to `goal_complete` — a `loop_id`
28
+ match so a stale turn cannot stop a newer loop, and no evidence-audit rules
29
+ block, because stopping a pacemaker asserts nothing about whether the wider
30
+ task is done. It is registered unconditionally rather than toggled with loop
31
+ state: tools are part of the cached request prefix, so mutating the tool set
32
+ mid-session would invalidate the conversation cache. With no standalone loop
33
+ active it refuses.
34
+
35
+ Standalone loops inject their objective under the same cache-stability contract
36
+ pi-goal uses: a byte-stable system append per loop, with only the wake number
37
+ and focus in the poke, so pokes stay pointer-sized in both modes.
38
+
39
+ ### Patch Changes
40
+
41
+ - c7f0029: make "Unlimited" a real choice in the max-iterations setting
42
+
43
+ `/loop` → Settings → Max iterations was a free-text box. Unlimited was
44
+ reachable only by knowing to type the word `unlimited`, while the compaction
45
+ row directly below it is a proper toggle. The value was accepted and saved
46
+ correctly, so this is an affordance fix, not a behaviour fix.
47
+
48
+ Picking the row now opens a choice — "Set a number…" or "Unlimited (no
49
+ iteration cap)" — with the current value in the title. The typed word still
50
+ works, so muscle memory and the `/loop --max unlimited` vocabulary are
51
+ unaffected.
52
+
3
53
  ## 0.3.1
4
54
 
5
55
  ### Patch Changes
package/README.md CHANGED
@@ -1,18 +1,26 @@
1
1
  # pi-loop — interval wakeups 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 an active [pi-goal](../pi-goal) goal moving, and keep long loops coherent across context compaction. pi-loop reads the fork's `goal-state` entries, so pair it with [`@hank-warren/pi-goal`](../pi-goal) rather than [upstream](https://github.com/narumiruna/pi-extensions/tree/main/packages/pi-goal).
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.
4
4
 
5
- pi-loop is a **pacemaker, not an evaluator**: it owns *when* the session wakes; pi-goal owns *whether the work is done*. **Loops require an active goal to operate** — the goal evaluator is the stop criterion, so a loop without a goal has nothing to decide when it is done. Coupling is read-only, fail-open reads of pi-goal's `goal-state` and pi-plan-mode's `plan-mode-state` session entries.
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:
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.
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.
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.
6
13
 
7
14
  ## Usage
8
15
 
9
16
  ```
10
- /goal get CI green # loops need an active goal first
11
- /loop 30m # poke the goal every 30 minutes if the session stalls
12
- /loop 10m recheck the pipeline # optional focus text added to every poke
13
- /loop # manager TUI (status, pause/resume, edit, settings, stop)
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
21
+ /loop # manager TUI (status, pause/resume, edit, settings, stop)
14
22
  /loop status | pause | resume | stop | settings
15
- /loop --max 20 --compact-at 60% 10m # per-loop overrides
23
+ /loop --max 20 --compact-at 60% 10m # per-loop overrides
16
24
  ```
17
25
 
18
26
  - **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).
@@ -25,11 +33,13 @@ Each tick evaluates, in order:
25
33
  1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days) — a forgotten loop is bounded.
26
34
  2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
27
35
  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.
28
- 4. **Goal state**: 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).
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).
29
37
  5. **Iteration cap** (default 25 delivered pokes, `--max`/settings, explicit `unlimited` opt-in): stop.
30
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.
31
39
 
32
- **A poke does not restate the objective**, and this is a deliberate cross-extension assumption. Loops require an active goal, and pi-goal appends the objective, `goal_id`, and goal-mode rules to the system prompt of *every* goal turn — including poke turns, because pokes are delivered as ordinary user messages that go through `before_agent_start`. Restating the goal in the poke would store a duplicate copy in the conversation on every wake for no benefit. 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). If pokes are ever delivered by a path that bypasses `before_agent_start` (for example `pi.sendMessage({triggerTurn})`, which calls the agent directly), this assumption breaks and the poke must carry the objective again.
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).
33
43
 
34
44
  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.
35
45
 
@@ -37,6 +47,15 @@ Expiry, completion, and pi-goal's safety states are also evaluated whenever the
37
47
 
38
48
  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.
39
49
 
50
+ ## Standalone loops: objective, injection, and `loop_complete`
51
+
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:
53
+
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.
56
+
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.
58
+
40
59
  ## Loop-aware compaction
41
60
 
42
61
  Long loops die by context exhaustion, not by failing. pi-loop owns the compaction path:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-loop",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
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.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -39,6 +39,7 @@
39
39
  ],
40
40
  "peerDependencies": {
41
41
  "@earendil-works/pi-coding-agent": "*",
42
- "@earendil-works/pi-tui": "*"
42
+ "@earendil-works/pi-tui": "*",
43
+ "typebox": "*"
43
44
  }
44
45
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `loop_complete`: the standalone loop's terminal tool.
3
+ *
4
+ * Deliberately thin. pi-goal's `goal_complete` carries a long evidence-audit
5
+ * rules block and a stale-turn guard because a premature goal completion
6
+ * abandons autonomous work and asserts the task is done. Stopping a loop
7
+ * early only stops the pacemaker — the user restarts it — so the same
8
+ * hardening would be duplicated cost for a much smaller blast radius. The one
9
+ * guard kept is the loop_id match, which is cheap and prevents a stale turn
10
+ * from stopping a newer loop.
11
+ *
12
+ * Registered unconditionally, never added or removed with loop state: tools
13
+ * are part of the cached request prefix, so mutating the tool set mid-session
14
+ * invalidates the whole conversation cache (the same constraint that shapes
15
+ * the objective injection). It refuses instead when no standalone loop is
16
+ * active.
17
+ */
18
+
19
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { Type } from "typebox";
21
+ import type { LoopController } from "./loop.js";
22
+
23
+ export const LOOP_COMPLETE_TOOL = "loop_complete";
24
+
25
+ const MAX_SUMMARY_LENGTH = 4_000;
26
+
27
+ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopController) {
28
+ pi.registerTool(
29
+ defineTool({
30
+ name: LOOP_COMPLETE_TOOL,
31
+ label: "Loop Complete",
32
+ description:
33
+ "Stop the active /loop when its objective's completion criteria are demonstrably met. Only for a standalone loop that carries its own objective; it stops the scheduled wakeups and does not assert that unrelated work is finished.",
34
+ promptSnippet: "Stop the active standalone /loop once its completion criteria are met",
35
+ promptGuidelines: [
36
+ "Call loop_complete only when the completion criteria stated in the loop objective are demonstrably met, verified against authoritative current state.",
37
+ "Pass the exact loop_id from the active /loop objective in the system prompt. A mismatched id means the loop changed and the call is refused.",
38
+ "loop_complete stops scheduled wakeups only. It does not mean an unrelated goal or task is complete.",
39
+ "If the criteria are not met, do not call it: keep working and expect another scheduled wake.",
40
+ ],
41
+ parameters: Type.Object({
42
+ loop_id: Type.String({
43
+ minLength: 1,
44
+ maxLength: 200,
45
+ description: "The exact loop_id from the active /loop objective in the system prompt.",
46
+ }),
47
+ summary: Type.Optional(
48
+ Type.String({
49
+ maxLength: MAX_SUMMARY_LENGTH,
50
+ description: "Brief note on how the completion criteria were met.",
51
+ }),
52
+ ),
53
+ }),
54
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
55
+ const loop = controller.state;
56
+ if (!loop || loop.objective === undefined) {
57
+ return {
58
+ content: toolContent(
59
+ "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.",
60
+ ),
61
+ details: { loopId: params.loop_id },
62
+ isError: true,
63
+ };
64
+ }
65
+ if (loop.status !== "active") {
66
+ return {
67
+ content: toolContent(`The /loop is already ${loop.status}; nothing to complete.`),
68
+ details: { loopId: loop.id, status: loop.status },
69
+ isError: true,
70
+ };
71
+ }
72
+ if (params.loop_id !== loop.id) {
73
+ return {
74
+ content: toolContent(
75
+ "loop_id does not match the active /loop. Use the exact loop_id from the active /loop objective in the system prompt.",
76
+ ),
77
+ details: { loopId: loop.id },
78
+ isError: true,
79
+ };
80
+ }
81
+ const summary = params.summary?.trim();
82
+ controller.completeLoop(summary);
83
+ return {
84
+ content: toolContent(
85
+ `Loop stopped: completion criteria met.${summary ? ` ${summary}` : ""}`,
86
+ ),
87
+ details: { loopId: loop.id, ...(summary ? { summary } : {}) },
88
+ };
89
+ },
90
+ }),
91
+ );
92
+ }
93
+
94
+ function toolContent(text: string) {
95
+ return [{ type: "text" as const, text }];
96
+ }
package/src/decide.ts CHANGED
@@ -3,12 +3,18 @@
3
3
  * decides what a wakeup does, so the full decision matrix is unit-testable
4
4
  * without timers or a Pi runtime.
5
5
  *
6
- * Precedence: loop liveness → expiry → plan mode → busy → goal presence and
7
- * state (loops require an active pi-goal goal to operate) → iteration cap →
8
- * poke.
6
+ * Precedence: loop liveness → expiry → plan mode → busy → mode-specific stop
7
+ * criteria → iteration cap → poke.
8
+ *
9
+ * The mode-specific step is the whole difference between the two kinds of
10
+ * loop. A goal-bound loop delegates "is the work done" to pi-goal and reads
11
+ * its `goal-state`, so a missing goal pauses it and a safety state holds it.
12
+ * A standalone loop owns its own objective, reads no goal state at all, and
13
+ * ends only through `loop_complete`, a cap, or the user — so pi-goal being
14
+ * absent is not an error for it.
9
15
  */
10
16
 
11
- import { GOAL_SAFETY_STATUSES, type GoalSnapshot, type LoopState } from "./state.js";
17
+ import { GOAL_SAFETY_STATUSES, type GoalSnapshot, isStandaloneLoop, type LoopState } from "./state.js";
12
18
 
13
19
  export interface TickEnvironment {
14
20
  now: number;
@@ -27,7 +33,7 @@ export type TickDecision =
27
33
  | { action: "stop"; reason: "goal-complete" | "max-iterations" }
28
34
  | { action: "pause"; reason: "goal-safety"; cause: string }
29
35
  | { action: "pause"; reason: "goal-missing" }
30
- | { action: "poke"; reason: "goal-stalled" | "goal-waiting" };
36
+ | { action: "poke"; reason: "goal-stalled" | "goal-waiting" | "objective-stalled" };
31
37
 
32
38
  export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
33
39
  if (loop.status !== "active") return { action: "none", reason: "loop-not-active" };
@@ -36,9 +42,20 @@ export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision
36
42
  if (env.compacting) return { action: "skip", reason: "compaction-in-flight" };
37
43
  if (env.busy) return { action: "skip", reason: "agent-busy" };
38
44
 
45
+ // A standalone loop carries its own objective, so it never consults
46
+ // pi-goal: it runs until loop_complete stops it, a cap is reached, or the
47
+ // user intervenes.
48
+ if (isStandaloneLoop(loop)) {
49
+ if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
50
+ return { action: "stop", reason: "max-iterations" };
51
+ }
52
+ return { action: "poke", reason: "objective-stalled" };
53
+ }
54
+
39
55
  const goal = env.goal;
40
- // Loops require a goal to operate: the goal evaluator owns the stop
41
- // criterion, so a loop with no goal has nothing to decide when it is done.
56
+ // Goal-bound loops require a goal to operate: the goal evaluator owns the
57
+ // stop criterion, so such a loop with no goal has nothing to decide when it
58
+ // is done.
42
59
  if (!goal) return { action: "pause", reason: "goal-missing" };
43
60
  if (goal.status === "complete") return { action: "stop", reason: "goal-complete" };
44
61
  if ((GOAL_SAFETY_STATUSES as readonly string[]).includes(goal.status)) {
package/src/index.ts CHANGED
@@ -9,12 +9,19 @@
9
9
 
10
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
11
  import { completeLoopArguments, parseLoopCommand } from "./command.js";
12
+ import { registerLoopCompleteTool } from "./complete-tool.js";
12
13
  import { LoopController, type LoopControllerOptions } from "./loop.js";
13
14
  import { showLoopManager, showLoopSettings } from "./manager.js";
15
+ import { buildLoopObjectivePrompt } from "./objective.js";
14
16
  import { registerLoopMessageRendering } from "./render.js";
15
17
 
16
18
  export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
17
19
  const controller = new LoopController(pi, options);
20
+ // Registered unconditionally and never toggled with loop state: tools are
21
+ // part of the cached request prefix, so mutating the tool set mid-session
22
+ // would invalidate the whole conversation cache. It refuses when no
23
+ // standalone loop is active.
24
+ registerLoopCompleteTool(pi, controller);
18
25
  // Collapse loop pokes into one-line transcript chips (display-only; the
19
26
  // stored message and model context are untouched).
20
27
  registerLoopMessageRendering(pi);
@@ -81,4 +88,15 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
81
88
  pi.on("agent_settled", async (_event, ctx) => {
82
89
  controller.onAgentSettled(ctx);
83
90
  });
91
+ // A standalone loop carries its own objective, so it injects it the way
92
+ // pi-goal does for a goal-bound one: a byte-stable system append, which is
93
+ // what lets the poke messages stay pointer-sized. A goal-bound loop adds
94
+ // nothing here — pi-goal already owns that turn's append.
95
+ pi.on("before_agent_start", (event) => {
96
+ const loop = controller.state;
97
+ if (!loop || loop.status !== "active") return;
98
+ const objectivePrompt = buildLoopObjectivePrompt(loop);
99
+ if (objectivePrompt === undefined) return;
100
+ return { systemPrompt: `${event.systemPrompt}\n\n${objectivePrompt}` };
101
+ });
84
102
  }
package/src/loop.ts CHANGED
@@ -29,7 +29,7 @@ import type {
29
29
  import type { LoopStartArguments } from "./command.js";
30
30
  import { decideTick, type TickDecision, type TickEnvironment } from "./decide.js";
31
31
  import { formatClock, formatDuration, parseDuration } from "./interval.js";
32
- import { buildCompactionInstructions, buildGoalPoke } from "./messages.js";
32
+ import { buildCompactionInstructions, buildGoalPoke, buildObjectivePoke } from "./messages.js";
33
33
  import {
34
34
  DEFAULT_LOOP_SETTINGS,
35
35
  type LoopSettings,
@@ -37,6 +37,7 @@ import {
37
37
  readLoopSettings,
38
38
  } from "./settings.js";
39
39
  import {
40
+ isStandaloneLoop,
40
41
  LOOP_STATE_ENTRY_TYPE,
41
42
  type LoopState,
42
43
  readGoalSnapshot,
@@ -231,11 +232,19 @@ export class LoopController {
231
232
  * maxIterations cap on a poke that never arrived; on a throw the loop re-arms
232
233
  * on the same cadence and retries at the next wake.
233
234
  */
234
- private deliverPoke(env: TickEnvironment, reason: "goal-stalled" | "goal-waiting"): void {
235
+ private deliverPoke(
236
+ env: TickEnvironment,
237
+ reason: "goal-stalled" | "goal-waiting" | "objective-stalled",
238
+ ): void {
235
239
  const loop = this.state;
236
- if (!loop || !env.goal) return;
240
+ if (!loop) return;
241
+ // A goal-bound poke restates nothing, so it is only meaningful while the
242
+ // goal it points at is readable; a standalone poke needs no goal at all.
243
+ if (reason !== "objective-stalled" && !env.goal) return;
237
244
  try {
238
- this.pi.sendUserMessage(buildGoalPoke(loop, reason));
245
+ this.pi.sendUserMessage(
246
+ reason === "objective-stalled" ? buildObjectivePoke(loop) : buildGoalPoke(loop, reason),
247
+ );
239
248
  } catch (error) {
240
249
  this.sessionCtx?.ui.notify(
241
250
  `pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
@@ -356,8 +365,16 @@ export class LoopController {
356
365
  `Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
357
366
  `Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
358
367
  ];
368
+ if (loop.objective) {
369
+ lines.push("Mode: standalone (this loop owns its completion criteria)");
370
+ lines.push(`Objective: ${loop.objective}`);
371
+ } else {
372
+ lines.push("Mode: goal-bound (pi-goal owns completion)");
373
+ }
359
374
  if (loop.prompt) lines.push(`Focus: ${loop.prompt}`);
360
- const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
375
+ const goal = isStandaloneLoop(loop)
376
+ ? undefined
377
+ : readGoalSnapshot(ctx.sessionManager.getBranch());
361
378
  if (goal) lines.push(`Goal (pi-goal): ${goal.status} — ${goal.text}`);
362
379
  if (this.nextWakeAt && loop.status === "active") {
363
380
  lines.push(`Next wake: ${formatClock(this.nextWakeAt)}`);
@@ -372,13 +389,24 @@ export class LoopController {
372
389
 
373
390
  // --- command actions ---
374
391
 
392
+ /**
393
+ * Mode selection, and the only place it happens.
394
+ *
395
+ * An active pi-goal goal wins: a bare or focused `/loop` alongside a goal
396
+ * behaves exactly as it always has, and the trailing text stays a per-wake
397
+ * focus. With no active goal the trailing text becomes this loop's own
398
+ * objective and the loop is standalone. With neither, there is nothing to
399
+ * work on, and the caller is told what to supply.
400
+ */
375
401
  startLoop(ctx: ExtensionCommandContext, start: LoopStartArguments): void {
376
402
  this.sessionCtx = ctx;
377
403
  const now = this.now();
378
404
  const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
379
- if (goal?.status !== "active") {
405
+ const goalBound = goal?.status === "active";
406
+ const objective = goalBound ? undefined : start.prompt?.trim();
407
+ if (!goalBound && !objective) {
380
408
  ctx.ui.notify(
381
- "Loops require an active goal to operate. Start one first: /goal <objective>, then /loop <interval> [focus].",
409
+ "A loop needs something to work on. Either give it an objective — /loop <interval> <objective with completion criteria> — or start a goal first with /goal <objective> and run /loop <interval> to bind to it.",
382
410
  "error",
383
411
  );
384
412
  return;
@@ -393,7 +421,10 @@ export class LoopController {
393
421
  this.state = {
394
422
  id: randomUUID().slice(0, 8),
395
423
  status: "active",
396
- ...(start.prompt ? { prompt: start.prompt } : {}),
424
+ // The same trailing text is a per-wake focus for a goal-bound loop and
425
+ // the authoritative objective for a standalone one; never both.
426
+ ...(goalBound && start.prompt ? { prompt: start.prompt } : {}),
427
+ ...(objective ? { objective } : {}),
397
428
  intervalMs: start.intervalMs,
398
429
  maxIterations:
399
430
  start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
@@ -410,7 +441,9 @@ export class LoopController {
410
441
  ? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
411
442
  : "";
412
443
  ctx.ui.notify(
413
- `Loop started: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, poking the active goal${start.prompt ? " with the loop focus" : ""}. Stop with /loop stop.`,
444
+ goalBound
445
+ ? `Loop started: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, poking the active goal${start.prompt ? " with the loop focus" : ""}. Stop with /loop stop.`
446
+ : `Loop started: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, working its own objective until the criteria are met (loop_complete), the cap is reached, or you run /loop stop.`,
414
447
  "info",
415
448
  );
416
449
  }
@@ -436,11 +469,15 @@ export class LoopController {
436
469
  this.transition("stopped", "loop expired (maxLoopDuration reached)");
437
470
  return;
438
471
  }
439
- // Same guard as startLoop: resuming into a finished or missing goal would
440
- // only stop or pause again at the first tick.
441
- if (readGoalSnapshot(ctx.sessionManager.getBranch())?.status !== "active") {
472
+ // Same guard as startLoop, and for the same reason: resuming a goal-bound
473
+ // loop into a finished or missing goal would only stop or pause again at
474
+ // the first tick. A standalone loop owns its objective and needs no goal.
475
+ if (
476
+ !isStandaloneLoop(loop) &&
477
+ readGoalSnapshot(ctx.sessionManager.getBranch())?.status !== "active"
478
+ ) {
442
479
  ctx.ui.notify(
443
- "Loops require an active goal to operate. Start one first: /goal <objective>, then /loop resume.",
480
+ "This loop is bound to a goal, which is no longer active. Start one with /goal <objective>, then /loop resume.",
444
481
  "error",
445
482
  );
446
483
  return;
@@ -463,6 +500,12 @@ export class LoopController {
463
500
  this.updateWidget();
464
501
  }
465
502
 
503
+ /** Terminal transition owned by the loop_complete tool. */
504
+ completeLoop(summary?: string): void {
505
+ if (!this.state || this.state.status === "stopped") return;
506
+ this.transition("stopped", `completion criteria met${summary ? ` — ${summary}` : ""}`);
507
+ }
508
+
466
509
  stopLoop(ctx: ExtensionContext, why = "stopped by user"): void {
467
510
  this.sessionCtx = ctx;
468
511
  if (!this.state || this.state.status === "stopped") {
package/src/manager.ts CHANGED
@@ -79,17 +79,26 @@ async function startFromMenu(
79
79
  ctx.ui.notify(`Invalid interval: ${intervalText}. Use <number><unit>, e.g. 5m.`, "error");
80
80
  return;
81
81
  }
82
+ // With an active goal the loop binds to it and the text is an optional
83
+ // per-wake focus. Without one the loop owns its objective, so the text is
84
+ // required — asking for it here is what replaces the old dead-end refusal.
82
85
  const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
83
- if (goal?.status !== "active") {
86
+ const goalBound = goal?.status === "active";
87
+ const promptText = await ctx.ui.input(
88
+ goalBound
89
+ ? "Loop focus (optional, added to every goal poke)"
90
+ : "Objective, including how the loop knows it is done",
91
+ goalBound ? undefined : "e.g. get CI green on main, verified by a passing run",
92
+ );
93
+ if (promptText === undefined) return;
94
+ const prompt = promptText.trim();
95
+ if (!goalBound && !prompt) {
84
96
  ctx.ui.notify(
85
- "Loops require an active goal to operate. Start one first: /goal <objective>.",
86
- "error",
97
+ "A loop with no active goal needs its own objective, so no loop was started.",
98
+ "warning",
87
99
  );
88
100
  return;
89
101
  }
90
- const promptText = await ctx.ui.input("Loop focus (optional, added to every goal poke)");
91
- if (promptText === undefined) return;
92
- const prompt = promptText.trim();
93
102
  controller.startLoop(ctx, {
94
103
  kind: "start",
95
104
  requestedMs: interval.requestedMs,
@@ -159,20 +168,34 @@ export async function showLoopSettings(
159
168
  const index = items.indexOf(choice);
160
169
  const next = structuredClone(s);
161
170
  if (index === 0) {
162
- const value = await ctx.ui.input(
163
- "Max iterations (positive number, or unlimited)",
164
- s.maxIterations === null ? "unlimited" : `${s.maxIterations}`,
171
+ // Unlimited is a first-class choice, not a magic word typed into a free
172
+ // text box: it is only reachable by discovery otherwise.
173
+ const SET_NUMBER = "Set a number…";
174
+ const UNLIMITED = "Unlimited (no iteration cap)";
175
+ const capChoice = await ctx.ui.select(
176
+ `Max iterations · currently ${s.maxIterations === null ? "Unlimited" : s.maxIterations}`,
177
+ [SET_NUMBER, UNLIMITED],
165
178
  );
166
- if (value === undefined) continue;
167
- const trimmed = value.trim();
168
- if (trimmed === "unlimited") next.maxIterations = null;
179
+ if (capChoice === undefined) continue;
180
+ if (capChoice === UNLIMITED) next.maxIterations = null;
169
181
  else {
170
- const parsed = Number(trimmed);
171
- if (!Number.isSafeInteger(parsed) || parsed <= 0) {
172
- ctx.ui.notify(`Invalid value: ${value}.`, "error");
173
- continue;
182
+ const value = await ctx.ui.input(
183
+ "Max iterations (positive whole number)",
184
+ s.maxIterations === null ? "25" : `${s.maxIterations}`,
185
+ );
186
+ if (value === undefined) continue;
187
+ const trimmed = value.trim();
188
+ // Keep honouring the typed word so muscle memory and the /loop --max
189
+ // vocabulary still work.
190
+ if (trimmed === "unlimited") next.maxIterations = null;
191
+ else {
192
+ const parsed = Number(trimmed);
193
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
194
+ ctx.ui.notify(`Invalid value: ${value}.`, "error");
195
+ continue;
196
+ }
197
+ next.maxIterations = parsed;
174
198
  }
175
- next.maxIterations = parsed;
176
199
  }
177
200
  } else if (index === 1) {
178
201
  const value = await ctx.ui.input("Max loop duration (e.g. 7d)", s.maxLoopDuration);
package/src/messages.ts CHANGED
@@ -35,6 +35,21 @@ export function buildGoalPoke(loop: LoopState, reason: "goal-stalled" | "goal-wa
35
35
  return appendPokeMarker(lines.join("\n"), loop.id, loop.iteration + 1);
36
36
  }
37
37
 
38
+ /**
39
+ * The standalone poke. Slim for the same reason the goal-bound one is: this
40
+ * loop's own objective injection puts the objective and loop-mode rules in
41
+ * the system prompt of every turn, so restating them here would store a
42
+ * duplicate copy on every wake.
43
+ */
44
+ export function buildObjectivePoke(loop: LoopState): string {
45
+ const lines = [
46
+ `Scheduled loop wakeup ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}).`,
47
+ "The session went idle but the loop objective's completion criteria are not met. Continue working it — the objective and loop-mode rules are in the system prompt.",
48
+ ];
49
+ if (loop.prompt) lines.push("", `Loop focus: ${loop.prompt}`);
50
+ return appendPokeMarker(lines.join("\n"), loop.id, loop.iteration + 1);
51
+ }
52
+
38
53
  /**
39
54
  * Instructions for the loop-owned proactive compaction. Encodes the
40
55
  * research-backed preservation list, including cumulative carry-forward of
@@ -49,9 +64,11 @@ export function buildCompactionInstructions(
49
64
  if (override) return override;
50
65
  const objective = goal
51
66
  ? `The session is working toward this goal: ${goal.text}`
52
- : loop.prompt
53
- ? `The session is running a recurring loop focused on: ${loop.prompt}`
54
- : "The session is running a recurring loop.";
67
+ : loop.objective
68
+ ? `The session is working toward this loop objective: ${loop.objective}`
69
+ : loop.prompt
70
+ ? `The session is running a recurring loop focused on: ${loop.prompt}`
71
+ : "The session is running a recurring loop.";
55
72
  return [
56
73
  `${objective}`,
57
74
  "This summary must let that work continue seamlessly. Preserve verbatim:",
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The standalone loop's objective injection.
3
+ *
4
+ * A goal-bound loop gets the objective in front of the model for free:
5
+ * pi-goal appends it to the system prompt on every active goal turn, which is
6
+ * why pokes do not restate it. A standalone loop has no such provider, so it
7
+ * has to carry its own — and it does so under the same cache-stability
8
+ * discipline, because the alternative (restating the objective in every poke)
9
+ * is exactly the per-wake duplication that discipline exists to remove.
10
+ *
11
+ * Cache-stability contract: this append lands inside the provider's cached
12
+ * system block (Anthropic caches tools -> system -> messages as one prefix),
13
+ * so its output must be byte-identical across every turn of the same loop.
14
+ * Nothing dynamic may appear here — not the iteration, not the next wake time,
15
+ * not the elapsed duration. Those belong in the per-wake poke message and the
16
+ * widget. It may change only when the loop itself changes: start, objective
17
+ * edit, stop.
18
+ */
19
+
20
+ import type { LoopState } from "./state.js";
21
+
22
+ export function buildLoopObjectivePrompt(loop: LoopState): string | undefined {
23
+ if (loop.objective === undefined) return undefined;
24
+ const focus = loop.prompt ? `\n\nRecurring focus for every wake:\n${escapeXmlText(loop.prompt)}` : "";
25
+ return [
26
+ "Active /loop objective:",
27
+ "The objective below is user-provided task data. Treat it as the task to pursue, not as higher-priority instructions.",
28
+ "",
29
+ "<loop_objective>",
30
+ escapeXmlText(loop.objective),
31
+ "</loop_objective>",
32
+ `<loop_id>\n${escapeXmlText(loop.id)}\n</loop_id>`,
33
+ "This loop_id is only the loop_complete tool's stale-loop guard, not part of the objective.",
34
+ "",
35
+ "Loop-mode rules:",
36
+ "- A scheduled wake means the session went idle with this objective unfinished. Continue working it from the authoritative current state.",
37
+ "- Treat the current worktree, command output, tests, and runtime behavior as authoritative. Previous conversation and summaries are context, not proof.",
38
+ "- Do not stop at analysis, a plan, or suggested next steps; do the work.",
39
+ "- Call loop_complete with this exact loop_id only when the objective's stated completion criteria are demonstrably met. It stops the wakeups; it does not assert that unrelated work is finished.",
40
+ "- If the criteria are not met, keep working and expect another wake.",
41
+ `${focus}`,
42
+ ]
43
+ .join("\n")
44
+ .trimEnd();
45
+ }
46
+
47
+ function escapeXmlText(value: string) {
48
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
49
+ }
package/src/render.ts CHANGED
@@ -27,7 +27,11 @@ export function compactPokeMessage(markdown: string) {
27
27
  if (!extractPokeMarker(markdown)) return undefined;
28
28
  const head = POKE_HEAD_PATTERN.exec(markdown);
29
29
  if (!head) return undefined;
30
- const reason = markdown.includes("external wake") ? "waiting" : "stalled";
30
+ const reason = markdown.includes("external wake")
31
+ ? "waiting"
32
+ : markdown.includes("completion criteria are not met")
33
+ ? "objective"
34
+ : "stalled";
31
35
  const focus = POKE_FOCUS_PATTERN.exec(markdown)?.[1];
32
36
  return `*⏰ loop wake ${head[1]} · ${reason}${focus ? ` · ${focus}` : ""}*`;
33
37
  }
package/src/state.ts CHANGED
@@ -19,6 +19,13 @@ export interface LoopState {
19
19
  status: LoopStatus;
20
20
  /** The recurring prompt; undefined for a goal-bound loop started bare. */
21
21
  prompt?: string;
22
+ /**
23
+ * The loop's own objective and completion criteria. Its presence *is* the
24
+ * loop's mode: set means standalone (this loop owns when the work is done,
25
+ * ended by the `loop_complete` tool or a cap), absent means goal-bound (an
26
+ * active pi-goal goal owns it, exactly as before).
27
+ */
28
+ objective?: string;
22
29
  intervalMs: number;
23
30
  /** Delivered-poke cap; null means unlimited. */
24
31
  maxIterations: number | null;
@@ -47,6 +54,12 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
47
54
  prompt = record.prompt.trim();
48
55
  if (!prompt || prompt.length > MAX_PROMPT_LENGTH) return undefined;
49
56
  }
57
+ let objective: string | undefined;
58
+ if (record.objective !== undefined) {
59
+ if (typeof record.objective !== "string") return undefined;
60
+ objective = record.objective.trim();
61
+ if (!objective || objective.length > MAX_PROMPT_LENGTH) return undefined;
62
+ }
50
63
  const intervalMs = record.intervalMs;
51
64
  if (!isPositiveSafeInteger(intervalMs)) return undefined;
52
65
  const maxIterations = record.maxIterations;
@@ -68,6 +81,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
68
81
  id,
69
82
  status: status as LoopStatus,
70
83
  ...(prompt === undefined ? {} : { prompt }),
84
+ ...(objective === undefined ? {} : { objective }),
71
85
  intervalMs,
72
86
  maxIterations: maxIterations as number | null,
73
87
  compactAt: compactAt as number | null,
@@ -108,6 +122,14 @@ export function restoreLoopState(entries: unknown[]): LoopState | undefined {
108
122
  return normalizeLoopState(record.loop);
109
123
  }
110
124
 
125
+ /**
126
+ * A standalone loop owns its own completion criteria; a goal-bound loop
127
+ * delegates that to pi-goal. Presence of `objective` is the discriminator.
128
+ */
129
+ export function isStandaloneLoop(loop: LoopState): boolean {
130
+ return loop.objective !== undefined;
131
+ }
132
+
111
133
  export const GOAL_SAFETY_STATUSES = ["paused", "blocked", "usage_limited", "budget_limited"] as const;
112
134
 
113
135
  /** How many `goal-state` entries a clear may be scanned back through. */