@hank-warren/pi-loop 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,17 @@
1
+ # @hank-warren/pi-loop
2
+
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+
9
+ ## 0.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 4e16c70: Redesign inline invocation to be tool-mediated, fixing message loss, "Agent is already processing a prompt" errors, and uninterruptible turns caused by the input-splitting approach (extension-sent messages are never dispatched as commands, so the re-sent `/goal` reached the model as plain text and the user's prose was dropped).
14
+
15
+ pi-goal: new `goal_start` tool reuses the `/goal` command's exact activation path; mid-prompt `/goal <objective>` and line-leading `goal: <objective>` invocations now append a one-line reminder to the otherwise-untouched message so the model calls the tool — nothing is cut, split, or re-sent. The `inlineInvocation` setting gates the reminder.
16
+
17
+ pi-loop: loops now require an active pi-goal goal to operate — start refuses without one, a cleared goal pauses the loop, completion still stops it. `/loop` is user-typed only: the inline `/loop` input handler and the `pokePreamble`/`inlineInvocation` settings are removed, and the loop prompt is now an optional focus added to goal pokes.
package/README.md CHANGED
@@ -1,21 +1,22 @@
1
1
  # pi-loop — interval wakeups for the Pi coding agent
2
2
 
3
- Inspired by Claude Code's `/loop`, adapted to Pi: re-run a prompt on an interval, rescue a stalled session that has an unfinished [pi-goal](https://github.com/narumiruna/pi-extensions/tree/main/packages/pi-goal) goal, 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](https://github.com/narumiruna/pi-extensions/tree/main/packages/pi-goal) goal 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*. It reads pi-goal's `goal-state` and pi-plan-mode's `plan-mode-state` session entries read-only and fail-open — with neither installed it still runs plain recurring-prompt loops.
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
 
7
7
  ## Usage
8
8
 
9
9
  ```
10
- /loop 5m check my PR, address review comments, fix CI # recurring prompt every 5 minutes
11
- /loop 30m # pi-goal goal active: poke it every 30 minutes
12
- /loop # manager TUI (status, pause/resume, edit, settings, stop)
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)
13
14
  /loop status | pause | resume | stop | settings
14
- /loop --max 20 --compact-at 60% 10m <prompt> # per-loop overrides
15
+ /loop --max 20 --compact-at 60% 10m # per-loop overrides
15
16
  ```
16
17
 
17
18
  - **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).
18
- - `/loop` also works **mid-prompt** (`get CI green /loop 10m recheck the pipeline`): the command dispatches first and the surrounding prose follows. Backtick code is ignored; messages with images pass through. Toggle with the `inlineInvocation` setting.
19
+ - `/loop` is deliberately **user-typed only** — the model never starts or stops loops. (Inline `/goal` invocation is the [pi-goal fork](https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-goal)'s `goal_start` tool.)
19
20
 
20
21
  ## What a wakeup does
21
22
 
@@ -24,9 +25,9 @@ Each tick evaluates, in order:
24
25
  1. **Expired?** Loops hard-expire after `maxLoopDuration` (default 7 days) — a forgotten loop is bounded.
25
26
  2. **Plan mode active?** Skip quietly; never inject prompts into a planning conversation.
26
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.
27
- 4. **Goal state** (when pi-goal has a goal): completion **stops** the loop; a safety pause (`paused`/`blocked`/`usage_limited`/`budget_limited`) **pauses** the loop — 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; 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
29
  5. **Iteration cap** (default 25 delivered pokes, `--max`/settings, explicit `unlimited` opt-in): stop.
29
- 6. **Poke**: the stored prompt (with a short scheduled-iteration preamble) or a goal wake message. 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 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
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
33
 
@@ -51,13 +52,11 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
51
52
  "threshold": 0.7,
52
53
  "postCompactContinuation": true,
53
54
  "instructions": null
54
- },
55
- "pokePreamble": null,
56
- "inlineInvocation": true
55
+ }
57
56
  }
