@hank-warren/pi-loop 0.2.1 → 0.3.1

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,84 @@
1
1
  # @hank-warren/pi-loop
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 910f77a: stop invalidating the prompt cache, and stop re-sending the goal block every turn
8
+
9
+ Goal mode has to keep the objective and its rules in front of the model on every
10
+ turn. It did that twice: a full block appended to the system prompt with the
11
+ running budget counter embedded in it, and a near-complete copy re-sent as a
12
+ stored user message on every automatic continuation. pi-loop restated the
13
+ objective a third time in every poke.
14
+
15
+ Anthropic caches `tools → system → messages` as one prefix, so the moving budget
16
+ counter invalidated the system _and_ conversation cache on every turn of a
17
+ budgeted goal. Verified against the installed pi-ai Anthropic adapter
18
+ (`buildParams` places `cache_control` on the system block and the last user
19
+ message, and the `before_agent_start` append lands inside that cached block).
20
+
21
+ **Static per goal.** The system append is now byte-identical across turns of the
22
+ same goal and states only the budget _total_, so it changes only when the goal
23
+ does — start, edit, clear — which is one accepted cache rewrite per boundary.
24
+ Pinned as the cache-stability contract: same goal, different tokens used,
25
+ iteration, and elapsed time produce identical output.
26
+
27
+ **Dynamic per wake.** Kickoff, continuation, and poke messages shrink to
28
+ pointer-sized triggers carrying the continuation number, budget usage, and wake
29
+ reason, and point at the system prompt for the rest. Measured at ~4 chars/token:
30
+ kickoff 805 → 53, continuation 840 → 54, poke 86 → 64, system append 807 → 820
31
+ (+13 once per goal, the price of stability). Stored conversation tokens for a
32
+ 25-iteration goal: 20,965 → 1,349. The resume, edit, and waiting-resume prompts
33
+ keep the full block — they are rare, user-initiated, and already a cache
34
+ boundary.
35
+
36
+ **Legibility.** Those messages now collapse to one-line transcript chips
37
+ (`⟳ goal continuation #4 · budget 12k/100k`, `⏰ loop wake 4/25 · stalled`) via
38
+ markdown transformers, which are display-only by Pi's contract: the stored
39
+ message and the model's context are untouched. New goal and loop widgets above
40
+ the editor carry the counters that left the system prompt — objective, budget
41
+ fraction, iteration, automatic turns for the goal; interval, iteration/cap, next
42
+ wake, and focus for the loop.
43
+
44
+ **Cross-extension assumption.** pi-loop's poke no longer restates the objective,
45
+ because loops require an active goal and pokes are ordinary user messages that
46
+ go through `before_agent_start`, so every poke turn already carries pi-goal's
47
+ system append. Documented in both READMEs; if pokes are ever delivered by a path
48
+ that bypasses that hook, the poke must carry the objective again.
49
+
50
+ ## 0.3.0
51
+
52
+ ### Minor Changes
53
+
54
+ - 3e94f24: drop the post-compaction continuation and harden the loop engine
55
+
56
+ **Removed:** the `postCompactContinuation` setting, the message it sent, and the
57
+ `session_compact` handler behind it. pi-goal already re-prompts the session after
58
+ a compaction for an active goal, and a loop requires an active goal, so pi-loop's
59
+ follow-up only ever doubled the queued messages and the tokens they cost. pi-goal
60
+ owns that message; pi-loop owns the compaction trigger and its instructions.
61
+ Threshold compaction is unchanged. An existing `pi-loop.json` carrying the removed
62
+ key keeps working — it is ignored, never rejected — but the settings row and the
63
+ session surface are gone, hence the minor bump.
64
+
65
+ **Terminal decisions at settle.** A settled boundary with no pending wake now
66
+ evaluates expiry, completion, and pi-goal's safety states, so a loop stops the
67
+ moment its goal does instead of up to one interval later. Poke and skip decisions
68
+ are ignored there: only the timer pokes.
69
+
70
+ **Delivery and compaction no longer lose or wedge state.** A poke is sent before
71
+ it is accounted for, so a refused delivery re-arms on the same cadence instead of
72
+ burning an iteration on a message that never arrived. A synchronous throw from
73
+ `ctx.compact` resets the in-flight flag rather than leaving every later tick
74
+ skipping as compaction-in-flight, and a failed compaction releases a held wake
75
+ just as a successful one does.
76
+
77
+ **Reading pi-goal through its completion clear** is now a bounded backward scan
78
+ over consecutive clears, so a completion still stops the loop when other entries
79
+ land on top of it. `resumeLoop` refuses a loop whose goal is gone, matching
80
+ `startLoop`. Compaction instructions receive the goal only while it is active.
81
+
3
82
  ## 0.2.1
4
83
 
5
84
  ### Patch Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
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](https://github.com/narumiruna/pi-extensions/tree/main/packages/pi-goal) goal moving, and keep long loops coherent across context compaction.
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).
4
4
 
5
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.
6
6
 
@@ -25,18 +25,24 @@ Each tick evaluates, in order:
25
25
  1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days) — a forgotten loop is bounded.
