@hank-warren/pi-loop 0.2.0 → 0.3.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,43 @@
1
1
  # @hank-warren/pi-loop
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3e94f24: drop the post-compaction continuation and harden the loop engine
8
+
9
+ **Removed:** the `postCompactContinuation` setting, the message it sent, and the
10
+ `session_compact` handler behind it. pi-goal already re-prompts the session after
11
+ a compaction for an active goal, and a loop requires an active goal, so pi-loop's
12
+ follow-up only ever doubled the queued messages and the tokens they cost. pi-goal
13
+ owns that message; pi-loop owns the compaction trigger and its instructions.
14
+ Threshold compaction is unchanged. An existing `pi-loop.json` carrying the removed
15
+ key keeps working — it is ignored, never rejected — but the settings row and the
16
+ session surface are gone, hence the minor bump.
17
+
18
+ **Terminal decisions at settle.** A settled boundary with no pending wake now
19
+ evaluates expiry, completion, and pi-goal's safety states, so a loop stops the
20
+ moment its goal does instead of up to one interval later. Poke and skip decisions
21
+ are ignored there: only the timer pokes.
22
+
23
+ **Delivery and compaction no longer lose or wedge state.** A poke is sent before
24
+ it is accounted for, so a refused delivery re-arms on the same cadence instead of
25
+ burning an iteration on a message that never arrived. A synchronous throw from
26
+ `ctx.compact` resets the in-flight flag rather than leaving every later tick
27
+ skipping as compaction-in-flight, and a failed compaction releases a held wake
28
+ just as a successful one does.
29
+
30
+ **Reading pi-goal through its completion clear** is now a bounded backward scan
31
+ over consecutive clears, so a completion still stops the loop when other entries
32
+ land on top of it. `resumeLoop` refuses a loop whose goal is gone, matching
33
+ `startLoop`. Compaction instructions receive the goal only while it is active.
34
+
35
+ ## 0.2.1
36
+
37
+ ### Patch Changes
38
+
39
+ - 2b22273: Stop the loop with "goal completed" when pi-goal clears its state entry after completion. pi-goal persists the finished goal (status complete) and then writes a clear (goal: null), so by the loop's next tick the last goal-state entry was the clear and the loop paused as goal-missing instead of stopping as goal-complete. readGoalSnapshot now reads a completed goal through its completion clear; a clear over any non-complete goal (user /goal clear mid-flight) still pauses the loop.
40
+
3
41
  ## 0.2.0
4
42
 
5
43
  ### Minor 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,9 +25,11 @@ 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 restating the goal (plus 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
+
32
+ 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.
31
33
 
32
34
  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.
33
35
 
@@ -36,7 +38,7 @@ The footer widget shows `loop 5m · 3/25 · next 14:32`; `/loop status` shows th
36
38
  Long loops die by context exhaustion, not by failing. pi-loop owns the compaction path:
37
39
 
38
40
  - **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.
41
+ - **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
42
  - 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
43
 
42
44
  ## Settings
@@ -50,7 +52,6 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
50
52
  "compaction": {
51
53
  "enabled": true,
52
54
  "threshold": 0.7,
53
- "postCompactContinuation": true,
54
55
  "instructions": null
55
56
  }