58
57
  ```
59
58
 
60
- `maxIterations: null` means unlimited. `compaction.instructions` and `pokePreamble` override the built-in templates.
59
+ `maxIterations: null` means unlimited. `compaction.instructions` overrides the built-in template.
61
60
 
62
61
  ## Install
63
62
 
@@ -65,7 +64,7 @@ Long loops die by context exhaustion, not by failing. pi-loop owns the compactio
65
64
  pi install npm:@hank-warren/pi-loop
66
65
  ```
67
66
 
68
- Works standalone; pairs best with `npm:@hank-warren/pi-goal` (or upstream `@narumitw/pi-goal`) for goal-evaluated stop criteria.
67
+ Requires a goal extension for its session entries: `npm:@hank-warren/pi-goal` (recommended) or upstream `@narumitw/pi-goal`.
69
68
 
70
69
  ## License
71
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-loop",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": [
package/src/decide.ts CHANGED
@@ -3,8 +3,9 @@
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 (approved plan): loop liveness → expiry → plan mode → busy →
7
- * goal state → iteration cap → poke.
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.
8
9
  */
9
10
 
10
11
  import { GOAL_SAFETY_STATUSES, type GoalSnapshot, type LoopState } from "./state.js";
@@ -25,7 +26,8 @@ export type TickDecision =
25
26
  | { action: "skip"; reason: "plan-mode-active" | "agent-busy" | "compaction-in-flight" }
26
27
  | { action: "stop"; reason: "goal-complete" | "max-iterations" }
27
28
  | { action: "pause"; reason: "goal-safety"; cause: string }
28
- | { action: "poke"; reason: "recurring-prompt" | "goal-stalled" | "goal-waiting" };
29
+ | { action: "pause"; reason: "goal-missing" }
30
+ | { action: "poke"; reason: "goal-stalled" | "goal-waiting" };
29
31
 
30
32
  export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
31
33
  if (loop.status !== "active") return { action: "none", reason: "loop-not-active" };
@@ -35,28 +37,26 @@ export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision
35
37
  if (env.busy) return { action: "skip", reason: "agent-busy" };
36
38
 
37
39
  const goal = env.goal;
38
- if (goal) {
39
- if (goal.status === "complete") return { action: "stop", reason: "goal-complete" };
40
- if ((GOAL_SAFETY_STATUSES as readonly string[]).includes(goal.status)) {
41
- return { action: "pause", reason: "goal-safety", cause: goal.status };
42
- }
43
- if (goal.status !== "active") {
44
- // Unknown status from a newer pi-goal: treat like a safety state
45
- // rather than poking past a guard we do not understand.
46
- return { action: "pause", reason: "goal-safety", cause: goal.status };
47
- }
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.
42
+ if (!goal) return { action: "pause", reason: "goal-missing" };
43
+ if (goal.status === "complete") return { action: "stop", reason: "goal-complete" };
44
+ if ((GOAL_SAFETY_STATUSES as readonly string[]).includes(goal.status)) {
45
+ return { action: "pause", reason: "goal-safety", cause: goal.status };
46
+ }
47
+ if (goal.status !== "active") {
48
+ // Unknown status from a newer pi-goal: treat like a safety state
49
+ // rather than poking past a guard we do not understand.
50
+ return { action: "pause", reason: "goal-safety", cause: goal.status };
48
51
  }
49
52
 
50
53
  if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
51
54
  return { action: "stop", reason: "max-iterations" };
52
55
  }
53
56
 
54
- if (goal) {
55
- // An idle session with an active goal is exactly the stall/wait case:
56
- // pi-goal continues on its own at every idle boundary, so idleness at
57
- // tick time means its continuation was lost, or the goal is waiting on
58
- // an external event which this wakeup is.
59
- return { action: "poke", reason: goal.waiting ? "goal-waiting" : "goal-stalled" };
60
- }
61
- return { action: "poke", reason: "recurring-prompt" };
57
+ // An idle session with an active goal is exactly the stall/wait case:
58
+ // pi-goal continues on its own at every idle boundary, so idleness at
59
+ // tick time means its continuation was lost, or the goal is waiting on
60
+ // an external event which this wakeup is.
61
+ return { action: "poke", reason: goal.waiting ? "goal-waiting" : "goal-stalled" };
62
62
  }