26
26
  2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
27
27
  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; 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).
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).
29
29
  5. **Iteration cap** (default 25 delivered pokes, `--max`/settings, explicit `unlimited` opt-in): stop.
30
- 6. **Poke**: a goal wake message restating the goal (plus the loop focus, when set). Every loop-injected message carries a provenance marker (`<!-- pi-loop-poke:<id>:<n> -->`) so wakeups are distinguishable from user prompts and stale wakes are dropped.
30
+ 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
31
 
32
- The footer widget shows `loop 5m · 3/25 · next 14:32`; `/loop status` shows the full card including the last tick's decision and reason.
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.
33
+
34
+ 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
+
36
+ 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.
37
+
38
+ 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.
33
39
 
34
40
  ## Loop-aware compaction
35
41
 
36
42
  Long loops die by context exhaustion, not by failing. pi-loop owns the compaction path:
37
43
 
38
44
  - **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.
39
- - **Post-compaction continuation**: after every compaction (whoever triggered it), a follow-up message restates the loop prompt, iteration, and when a goal exists the goal text plus pi-goal's accounting (iteration, automatic turns, token budget) so work resumes coherently instead of drifting. It never contains a dispatchable command.
45
+ - **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.
40
46
  - 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).
41
47
 
42
48
  ## Settings
@@ -50,7 +56,6 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
50
56
  "compaction": {
51
57
  "enabled": true,
52
58
  "threshold": 0.7,
53
- "postCompactContinuation": true,
54
59
  "instructions": null
55
60
  }
56
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-loop",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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": [
@@ -38,6 +38,7 @@
38
38
  "CHANGELOG.md"
39
39
  ],
40
40
  "peerDependencies": {
41
- "@earendil-works/pi-coding-agent": "*"
41
+ "@earendil-works/pi-coding-agent": "*",
42
+ "@earendil-works/pi-tui": "*"
42
43
  }
43
44
  }
package/src/index.ts CHANGED
@@ -11,9 +11,13 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
11
11
  import { completeLoopArguments, parseLoopCommand } from "./command.js";
12
12
  import { LoopController, type LoopControllerOptions } from "./loop.js";
13
13
  import { showLoopManager, showLoopSettings } from "./manager.js";
14
+ import { registerLoopMessageRendering } from "./render.js";
14
15
 
15
16
  export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
16
17
  const controller = new LoopController(pi, options);
18
+ // Collapse loop pokes into one-line transcript chips (display-only; the
19
+ // stored message and model context are untouched).
20
+ registerLoopMessageRendering(pi);
17
21
 