56
57
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-loop",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": [
package/src/index.ts CHANGED
@@ -77,7 +77,4 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
77
77
  pi.on("agent_settled", async (_event, ctx) => {
78
78
  controller.onAgentSettled(ctx);
79
79
  });
80
- pi.on("session_compact", async (_event, ctx) => {
81
- controller.onSessionCompact(ctx);
82
- });
83
80
  }
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,
@@ -63,7 +63,6 @@ export class LoopController {
63
63
  private timer: NodeJS.Timeout | undefined;
64
64
  private nextWakeAt: number | undefined;
65
65
  private wakePending = false;
66
- private pendingLoopId: string | undefined;
67
66
  private sessionCtx: ExtensionContext | undefined;
68
67
 
69
68
  constructor(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
@@ -77,7 +76,6 @@ export class LoopController {
77
76
  onSessionStart(ctx: ExtensionContext): void {
78
77
  this.clearTimer();
79
78
  this.wakePending = false;
80
- this.pendingLoopId = undefined;
81
79
  this.compacting = false;
82
80
  this.lastDecision = undefined;
83
81
  this.sessionCtx = ctx;
@@ -102,7 +100,6 @@ export class LoopController {
102
100
  onSessionShutdown(): void {
103
101
  this.clearTimer();
104
102
  this.wakePending = false;
105
- this.pendingLoopId = undefined;
106
103
  this.sessionCtx = undefined;
107
104
  }
108
105
 
@@ -111,26 +108,30 @@ export class LoopController {
111
108
  if (!this.state || this.state.status !== "active") return;
112
109
  if (this.maybeStartCompaction(ctx)) return;
113
110
  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
111
  this.wakePending = false;
121
- this.pendingLoopId = undefined;
122
112
  this.runTick(ctx);
113
+ return;
123
114
  }
115
+ this.settleTerminalState(ctx);
124
116
  }
125
117
 
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
- });
118
+ /**
119
+ * A settled boundary with no wake pending still evaluates the terminal
120
+ * decisions expiry, a completed or missing goal, a pi-goal safety state —
121
+ * so the loop settles the moment the goal does instead of up to one interval
122
+ * later. Poke and skip decisions are deliberately ignored here: only the
123
+ * timer pokes, and settling is not a schedule.
124
+ */
125
+ private settleTerminalState(ctx: ExtensionContext): void {
126
+ const loop = this.state;
127
+ if (!loop) return;
128
+ const env = this.gatherEnvironment(ctx);
129
+ const decision = decideTick(loop, env);
130
+ if (decision.action !== "expire" && decision.action !== "stop" && decision.action !== "pause") {
131
+ return;
132
+ }
133
+ this.lastDecision = { ...decision, at: env.now };
134
+ this.applyTerminalDecision(loop, decision);
134
135
  }
135
136
 
136
137
  // --- tick machinery ---
@@ -174,9 +175,6 @@ export class LoopController {
174
175
  switch (decision.action) {
175
176
  case "none":
176
177
  return;
177
- case "expire":
178
- this.transition("stopped", "loop expired (maxLoopDuration reached)");
179
- return;
180
178
  case "skip":
181
179
  if (decision.reason === "plan-mode-active") {
182
180
  // Plan mode may end without an agent_settled we can use, so
@@ -186,10 +184,26 @@ export class LoopController {
186
184
  // Busy or compacting: coalesce into one pending wake that
187
185
  // the next agent_settled (or compaction onComplete) delivers.
188
186
  this.wakePending = true;
189
- this.pendingLoopId = loop.id;
190
187
  }
191
188
  this.updateWidget();
192
189
  return;
190
+ case "poke":
191
+ this.deliverPoke(env, decision.reason);
192
+ return;
193
+ default:
194
+ this.applyTerminalDecision(loop, decision);
195
+ return;
196
+ }
197
+ }
198
+
199
+ private applyTerminalDecision(
200
+ loop: LoopState,
201
+ decision: Extract<TickDecision, { action: "expire" | "stop" | "pause" }>,
202
+ ): void {
203
+ switch (decision.action) {
204
+ case "expire":
205
+ this.transition("stopped", "loop expired (maxLoopDuration reached)");
206
+ return;
193
207
  case "stop":
194
208
  this.transition(
195
209
  "stopped",
@@ -206,23 +220,31 @@ export class LoopController {
206
220
  : `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
207
221
  );
208
222
  return;
209
- case "poke":
210
- this.deliverPoke(ctx, env, decision.reason);
211
- return;
212
223
  }
213
224
  }
214
225
 
215
- private deliverPoke(
216
- ctx: ExtensionContext,
217
- env: TickEnvironment,
218
- reason: "goal-stalled" | "goal-waiting",
219
- ): void {
226
+ /**
227
+ * Send first, then account. Pi can refuse the delivery (a busy or compacting
228
+ * session), and an iteration persisted before the send would burn the
229
+ * maxIterations cap on a poke that never arrived; on a throw the loop re-arms
230
+ * on the same cadence and retries at the next wake.
231
+ */
232
+ private deliverPoke(env: TickEnvironment, reason: "goal-stalled" | "goal-waiting"): void {
220
233
  const loop = this.state;
221
234
  if (!loop || !env.goal) return;
222
- const message = buildGoalPoke(loop, env.goal, reason);
235
+ try {
236
+ this.pi.sendUserMessage(buildGoalPoke(loop, env.goal, reason));
237
+ } catch (error) {
238
+ this.sessionCtx?.ui.notify(
239
+ `pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
240
+ "warning",
241
+ );
242
+ this.scheduleTick(loop.intervalMs);
243
+ this.updateWidget();
244
+ return;
245
+ }
223
246
  this.state = { ...loop, iteration: loop.iteration + 1, lastWakeAt: env.now };
224
247
  this.persist();
225
- this.pi.sendUserMessage(message);
226
248
  this.scheduleTick(this.state.intervalMs);
227
249
  this.updateWidget();
228
250
  }
@@ -237,30 +259,45 @@ export class LoopController {
237
259
  if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
238
260
  const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
239
261
  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
- });
262
+ try {
263
+ ctx.compact({
264
+ customInstructions: buildCompactionInstructions(
265
+ loop,
266
+ // A completed or otherwise finished goal is no longer the
267
+ // objective the summary must preserve.
268
+ goal?.status === "active" ? goal : undefined,
269
+ this.settings.compaction.instructions,
270
+ ),
271
+ onComplete: () => {
272
+ this.compacting = false;
273
+ this.nudgeHeldWake();
274
+ },
275
+ onError: (error) => {
276
+ this.compacting = false;
277
+ this.sessionCtx?.ui.notify(`pi-loop compaction failed: ${formatError(error)}`, "warning");
278
+ this.nudgeHeldWake();
279
+ },
280
+ });
281
+ } catch (error) {
282
+ // A synchronous refusal (a torn-down runner) must not leave the loop
283
+ // skipping every tick as compaction-in-flight for the rest of the
284
+ // session.
285
+ this.compacting = false;
286
+ ctx.ui.notify(`pi-loop compaction could not start: ${formatError(error)}`, "warning");
287
+ return false;
288
+ }
261
289
  return true;
262
290
  }
263
291
 
292
+ /**
293
+ * A wake held during compaction delivers at the next settled boundary;
294
+ * nudge in case that boundary already passed while compaction ran.
295
+ */
296
+ private nudgeHeldWake(): void {
297
+ const ctx = this.sessionCtx;
298
+ if (ctx && this.wakePending) this.onAgentSettled(ctx);
299
+ }
300
+
264
301
  // --- state transitions & presentation ---
265
302
 
266
303
  private transition(status: "paused" | "stopped", why: string): void {
@@ -268,7 +305,6 @@ export class LoopController {
268
305
  this.state = { ...this.state, status };
269
306
  this.clearTimer();
270
307
  this.wakePending = false;
271
- this.pendingLoopId = undefined;
272
308
  this.persist();
273
309
  this.sessionCtx?.ui.notify(`Loop ${status}: ${why}.`, "info");
274
310
  this.updateWidget();
@@ -361,7 +397,6 @@ export class LoopController {
361
397
  expiresAt: now + expiryMs,
362
398
  };
363
399
  this.wakePending = false;
364
- this.pendingLoopId = undefined;
365
400
  this.persist();
366
401
  this.scheduleTick(start.intervalMs);
367
402
  this.updateWidget();
@@ -395,6 +430,15 @@ export class LoopController {
395
430
  this.transition("stopped", "loop expired (maxLoopDuration reached)");
396
431
  return;
397
432
  }
433
+ // Same guard as startLoop: resuming into a finished or missing goal would
434
+ // only stop or pause again at the first tick.
435
+ if (readGoalSnapshot(ctx.sessionManager.getBranch())?.status !== "active") {
436
+ ctx.ui.notify(
437
+ "Loops require an active goal to operate. Start one first: /goal <objective>, then /loop resume.",
438
+ "error",
439
+ );
440
+ return;
441
+ }
398
442
  this.state = { ...loop, status: "active" };
399
443
  this.persist();
400
444
  this.scheduleTick(loop.intervalMs);
@@ -422,3 +466,7 @@ export class LoopController {
422
466
  this.transition("stopped", why);
423
467
  }
424
468
  }
469
+
470
+ function formatError(error: unknown): string {
471
+ return error instanceof Error ? error.message : String(error);
472
+ }
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 {
@@ -41,6 +41,7 @@ export function buildGoalPoke(
41
41
  */
42
42
  export function buildCompactionInstructions(
43
43
  loop: LoopState,
44
+ /** The goal only when it is still active; a finished goal is not the objective. */
44
45
  goal: GoalSnapshot | undefined,
45
46
  override: string | null,
46
47
  ): string {
@@ -62,38 +63,3 @@ export function buildCompactionInstructions(
62
63
  "Discard raw tool output, file contents that live on disk, and duplicate exploration.",
63
64
  ].join("\n");
64
65
  }
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/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
@@ -87,11 +87,17 @@ interface SessionEntryLike {
87
87
  }
88
88
 
89
89
  function lastCustomEntryData(entries: unknown[], customType: string): unknown {
90
- for (let index = entries.length - 1; index >= 0; index -= 1) {
90
+ return lastCustomEntryDatas(entries, customType, 1)[0];
91
+ }
92
+
93
+ /** Newest-first data of the last `limit` custom entries of `customType`. */
94
+ function lastCustomEntryDatas(entries: unknown[], customType: string, limit: number): unknown[] {
95
+ const datas: unknown[] = [];
96
+ for (let index = entries.length - 1; index >= 0 && datas.length < limit; index -= 1) {
91
97
  const entry = entries[index] as SessionEntryLike | undefined;
92
- if (entry?.type === "custom" && entry.customType === customType) return entry.data;
98
+ if (entry?.type === "custom" && entry.customType === customType) datas.push(entry.data);
93
99
  }
94
- return undefined;
100
+ return datas;
95
101
  }
96
102
 
97
103
  /** Restore the persisted loop state from a session branch, fail-open. */
@@ -104,6 +110,9 @@ export function restoreLoopState(entries: unknown[]): LoopState | undefined {
104
110
 
105
111
  export const GOAL_SAFETY_STATUSES = ["paused", "blocked", "usage_limited", "budget_limited"] as const;
106
112
 
113
+ /** How many `goal-state` entries a clear may be scanned back through. */
114
+ const GOAL_CLEAR_SCAN_LIMIT = 8;
115
+
107
116
  export interface GoalSnapshot {
108
117
  status: string;
109
118
  text: string;
@@ -120,11 +129,31 @@ export interface GoalSnapshot {
120
129
  * entry shape is not recognizably a goal. Only fields pi-loop consumes are
121
130
  * extracted; unknown statuses are preserved verbatim so the caller can treat
122
131
  * anything outside its known sets conservatively.
132
+ *
133
+ * Completion race: pi-goal persists the finished goal (status "complete") and
134
+ * then clears the entry (goal: null), so by the loop's next tick the last
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.
123
141
  */
124
142
  export function readGoalSnapshot(entries: unknown[]): GoalSnapshot | undefined {
125
- const data = ownRecord(lastCustomEntryData(entries, GOAL_STATE_ENTRY_TYPE));
126
- if (!data) return undefined;
127
- const goal = ownRecord(data.goal);
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;
153
+ }
154
+
155
+ function parseGoalSnapshot(value: unknown): GoalSnapshot | undefined {
156
+ const goal = ownRecord(value);
128
157
  if (!goal) return undefined;
129
158
  const status = typeof goal.status === "string" ? goal.status : undefined;
130
159
  const text = typeof goal.text === "string" ? goal.text.trim() : "";