package/src/index.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * pi-loop: Claude-Code-/loop-inspired pacemaker for Pi with hybrid semantics
3
- * (recurring prompt re-runs plus stall rescue) and loop-aware compaction.
4
- * The loop owns *when* the session wakes; @narumitw/pi-goal (or the
2
+ * pi-loop: Claude-Code-/loop-inspired pacemaker for Pi. A loop wakes the
3
+ * session on an interval to keep an active pi-goal goal moving (stall rescue
4
+ * and goal_wait wakes) with loop-aware compaction. Loops require an active
5
+ * goal: the loop owns *when* the session wakes; @narumitw/pi-goal (or the
5
6
  * @hank-warren/pi-goal fork) owns *whether the work is done*, read through
6
7
  * its `goal-state` session entries only.
7
8
  */
8
9
 
9
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
11
  import { completeLoopArguments, parseLoopCommand } from "./command.js";
11
- import { registerInlineInvocation } from "./inline-invocation.js";
12
12
  import { LoopController, type LoopControllerOptions } from "./loop.js";
13
13
  import { showLoopManager, showLoopSettings } from "./manager.js";
14
14
 
@@ -17,7 +17,7 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
17
17
 
18
18
  pi.registerCommand("loop", {
19
19
  description:
20
- "Wake the session on an interval: /loop [--max N] [--compact-at 60%] <interval> [prompt]",
20
+ "Wake the session on an interval to keep the active /goal moving: /loop [--max N] [--compact-at 60%] <interval> [focus]",
21
21
  getArgumentCompletions: (prefix: string) => completeLoopArguments(prefix),
22
22
  handler: async (args: string, ctx: ExtensionCommandContext) => {
23
23
  const command = parseLoopCommand(args);
@@ -80,5 +80,4 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
80
80
  pi.on("session_compact", async (_event, ctx) => {
81
81
  controller.onSessionCompact(ctx);
82
82
  });
83
- registerInlineInvocation(pi, controller);
84
83
  }
package/src/loop.ts CHANGED
@@ -8,8 +8,9 @@
8
8
  * - Pokes deliver only at a fully idle boundary; a tick that lands while the
9
9
  * agent is busy coalesces into a single pending wake delivered at the next
10
10
  * agent_settled. Missed ticks never stack.
11
- * - pi-goal owns "whether the work is done": its safety states pause the loop,
12
- * its completion stops it, and its thresholds ride along in the
11
+ * - Loops require an active pi-goal goal to operate: pi-goal owns "whether
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
13
14
  * post-compaction continuation. Coupling is read-only session entries.
14
15
  * - The loop's proactive compaction is the normal compaction path; Pi's
15
16
  * reserve-token auto-compaction is the fault handler.
@@ -28,7 +29,6 @@ import {
28
29
  buildCompactionInstructions,
29
30
  buildGoalPoke,
30
31
  buildPostCompactContinuation,
31
- buildPromptPoke,
32
32
  } from "./messages.js";
33
33
  import {
34
34
  DEFAULT_LOOP_SETTINGS,
@@ -201,7 +201,9 @@ export class LoopController {
201
201
  case "pause":
202
202
  this.transition(
203
203
  "paused",
204
- `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
204
+ decision.reason === "goal-missing"
205
+ ? "loops require an active goal; start one with /goal <objective>, then /loop resume"
206
+ : `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
205
207
  );
206
208
  return;
207
209
  case "poke":
@@ -213,14 +215,11 @@ export class LoopController {
213
215
  private deliverPoke(
214
216
  ctx: ExtensionContext,
215
217
  env: TickEnvironment,
216
- reason: "recurring-prompt" | "goal-stalled" | "goal-waiting",
218
+ reason: "goal-stalled" | "goal-waiting",
217
219
  ): void {
218
220
  const loop = this.state;
219
- if (!loop) return;
220
- const message =
221
- reason === "recurring-prompt" || !env.goal
222
- ? buildPromptPoke(loop, this.settings.pokePreamble)
223
- : buildGoalPoke(loop, env.goal, reason === "goal-waiting" ? "goal-waiting" : "goal-stalled");
221
+ if (!loop || !env.goal) return;
222
+ const message = buildGoalPoke(loop, env.goal, reason);
224
223
  this.state = { ...loop, iteration: loop.iteration + 1, lastWakeAt: env.now };
225
224
  this.persist();
226
225
  this.pi.sendUserMessage(message);
@@ -315,7 +314,7 @@ export class LoopController {
315
314
  `Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
316
315
  `Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
317
316
  ];
318
- if (loop.prompt) lines.push(`Prompt: ${loop.prompt}`);
317
+ if (loop.prompt) lines.push(`Focus: ${loop.prompt}`);
319
318
  const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
320
319
  if (goal) lines.push(`Goal (pi-goal): ${goal.status} — ${goal.text}`);
321
320
  if (this.nextWakeAt && loop.status === "active") {
@@ -335,9 +334,9 @@ export class LoopController {
335
334
  this.sessionCtx = ctx;
336
335
  const now = this.now();
337
336
  const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
338
- if (!start.prompt && goal?.status !== "active") {
337
+ if (goal?.status !== "active") {
339
338
  ctx.ui.notify(
340
- "A prompt is required unless a pi-goal goal is active: /loop <interval> <prompt>.",
339
+ "Loops require an active goal to operate. Start one first: /goal <objective>, then /loop <interval> [focus].",
341
340
  "error",
342
341
  );
343
342
  return;
@@ -369,9 +368,8 @@ export class LoopController {
369
368
  const clampNote = start.clamped
370
369
  ? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
371
370
  : "";
372
- const target = start.prompt ? "the loop prompt" : "the active goal";
373
371
  ctx.ui.notify(
374
- `Loop started: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, poking ${target}. Stop with /loop stop.`,
372
+ `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.`,
375
373
  "info",
376
374
  );
377
375
  }
package/src/manager.ts CHANGED
@@ -27,8 +27,8 @@ export async function showLoopManager(
27
27
  for (;;) {
28
28
  const loop = controller.state;
29
29
  const options: string[] = ["Status"];
30
- if (loop?.status === "active") options.push("Pause", "Edit prompt", "Edit interval", "Stop");
31
- else if (loop?.status === "paused") options.push("Resume", "Edit prompt", "Edit interval", "Stop");
30
+ if (loop?.status === "active") options.push("Pause", "Edit focus", "Edit interval", "Stop");
31
+ else if (loop?.status === "paused") options.push("Resume", "Edit focus", "Edit interval", "Stop");
32
32
  else options.push("Start a loop…");
33
33
  options.push("Settings");
34
34
  const choice = await ctx.ui.select(`Pi Loop${loop ? ` · ${loop.status}` : ""}`, options);
@@ -49,7 +49,7 @@ export async function showLoopManager(
49
49
  case "Start a loop…":
50
50
  await startFromMenu(controller, ctx);
51
51
  break;
52
- case "Edit prompt":
52
+ case "Edit focus":
53
53
  await editPrompt(controller, ctx);
54
54
  break;
55
55
  case "Edit interval":
@@ -80,11 +80,14 @@ async function startFromMenu(
80
80
  return;
81
81
  }
82
82
  const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
83
- const promptText = await ctx.ui.input(
84
- goal?.status === "active"
85
- ? "Loop prompt (optional: empty pokes the active goal)"
86
- : "Loop prompt",
87
- );
83
+ if (goal?.status !== "active") {
84
+ ctx.ui.notify(
85
+ "Loops require an active goal to operate. Start one first: /goal <objective>.",
86
+ "error",
87
+ );
88
+ return;
89
+ }
90
+ const promptText = await ctx.ui.input("Loop focus (optional, added to every goal poke)");
88
91
  if (promptText === undefined) return;
89
92
  const prompt = promptText.trim();
90
93
  controller.startLoop(ctx, {
@@ -99,21 +102,16 @@ async function startFromMenu(
99
102
  async function editPrompt(controller: LoopController, ctx: ExtensionCommandContext): Promise<void> {
100
103
  const loop = controller.state;
101
104
  if (!loop || loop.status === "stopped") return;
102
- const next = await ctx.ui.input("Loop prompt", loop.prompt ?? "");
105
+ const next = await ctx.ui.input("Loop focus (optional, added to every goal poke)", loop.prompt ?? "");
103
106
  if (next === undefined) return;
104
107
  const prompt = next.trim();
105
- const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
106
- if (!prompt && goal?.status !== "active") {
107
- ctx.ui.notify("A prompt is required unless a pi-goal goal is active.", "error");
108
- return;
109
- }
110
108
  if (prompt) controller.state = { ...loop, prompt };
111
109
  else {
112
110
  const { prompt: _dropped, ...rest } = loop;
113
111
  controller.state = rest;
114
112
  }
115
113
  controller.persist();
116
- ctx.ui.notify("Loop prompt updated.", "info");
114
+ ctx.ui.notify("Loop focus updated.", "info");
117
115
  }
118
116
 
119
117
  async function editInterval(
@@ -156,7 +154,6 @@ export async function showLoopSettings(
156
154
  `Max loop duration: ${s.maxLoopDuration}`,
157
155
  `Proactive compaction: ${s.compaction.enabled ? `On at ${Math.round(s.compaction.threshold * 100)}%` : "Off"}`,
158
156
  `Post-compact continuation: ${s.compaction.postCompactContinuation ? "On" : "Off"}`,
159
- `Inline /loop: ${s.inlineInvocation ? "On" : "Off"}`,
160
157
  ];
161
158
  const choice = await ctx.ui.select("Pi Loop Settings", items);
162
159
  if (choice === undefined) return;
@@ -205,8 +202,6 @@ export async function showLoopSettings(
205
202
  }
206
203
  } else if (index === 3) {
207
204
  next.compaction.postCompactContinuation = !s.compaction.postCompactContinuation;
208
- } else if (index === 4) {
209
- next.inlineInvocation = !s.inlineInvocation;
210
205
  } else {
211
206
  continue;
212
207
  }
package/src/messages.ts CHANGED
@@ -15,15 +15,6 @@ function formatIteration(loop: LoopState): string {
15
15
  return `${loop.iteration + 1}/${cap}`;
16
16
  }
17
17
 
18
- /** The recurring-prompt poke: the stored prompt with a short scheduled preamble. */
19
- export function buildPromptPoke(loop: LoopState, preambleOverride: string | null): string {
20
- const preamble =
21
- preambleOverride ??
22
- `Scheduled loop iteration ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}). Continue the recurring task below; if its work is exhausted, say so instead of inventing new work.`;
23
- const prompt = loop.prompt ?? "";
24
- return appendPokeMarker(`${preamble}\n\n${prompt}`.trim(), loop.id, loop.iteration + 1);
25
- }
26
-
27
18
  /** The goal-bound poke: restate the goal and wake goal_wait if applicable. */
28
19
  export function buildGoalPoke(
29
20
  loop: LoopState,
@@ -57,7 +48,7 @@ export function buildCompactionInstructions(
57
48
  const objective = goal
58
49
  ? `The session is working toward this goal: ${goal.text}`
59
50
  : loop.prompt
60
- ? `The session is running a recurring task: ${loop.prompt}`
51
+ ? `The session is running a recurring loop focused on: ${loop.prompt}`
61
52
  : "The session is running a recurring loop.";
62
53
  return [
63
54
  `${objective}`,
@@ -86,7 +77,7 @@ export function buildPostCompactContinuation(
86
77
  "Context was just compacted. Loop status, restored from outside the context window:",
87
78
  `- loop iteration: ${loop.iteration}${loop.maxIterations === null ? "" : ` of ${loop.maxIterations}`}, waking every ${formatDuration(loop.intervalMs)}`,
88
79
  ];
89
- if (loop.prompt) lines.push(`- recurring task: ${loop.prompt}`);
80
+ if (loop.prompt) lines.push(`- loop focus: ${loop.prompt}`);
90
81
  if (goal) {
91
82
  lines.push(`- active goal (status ${goal.status}): ${goal.text}`);
92
83
  const thresholds: string[] = [];
package/src/settings.ts CHANGED
@@ -28,10 +28,6 @@ export interface LoopSettings {
28
28
  /** Wall-clock expiry for a loop, e.g. "7d" (research: bound forgotten loops). */
29
29
  maxLoopDuration: string;
30
30
  compaction: LoopCompactionSettings;
31
- /** Override for the built-in poke preamble template. */
32
- pokePreamble: string | null;
33
- /** Recognize /loop mid-prompt. */
34
- inlineInvocation: boolean;
35
31
  }
36
32
 
37
33
  export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
@@ -43,8 +39,6 @@ export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
43
39
  postCompactContinuation: true,
44
40
  instructions: null,
45
41
  },
46
- pokePreamble: null,
47
- inlineInvocation: true,
48
42
  };
49
43
 
50
44
  export type LoopSettingsLoadResult =
@@ -95,26 +89,10 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
95
89
  return undefined;
96
90
  }
97
91
 
98
- const pokePreamble = readNullableString(
99
- record,
100
- "pokePreamble",
101
- DEFAULT_LOOP_SETTINGS.pokePreamble,
102
- );
103
- if (pokePreamble === false) return undefined;
104
-
105
- const inlineInvocation = readBoolean(
106
- record,
107
- "inlineInvocation",
108
- DEFAULT_LOOP_SETTINGS.inlineInvocation,
109
- );
110
- if (typeof inlineInvocation !== "boolean") return undefined;
111
-
112
92
  return {
113
93
  maxIterations,
114
94
  maxLoopDuration,
115
95
  compaction: { enabled, threshold, postCompactContinuation, instructions },
116
- pokePreamble,
117
- inlineInvocation,
118
96
  };
119
97
  }
120
98
 
@@ -201,8 +179,6 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
201
179
  maxIterations: normalized.maxIterations,
202
180
  maxLoopDuration: normalized.maxLoopDuration,
203
181
  compaction: { ...compaction, ...normalized.compaction },
204
- pokePreamble: normalized.pokePreamble,
205
- inlineInvocation: normalized.inlineInvocation,
206
182
  },
207
183
  null,
208
184
  2,
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. */
@@ -120,11 +126,26 @@ export interface GoalSnapshot {
120
126
  * entry shape is not recognizably a goal. Only fields pi-loop consumes are
121
127
  * extracted; unknown statuses are preserved verbatim so the caller can treat
122
128
  * anything outside its known sets conservatively.
129
+ *
130
+ * Completion race: pi-goal persists the finished goal (status "complete") and
131
+ * 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.
123
136
  */
124
137
  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);
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;
145
+ }
146
+
147
+ function parseGoalSnapshot(value: unknown): GoalSnapshot | undefined {
148
+ const goal = ownRecord(value);
128
149
  if (!goal) return undefined;
129
150
  const status = typeof goal.status === "string" ? goal.status : undefined;
130
151
  const text = typeof goal.text === "string" ? goal.text.trim() : "";
@@ -1,89 +0,0 @@
1
- /**
2
- * Inline slash-command detection.
3
- *
4
- * Pi dispatches extension commands only when a message *starts* with the
5
- * command token; a command mentioned mid-prompt arrives as plain text. This
6
- * helper finds the first inline occurrence of a command token so the caller
7
- * can dispatch it and deliver the surrounding prose separately.
8
- *
9
- * This module is deliberately package-agnostic: every package-specific detail
10
- * is a parameter so the file can be duplicated byte-for-byte into sibling
11
- * packages (see DUPLICATED_SOURCES in scripts/validate.py once a second copy
12
- * exists). Edit one copy, then copy it over the other.
13
- */
14
-
15
- export interface InlineCommandSplit {
16
- /** Text before the command token, trailing whitespace removed. */
17
- prose: string;
18
- /** Everything after the command token to end of message, trimmed. */
19
- commandArgs: string;
20
- }
21
-
22
- /**
23
- * Find the first inline `/<commandName>` occurrence in `text`.
24
- *
25
- * Detection rules:
26
- * - The token must be preceded by start-of-line or whitespace and followed by
27
- * whitespace plus a non-empty remainder. A bare trailing `/cmd` mention or a
28
- * path-like `foo/cmd` never matches.
29
- * - A token at position 0 is ignored: that is Pi's native dispatch position.
30
- * - Occurrences inside backtick code (inline spans and fenced blocks) are
31
- * ignored. Code regions are approximated by the CommonMark backtick-run
32
- * rule: a run of N backticks opens a region closed by the next run of
33
- * exactly N backticks.
34
- *
35
- * Returns undefined when no qualifying occurrence exists.
36
- */
37
- export function extractInlineCommand(
38
- text: string,
39
- commandName: string,
40
- ): InlineCommandSplit | undefined {
41
- const token = `/${commandName}`;
42
- const code = codeRegions(text);
43
- let searchFrom = 0;
44
- while (searchFrom < text.length) {
45
- const index = text.indexOf(token, searchFrom);
46
- if (index === -1) return undefined;
47
- searchFrom = index + 1;
48
- if (index === 0) continue;
49
- const before = text[index - 1];
50
- if (before !== undefined && !/\s/.test(before)) continue;
51
- const afterToken = text[index + token.length];
52
- if (afterToken === undefined || !/\s/.test(afterToken)) continue;
53
- if (insideRegion(code, index)) continue;
54
- const commandArgs = text.slice(index + token.length).trim();
55
- if (!commandArgs) continue;
56
- return {
57
- prose: text.slice(0, index).trimEnd(),
58
- commandArgs,
59
- };
60
- }
61
- return undefined;
62
- }
63
-
64
- type Region = readonly [start: number, end: number];
65
-
66
- function codeRegions(text: string): Region[] {
67
- const regions: Region[] = [];
68
- const runs: Array<{ index: number; length: number }> = [];
69
- const runPattern = /`+/g;
70
- for (let match = runPattern.exec(text); match; match = runPattern.exec(text)) {
71
- runs.push({ index: match.index, length: match[0].length });
72
- }
73
- for (let open = 0; open < runs.length; open += 1) {
74
- const opener = runs[open];
75
- if (opener === undefined) continue;
76
- for (let close = open + 1; close < runs.length; close += 1) {
77
- const closer = runs[close];
78
- if (closer === undefined || closer.length !== opener.length) continue;
79
- regions.push([opener.index, closer.index + closer.length]);
80
- open = close;
81
- break;
82
- }
83
- }
84
- return regions;
85
- }
86
-
87
- function insideRegion(regions: readonly Region[], index: number): boolean {
88
- return regions.some(([start, end]) => index >= start && index < end);
89
- }
@@ -1,39 +0,0 @@
1
- /**
2
- * Inline /loop invocation: recognize `/loop <args>` mid-prompt through Pi's
3
- * input pipeline, dispatch it command-first, and deliver surrounding prose as
4
- * a follow-up. Same design as the pi-goal fork's inline /goal; the detection
5
- * helper (inline-command.ts) is duplicated byte-for-byte from
6
- * packages/pi-goal and registered in DUPLICATED_SOURCES.
7
- */
8
-
9
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
- import { extractInlineCommand } from "./inline-command.js";
11
- import type { LoopController } from "./loop.js";
12
-
13
- export const INLINE_LOOP_COMMAND = "loop";
14
-
15
- export function registerInlineInvocation(pi: ExtensionAPI, controller: LoopController) {
16
- pi.on("input", (event) => {
17
- // Messages sent by extensions (including our own re-dispatch below) are
18
- // never rewritten; leading "/loop" is consumed by Pi's command dispatch
19
- // before this event fires, so recursion is impossible either way.
20
- if (event.source === "extension") return;
21
- if (!controller.settings.inlineInvocation) return;
22
- // A message with attached images is passed through untouched: splitting
23
- // it would detach the images from the text they belong to.
24
- if (event.images && event.images.length > 0) return;
25
- const split = extractInlineCommand(event.text, INLINE_LOOP_COMMAND);
26
- if (!split) return;
27
- // While the agent streams, Pi requires an explicit delivery mode; reuse
28
- // the mode the original message would have used.
29
- const deliverAs = event.streamingBehavior;
30
- pi.sendUserMessage(
31
- `/${INLINE_LOOP_COMMAND} ${split.commandArgs}`,
32
- deliverAs ? { deliverAs } : undefined,
33
- );
34
- if (split.prose) {
35
- pi.sendUserMessage(split.prose, { deliverAs: "followUp" });
36
- }
37
- return { action: "handled" as const };
38
- });
39
- }