18
22
  pi.registerCommand("loop", {
19
23
  description:
@@ -77,7 +81,4 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
77
81
  pi.on("agent_settled", async (_event, ctx) => {
78
82
  controller.onAgentSettled(ctx);
79
83
  });
80
- pi.on("session_compact", async (_event, ctx) => {
81
- controller.onSessionCompact(ctx);
82
- });
83
84
  }
package/src/loop.ts CHANGED
@@ -10,10 +10,14 @@
10
10
  * agent_settled. Missed ticks never stack.
11
11
  * - Loops require an active pi-goal goal to operate: pi-goal owns "whether
12
12
  * the work is done". Its safety states pause the loop, its completion stops
13
- * it, a missing goal pauses the loop, and its thresholds ride along in the
14
- * post-compaction continuation. Coupling is read-only session entries.
13
+ * it, and a missing goal pauses the loop. Coupling is read-only session
14
+ * entries.
15
+ * - Terminal decisions (expiry, completion, safety) also land at a settled
16
+ * boundary, so the loop settles as soon as the goal does; only the timer
17
+ * ever pokes.
15
18
  * - The loop's proactive compaction is the normal compaction path; Pi's
16
- * reserve-token auto-compaction is the fault handler.
19
+ * reserve-token auto-compaction is the fault handler. pi-goal owns the
20
+ * post-compaction re-prompt: the loop sends none of its own.
17
21
  */
18
22
 
19
23
  import { randomUUID } from "node:crypto";
@@ -25,11 +29,7 @@ import type {
25
29
  import type { LoopStartArguments } from "./command.js";
26
30
  import { decideTick, type TickDecision, type TickEnvironment } from "./decide.js";
27
31
  import { formatClock, formatDuration, parseDuration } from "./interval.js";
28
- import {
29
- buildCompactionInstructions,
30
- buildGoalPoke,
31
- buildPostCompactContinuation,
32
- } from "./messages.js";
32
+ import { buildCompactionInstructions, buildGoalPoke } from "./messages.js";
33
33
  import {
34
34
  DEFAULT_LOOP_SETTINGS,
35
35
  type LoopSettings,
@@ -43,6 +43,7 @@ import {
43
43
  readPlanModeEnabled,
44
44
  restoreLoopState,
45
45
  } from "./state.js";
46
+ import { clearLoopWidget, updateLoopWidget } from "./widget.js";
46
47
 
47
48
  export const LOOP_STATUS_KEY = "loop";
48
49
 
@@ -63,7 +64,6 @@ export class LoopController {
63
64
  private timer: NodeJS.Timeout | undefined;
64
65
  private nextWakeAt: number | undefined;
65
66
  private wakePending = false;
66
- private pendingLoopId: string | undefined;
67
67
  private sessionCtx: ExtensionContext | undefined;
68
68
 
69
69
  constructor(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
@@ -77,7 +77,6 @@ export class LoopController {
77
77
  onSessionStart(ctx: ExtensionContext): void {
78
78
  this.clearTimer();
79
79
  this.wakePending = false;
80
- this.pendingLoopId = undefined;
81
80
  this.compacting = false;
82
81
  this.lastDecision = undefined;
83
82
  this.sessionCtx = ctx;
@@ -102,7 +101,7 @@ export class LoopController {
102
101
  onSessionShutdown(): void {
103
102
  this.clearTimer();
104
103
  this.wakePending = false;
105
- this.pendingLoopId = undefined;
104
+ if (this.sessionCtx) clearLoopWidget(this.sessionCtx.ui);
106
105
  this.sessionCtx = undefined;
107
106
  }
108
107
 
@@ -111,26 +110,30 @@ export class LoopController {
111
110
  if (!this.state || this.state.status !== "active") return;
112
111
  if (this.maybeStartCompaction(ctx)) return;
113
112
  if (this.wakePending) {
114
- if (this.pendingLoopId !== this.state.id) {
115
- // Stale wake from a replaced loop: drop it (research: CC #57660).
116
- this.wakePending = false;
117
- this.pendingLoopId = undefined;
118
- return;
119
- }
120
113
  this.wakePending = false;
121
- this.pendingLoopId = undefined;
122
114
  this.runTick(ctx);
115
+ return;
123
116
  }
117
+ this.settleTerminalState(ctx);
124
118
  }
125
119
 
126
- onSessionCompact(ctx: ExtensionContext): void {
127
- this.sessionCtx = ctx;
128
- if (!this.state || this.state.status !== "active") return;
129
- if (!this.settings.compaction.postCompactContinuation) return;
130
- const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
131
- this.pi.sendUserMessage(buildPostCompactContinuation(this.state, goal), {
132
- deliverAs: "followUp",
133
- });
120
+ /**
121
+ * A settled boundary with no wake pending still evaluates the terminal
122
+ * decisions expiry, a completed or missing goal, a pi-goal safety state —
123
+ * so the loop settles the moment the goal does instead of up to one interval
124
+ * later. Poke and skip decisions are deliberately ignored here: only the
125
+ * timer pokes, and settling is not a schedule.
126
+ */
127
+ private settleTerminalState(ctx: ExtensionContext): void {
128
+ const loop = this.state;
129
+ if (!loop) return;
130
+ const env = this.gatherEnvironment(ctx);
131
+ const decision = decideTick(loop, env);
132
+ if (decision.action !== "expire" && decision.action !== "stop" && decision.action !== "pause") {
133
+ return;
134
+ }
135
+ this.lastDecision = { ...decision, at: env.now };
136
+ this.applyTerminalDecision(loop, decision);
134
137
  }
135
138
 
136
139
  // --- tick machinery ---
@@ -174,9 +177,6 @@ export class LoopController {
174
177
  switch (decision.action) {
175
178
  case "none":
176
179
  return;
177
- case "expire":
178
- this.transition("stopped", "loop expired (maxLoopDuration reached)");
179
- return;
180
180
  case "skip":
181
181
  if (decision.reason === "plan-mode-active") {
182
182
  // Plan mode may end without an agent_settled we can use, so
@@ -186,10 +186,26 @@ export class LoopController {
186
186
  // Busy or compacting: coalesce into one pending wake that
187
187
  // the next agent_settled (or compaction onComplete) delivers.
188
188
  this.wakePending = true;
189
- this.pendingLoopId = loop.id;
190
189
  }
191
190
  this.updateWidget();
192
191
  return;
192
+ case "poke":
193
+ this.deliverPoke(env, decision.reason);
194
+ return;
195
+ default:
196
+ this.applyTerminalDecision(loop, decision);
197
+ return;
198
+ }
199
+ }
200
+
201
+ private applyTerminalDecision(
202
+ loop: LoopState,
203
+ decision: Extract<TickDecision, { action: "expire" | "stop" | "pause" }>,
204
+ ): void {
205
+ switch (decision.action) {
206
+ case "expire":
207
+ this.transition("stopped", "loop expired (maxLoopDuration reached)");
208
+ return;
193
209
  case "stop":
194
210
  this.transition(
195
211
  "stopped",
@@ -206,23 +222,31 @@ export class LoopController {
206
222
  : `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
207
223
  );
208
224
  return;
209
- case "poke":
210
- this.deliverPoke(ctx, env, decision.reason);
211
- return;
212
225
  }
213
226
  }
214
227
 
215
- private deliverPoke(
216
- ctx: ExtensionContext,
217
- env: TickEnvironment,
218
- reason: "goal-stalled" | "goal-waiting",
219
- ): void {
228
+ /**
229
+ * Send first, then account. Pi can refuse the delivery (a busy or compacting
230
+ * session), and an iteration persisted before the send would burn the
231
+ * maxIterations cap on a poke that never arrived; on a throw the loop re-arms
232
+ * on the same cadence and retries at the next wake.
233
+ */
234
+ private deliverPoke(env: TickEnvironment, reason: "goal-stalled" | "goal-waiting"): void {
220
235
  const loop = this.state;
221
236
  if (!loop || !env.goal) return;
222
- const message = buildGoalPoke(loop, env.goal, reason);
237
+ try {
238
+ this.pi.sendUserMessage(buildGoalPoke(loop, reason));
239
+ } catch (error) {
240
+ this.sessionCtx?.ui.notify(
241
+ `pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
242
+ "warning",
243
+ );
244
+ this.scheduleTick(loop.intervalMs);
245
+ this.updateWidget();
246
+ return;
247
+ }
223
248
  this.state = { ...loop, iteration: loop.iteration + 1, lastWakeAt: env.now };
224
249
  this.persist();
225
- this.pi.sendUserMessage(message);
226
250
  this.scheduleTick(this.state.intervalMs);
227
251
  this.updateWidget();
228
252
  }
@@ -237,30 +261,45 @@ export class LoopController {
237
261
  if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
238
262
  const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
239
263
  this.compacting = true;
240
- ctx.compact({
241
- customInstructions: buildCompactionInstructions(
242
- loop,
243
- goal,
244
- this.settings.compaction.instructions,
245
- ),
246
- onComplete: () => {
247
- this.compacting = false;
248
- const currentCtx = this.sessionCtx;
249
- // A pending wake held during compaction delivers at the next
250
- // settled boundary; nudge in case that boundary already passed.
251
- if (currentCtx && this.wakePending) this.onAgentSettled(currentCtx);
252
- },
253
- onError: (error) => {
254
- this.compacting = false;
255
- this.sessionCtx?.ui.notify(
256
- `pi-loop compaction failed: ${error instanceof Error ? error.message : String(error)}`,
257
- "warning",
258
- );
259
- },
260
- });
264
+ try {
265
+ ctx.compact({
266
+ customInstructions: buildCompactionInstructions(
267
+ loop,
268
+ // A completed or otherwise finished goal is no longer the
269
+ // objective the summary must preserve.
270
+ goal?.status === "active" ? goal : undefined,
271
+ this.settings.compaction.instructions,
272
+ ),
273
+ onComplete: () => {
274
+ this.compacting = false;
275
+ this.nudgeHeldWake();
276
+ },
277
+ onError: (error) => {
278
+ this.compacting = false;
279
+ this.sessionCtx?.ui.notify(`pi-loop compaction failed: ${formatError(error)}`, "warning");
280
+ this.nudgeHeldWake();
281
+ },
282
+ });
283
+ } catch (error) {
284
+ // A synchronous refusal (a torn-down runner) must not leave the loop
285
+ // skipping every tick as compaction-in-flight for the rest of the
286
+ // session.
287
+ this.compacting = false;
288
+ ctx.ui.notify(`pi-loop compaction could not start: ${formatError(error)}`, "warning");
289
+ return false;
290
+ }
261
291
  return true;
262
292
  }
263
293
 
294
+ /**
295
+ * A wake held during compaction delivers at the next settled boundary;
296
+ * nudge in case that boundary already passed while compaction ran.
297
+ */
298
+ private nudgeHeldWake(): void {
299
+ const ctx = this.sessionCtx;
300
+ if (ctx && this.wakePending) this.onAgentSettled(ctx);
301
+ }
302
+
264
303
  // --- state transitions & presentation ---
265
304
 
266
305
  private transition(status: "paused" | "stopped", why: string): void {
@@ -268,7 +307,6 @@ export class LoopController {
268
307
  this.state = { ...this.state, status };
269
308
  this.clearTimer();
270
309
  this.wakePending = false;
271
- this.pendingLoopId = undefined;
272
310
  this.persist();
273
311
  this.sessionCtx?.ui.notify(`Loop ${status}: ${why}.`, "info");
274
312
  this.updateWidget();
@@ -283,6 +321,10 @@ export class LoopController {
283
321
  const ui = this.sessionCtx?.ui;
284
322
  if (!ui) return;
285
323
  const loop = this.state;
324
+ updateLoopWidget(
325
+ ui,
326
+ loop ? { loop, wakePending: this.wakePending, nextWakeAt: this.nextWakeAt } : undefined,
327
+ );
286
328
  if (!loop || loop.status === "stopped") {
287
329
  ui.setStatus(LOOP_STATUS_KEY, undefined);
288
330
  return;
@@ -361,7 +403,6 @@ export class LoopController {
361
403
  expiresAt: now + expiryMs,
362
404
  };
363
405
  this.wakePending = false;
364
- this.pendingLoopId = undefined;
365
406
  this.persist();
366
407
  this.scheduleTick(start.intervalMs);
367
408
  this.updateWidget();
@@ -395,6 +436,15 @@ export class LoopController {
395
436
  this.transition("stopped", "loop expired (maxLoopDuration reached)");
396
437
  return;
397
438
  }
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") {
442
+ ctx.ui.notify(
443
+ "Loops require an active goal to operate. Start one first: /goal <objective>, then /loop resume.",
444
+ "error",
445
+ );
446
+ return;
447
+ }
398
448
  this.state = { ...loop, status: "active" };
399
449
  this.persist();
400
450
  this.scheduleTick(loop.intervalMs);
@@ -422,3 +472,7 @@ export class LoopController {
422
472
  this.transition("stopped", why);
423
473
  }
424
474
  }
475
+
476
+ function formatError(error: unknown): string {
477
+ return error instanceof Error ? error.message : String(error);
478
+ }
package/src/manager.ts CHANGED
@@ -153,7 +153,6 @@ export async function showLoopSettings(
153
153
  `Max iterations: ${s.maxIterations === null ? "Unlimited" : s.maxIterations}`,
154
154
  `Max loop duration: ${s.maxLoopDuration}`,
155
155
  `Proactive compaction: ${s.compaction.enabled ? `On at ${Math.round(s.compaction.threshold * 100)}%` : "Off"}`,
156
- `Post-compact continuation: ${s.compaction.postCompactContinuation ? "On" : "Off"}`,
157
156
  ];
158
157
  const choice = await ctx.ui.select("Pi Loop Settings", items);
159
158
  if (choice === undefined) return;
@@ -200,8 +199,6 @@ export async function showLoopSettings(
200
199
  next.compaction.enabled = true;
201
200
  next.compaction.threshold = fraction;
202
201
  }
203
- } else if (index === 3) {
204
- next.compaction.postCompactContinuation = !s.compaction.postCompactContinuation;
205
202
  } else {
206
203
  continue;
207
204
  }
package/src/markers.ts CHANGED
@@ -1,20 +1,20 @@
1
1
  /**
2
- * Provenance markers for loop-injected messages, following pi-goal's marker
2
+ * Provenance marker for loop-injected pokes, following pi-goal's marker
3
3
  * pattern: an HTML comment the model and transcript can see but that reads as
4
- * metadata, letting stale or duplicate deliveries be detected (research:
5
- * Claude Code's untagged auto-fires made wakeups indistinguishable from user
6
- * prompts, issue #57660).
4
+ * metadata, so a wakeup is identifiable as loop-injected rather than
5
+ * user-typed (research: Claude Code's untagged auto-fires made wakeups
6
+ * indistinguishable from user prompts, issue #57660).
7
+ *
8
+ * Provenance only. Nothing in pi-loop reads the marker back to drop stale or
9
+ * duplicate deliveries — the engine coalesces wakes in its own state, and the
10
+ * marker exists for the model, the transcript, and sibling extensions.
7
11
  */
8
12
 
9
13
  const POKE_MARKER_PREFIX = "pi-loop-poke:";
10
- const CONTINUATION_MARKER_PREFIX = "pi-loop-continuation:";
11
14
 
12
15
  const POKE_MARKER_PATTERN = new RegExp(
13
16
  `<!--\\s*${escapeRegExpText(POKE_MARKER_PREFIX)}([^\\s:>]+):(\\d+)\\s*-->`,
14
17
  );
15
- const CONTINUATION_MARKER_PATTERN = new RegExp(
16
- `<!--\\s*${escapeRegExpText(CONTINUATION_MARKER_PREFIX)}([^\\s>]+)\\s*-->`,
17
- );
18
18
 
19
19
  export function appendPokeMarker(prompt: string, loopId: string, iteration: number): string {
20
20
  return `${prompt}\n\n<!-- ${POKE_MARKER_PREFIX}${loopId}:${iteration} -->`;
@@ -28,14 +28,6 @@ export function extractPokeMarker(
28
28
  return { loopId: match[1], iteration: Number(match[2]) };
29
29
  }
30
30
 
31
- export function appendContinuationMarker(prompt: string, loopId: string): string {
32
- return `${prompt}\n\n<!-- ${CONTINUATION_MARKER_PREFIX}${loopId} -->`;
33
- }
34
-
35
- export function extractContinuationMarker(prompt: string): string | undefined {
36
- return CONTINUATION_MARKER_PATTERN.exec(prompt)?.[1];
37
- }
38
-
39
31
  function escapeRegExpText(value: string) {
40
32
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41
33
  }
package/src/messages.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Builders for every loop-injected message: pokes, the proactive compaction
3
- * instructions, and the post-compaction continuation. Pure string functions so
4
- * tests can pin their contracts most importantly that the continuation never
5
- * embeds a runnable /loop command (research: Claude Code compaction summaries
6
- * re-executed scheduling commands, issue #50554).
2
+ * Builders for every loop-injected message: pokes and the proactive compaction
3
+ * instructions. Pure string functions so tests can pin their contracts — most
4
+ * importantly that no loop-injected message embeds a runnable /loop command
5
+ * (research: Claude Code compaction summaries re-executed scheduling commands,
6
+ * issue #50554).
7
7
  */
8
8
 
9
9
  import { formatDuration } from "./interval.js";
10
- import { appendContinuationMarker, appendPokeMarker } from "./markers.js";
10
+ import { appendPokeMarker } from "./markers.js";
11
11
  import type { GoalSnapshot, LoopState } from "./state.js";
12
12
 
13
13
  function formatIteration(loop: LoopState): string {
@@ -15,22 +15,23 @@ function formatIteration(loop: LoopState): string {
15
15
  return `${loop.iteration + 1}/${cap}`;
16
16
  }
17
17
 
18
- /** The goal-bound poke: restate the goal and wake goal_wait if applicable. */
19
- export function buildGoalPoke(
20
- loop: LoopState,
21
- goal: GoalSnapshot,
22
- reason: "goal-stalled" | "goal-waiting",
23
- ): string {
18
+ /**
19
+ * The goal-bound poke: wake the session and point at the goal without
20
+ * restating it. Loops require an active pi-goal goal, so every poke turn
21
+ * already carries the objective, goal_id, and goal-mode rules through
22
+ * pi-goal's system prompt append; restating them here would store duplicate
23
+ * tokens in the conversation on every wake (see README: cross-extension
24
+ * assumption). Only the dynamic per-wake state (iteration, reason) belongs in
25
+ * this tail message.
26
+ */
27
+ export function buildGoalPoke(loop: LoopState, reason: "goal-stalled" | "goal-waiting"): string {
24
28
  const lines = [
25
29
  `Scheduled loop wakeup ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}).`,
26
30
  reason === "goal-waiting"
27
31
  ? "This is the external wake for your waiting goal. Re-check whatever the goal was waiting on and continue."
28
- : "The session went idle but the active goal is not complete. Continue working toward it.",
29
- "",
30
- `Active goal: ${goal.text}`,
32
+ : "The session went idle but the active goal is not complete. Continue working toward it — the objective and goal-mode rules are in the system prompt.",
31
33
  ];
32
34
  if (loop.prompt) lines.push("", `Loop focus: ${loop.prompt}`);
33
- lines.push("", "Use the goal tools (goal_complete, goal_blocked, goal_wait) when their conditions are met.");
34
35
  return appendPokeMarker(lines.join("\n"), loop.id, loop.iteration + 1);
35
36
  }
36
37
 
@@ -41,6 +42,7 @@ export function buildGoalPoke(
41
42
  */
42
43
  export function buildCompactionInstructions(
43
44
  loop: LoopState,
45
+ /** The goal only when it is still active; a finished goal is not the objective. */
44
46
  goal: GoalSnapshot | undefined,
45
47
  override: string | null,
46
48
  ): string {
@@ -62,38 +64,3 @@ export function buildCompactionInstructions(
62
64
  "Discard raw tool output, file contents that live on disk, and duplicate exploration.",
63
65
  ].join("\n");
64
66
  }
65
-
66
- /**
67
- * The post-compaction continuation message. Restates the loop and goal state
68
- * (including pi-goal's thresholds) so work resumes coherently. Deliberately
69
- * references /loop only as inert prose-free metadata: no line of this message
70
- * is a dispatchable command.
71
- */
72
- export function buildPostCompactContinuation(
73
- loop: LoopState,
74
- goal: GoalSnapshot | undefined,
75
- ): string {
76
- const lines = [
77
- "Context was just compacted. Loop status, restored from outside the context window:",
78
- `- loop iteration: ${loop.iteration}${loop.maxIterations === null ? "" : ` of ${loop.maxIterations}`}, waking every ${formatDuration(loop.intervalMs)}`,
79
- ];
80
- if (loop.prompt) lines.push(`- loop focus: ${loop.prompt}`);
81
- if (goal) {
82
- lines.push(`- active goal (status ${goal.status}): ${goal.text}`);
83
- const thresholds: string[] = [];
84
- if (goal.iteration !== undefined) thresholds.push(`goal iteration ${goal.iteration}`);
85
- if (goal.automaticModelTurns !== undefined) {
86
- thresholds.push(`${goal.automaticModelTurns} automatic turns used`);
87
- }
88
- if (goal.tokensUsed !== undefined) {
89
- thresholds.push(
90
- `${goal.tokensUsed} tokens used${goal.tokenBudget !== undefined ? ` of a ${goal.tokenBudget} budget` : ""}`,
91
- );
92
- }
93
- if (thresholds.length > 0) lines.push(`- goal accounting: ${thresholds.join(", ")}`);
94
- }
95
- lines.push(
96
- "Re-read any plan, progress, or state files the work relies on before continuing, then resume from the next concrete action.",
97
- );
98
- return appendContinuationMarker(lines.join("\n"), loop.id);
99
- }
package/src/render.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Display-only compaction of loop-injected pokes.
3
+ *
4
+ * Pokes are already token-lean, but they still render as multi-line prompt
5
+ * text plus a provenance marker comment. This transformer collapses each into
6
+ * a one-line themed chip in the transcript. Display-only by Pi contract: the
7
+ * stored message and model context are untouched, and pokes keep being
8
+ * delivered through sendUserMessage so pi-goal's before_agent_start hook
9
+ * (which appends the goal system prompt) still fires for every poke turn.
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { extractPokeMarker } from "./markers.js";
14
+
15
+ const POKE_HEAD_PATTERN = /^Scheduled loop wakeup (\S+) \(every ([^)]+)\)\./u;
16
+ const POKE_FOCUS_PATTERN = /^Loop focus: (.+)$/mu;
17
+
18
+ export function registerLoopMessageRendering(pi: ExtensionAPI) {
19
+ pi.registerMarkdownTransformer((markdown, { messageType }) => {
20
+ if (messageType !== "user") return markdown;
21
+ return compactPokeMessage(markdown) ?? markdown;
22
+ });
23
+ }
24
+
25
+ /** Exported for tests: the poke chip, or undefined when not ours. */
26
+ export function compactPokeMessage(markdown: string) {
27
+ if (!extractPokeMarker(markdown)) return undefined;
28
+ const head = POKE_HEAD_PATTERN.exec(markdown);
29
+ if (!head) return undefined;
30
+ const reason = markdown.includes("external wake") ? "waiting" : "stalled";
31
+ const focus = POKE_FOCUS_PATTERN.exec(markdown)?.[1];
32
+ return `*⏰ loop wake ${head[1]} · ${reason}${focus ? ` · ${focus}` : ""}*`;
33
+ }
package/src/settings.ts CHANGED
@@ -17,7 +17,6 @@ export interface LoopCompactionSettings {
17
17
  enabled: boolean;
18
18
  /** Fraction of the context window that triggers a proactive compact. */
19
19
  threshold: number;
20
- postCompactContinuation: boolean;
21
20
  /** Override for the built-in compaction instruction template. */
22
21
  instructions: string | null;
23
22
  }
@@ -36,7 +35,6 @@ export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
36
35
  compaction: {
37
36
  enabled: true,
38
37
  threshold: 0.7,
39
- postCompactContinuation: true,
40
38
  instructions: null,
41
39
  },
42
40
  };
@@ -63,12 +61,10 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
63
61
  const compactionValue = Object.hasOwn(record, "compaction") ? record.compaction : undefined;
64
62
  if (compactionValue !== undefined && !ownRecord(compactionValue)) return undefined;
65
63
  const compactionRecord = ownRecord(compactionValue) ?? {};
64
+ // `postCompactContinuation` was removed in favour of pi-goal owning the
65
+ // post-compaction re-prompt; a file still carrying it is preserved as an
66
+ // unknown field and ignored, never rejected.
66
67
  const enabled = readBoolean(compactionRecord, "enabled", DEFAULT_LOOP_SETTINGS.compaction.enabled);
67
- const postCompactContinuation = readBoolean(
68
- compactionRecord,
69
- "postCompactContinuation",
70
- DEFAULT_LOOP_SETTINGS.compaction.postCompactContinuation,
71
- );
72
68
  const threshold = Object.hasOwn(compactionRecord, "threshold")
73
69
  ? compactionRecord.threshold
74
70
  : DEFAULT_LOOP_SETTINGS.compaction.threshold;
@@ -79,7 +75,6 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
79
75
  );
80
76
  if (
81
77
  typeof enabled !== "boolean" ||
82
- typeof postCompactContinuation !== "boolean" ||
83
78
  instructions === false ||
84
79
  typeof threshold !== "number" ||
85
80
  !Number.isFinite(threshold) ||
@@ -92,7 +87,7 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
92
87
  return {
93
88
  maxIterations,
94
89
  maxLoopDuration,
95
- compaction: { enabled, threshold, postCompactContinuation, instructions },
90
+ compaction: { enabled, threshold, instructions },
96
91
  };
97
92
  }
98
93
 
package/src/state.ts CHANGED
@@ -110,6 +110,9 @@ export function restoreLoopState(entries: unknown[]): LoopState | undefined {
110
110
 
111
111
  export const GOAL_SAFETY_STATUSES = ["paused", "blocked", "usage_limited", "budget_limited"] as const;
112
112
 
113
+ /** How many `goal-state` entries a clear may be scanned back through. */
114
+ const GOAL_CLEAR_SCAN_LIMIT = 8;
115
+
113
116
  export interface GoalSnapshot {
114
117
  status: string;
115
118
  text: string;
@@ -129,19 +132,24 @@ export interface GoalSnapshot {
129
132
  *
130
133
  * Completion race: pi-goal persists the finished goal (status "complete") and
131
134
  * then clears the entry (goal: null), so by the loop's next tick the last
132
- * entry is the clear. A clear whose immediately preceding entry is a complete
133
- * goal therefore reports that complete goal the loop must stop with
134
- * "goal completed", not pause as goal-missing. A clear over any other status
135
- * (user /goal clear mid-flight) still reads as no goal.
135
+ * entry is the clear. When the newest entry is a clear (or unreadable), scan
136
+ * back over the consecutive run of clears for the goal they cleared: a
137
+ * complete goal is reported, so the loop stops with "goal completed" instead
138
+ * of pausing as goal-missing. A clear over any other status (user /goal clear
139
+ * mid-flight) still reads as no goal. The scan is bounded so a long history of
140
+ * clears cannot make the read walk the branch.
136
141
  */
137
142
  export function readGoalSnapshot(entries: unknown[]): GoalSnapshot | undefined {
138
- const datas = lastCustomEntryDatas(entries, GOAL_STATE_ENTRY_TYPE, 2);
139
- const newest = ownRecord(datas[0]);
140
- if (!newest) return undefined;
141
- const goal = parseGoalSnapshot(newest.goal);
142
- if (goal) return goal;
143
- const cleared = parseGoalSnapshot(ownRecord(datas[1])?.goal);
144
- return cleared?.status === "complete" ? cleared : undefined;
143
+ const datas = lastCustomEntryDatas(entries, GOAL_STATE_ENTRY_TYPE, GOAL_CLEAR_SCAN_LIMIT);
144
+ const newest = parseGoalSnapshot(ownRecord(datas[0])?.goal);
145
+ if (newest) return newest;
146
+ for (let index = 1; index < datas.length; index += 1) {
147
+ const cleared = parseGoalSnapshot(ownRecord(datas[index])?.goal);
148
+ // Another clear or an unreadable entry: keep scanning back.
149
+ if (!cleared) continue;
150
+ return cleared.status === "complete" ? cleared : undefined;
151
+ }
152
+ return undefined;
145
153
  }
146
154
 
147
155
  function parseGoalSnapshot(value: unknown): GoalSnapshot | undefined {
package/src/widget.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The loop widget: a compact themed line above the editor mirroring the
3
+ * footer status (interval · iteration/cap · next wake), with the loop focus
4
+ * dimmed below it when set.
5
+ *
6
+ * Presentation only: every entry point tolerates a host without setWidget
7
+ * (test fixtures, print mode) and swallows render-side failures, because a
8
+ * widget must never interrupt loop state transitions.
9
+ */
10
+
11
+ import { Text } from "@earendil-works/pi-tui";
12
+ import { formatClock, formatDuration } from "./interval.js";
13
+ import type { LoopState } from "./state.js";
14
+
15
+ export const LOOP_WIDGET_KEY = "loop";
16
+
17
+ interface WidgetTheme {
18
+ bold?: (text: string) => string;
19
+ fg?: (color: string, text: string) => string;
20
+ }
21
+
22
+ type WidgetHost = { setWidget?: unknown };
23
+
24
+ export interface LoopWidgetView {
25
+ loop: LoopState;
26
+ /** A wake is held for the next idle boundary. */
27
+ wakePending: boolean;
28
+ /** Epoch ms of the next scheduled tick, when armed. */
29
+ nextWakeAt: number | undefined;
30
+ }
31
+
32
+ export function updateLoopWidget(ui: WidgetHost, view: LoopWidgetView | undefined) {
33
+ const setWidget = resolveSetWidget(ui);
34
+ if (!setWidget) return;
35
+ try {
36
+ if (!view || view.loop.status === "stopped") {
37
+ setWidget(LOOP_WIDGET_KEY, undefined);
38
+ return;
39
+ }
40
+ setWidget(LOOP_WIDGET_KEY, (_tui: unknown, theme: WidgetTheme) => {
41
+ const bold = theme.bold ?? identity;
42
+ const dim = (text: string) => theme.fg?.("dim", text) ?? text;
43
+ const focus = view.loop.prompt ? `\n${dim(` focus: ${view.loop.prompt}`)}` : "";
44
+ return new Text(`${bold(loopWidgetLine(view))}${focus}`);
45
+ });
46
+ } catch {
47
+ // Presentation only; a widget failure must never break a loop transition.
48
+ }
49
+ }
50
+
51
+ export function clearLoopWidget(ui: WidgetHost) {
52
+ const setWidget = resolveSetWidget(ui);
53
+ if (!setWidget) return;
54
+ try {
55
+ setWidget(LOOP_WIDGET_KEY, undefined);
56
+ } catch {
57
+ // Presentation only.
58
+ }
59
+ }
60
+
61
+ export function loopWidgetLine(view: LoopWidgetView) {
62
+ const loop = view.loop;
63
+ if (loop.status === "paused") return "⏸ loop paused";
64
+ const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
65
+ const next = view.wakePending
66
+ ? "next on idle"
67
+ : view.nextWakeAt !== undefined
68
+ ? `next ${formatClock(view.nextWakeAt)}`
69
+ : "next unscheduled";
70
+ return `⟳ loop every ${formatDuration(loop.intervalMs)} · ${loop.iteration}/${cap} · ${next}`;
71
+ }
72
+
73
+ function identity(text: string) {
74
+ return text;
75
+ }
76
+
77
+ function resolveSetWidget(ui: WidgetHost) {
78
+ return typeof ui.setWidget === "function"
79
+ ? (ui.setWidget as (key: string, content: unknown) => void).bind(ui)
80
+ : undefined;
81
+ }