@hank-warren/pi-loop 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/manager.ts CHANGED
@@ -7,7 +7,6 @@
7
7
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
8
  import { formatDuration, parseInterval } from "./interval.js";
9
9
  import type { LoopController } from "./loop.js";
10
- import { readGoalSnapshot } from "./state.js";
11
10
  import {
12
11
  DEFAULT_LOOP_SETTINGS,
13
12
  type LoopSettings,
@@ -79,39 +78,35 @@ async function startFromMenu(
79
78
  ctx.ui.notify(`Invalid interval: ${intervalText}. Use <number><unit>, e.g. 5m.`, "error");
80
79
  return;
81
80
  }
82
- // With an active goal the loop binds to it and the text is an optional
83
- // per-wake focus. Without one the loop owns its objective, so the text is
84
- // required — asking for it here is what replaces the old dead-end refusal.
85
- const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
86
- const goalBound = goal?.status === "active";
81
+ // The loop owns its objective, so the text is required — asking for it here
82
+ // is what replaces the old dead-end refusal.
87
83
  const promptText = await ctx.ui.input(
88
- goalBound
89
- ? "Loop focus (optional, added to every goal poke)"
90
- : "Objective, including how the loop knows it is done",
91
- goalBound ? undefined : "e.g. get CI green on main, verified by a passing run",
84
+ "Objective, including how the loop knows it is done",
85
+ "e.g. get CI green on main, verified by a passing run",
92
86
  );
93
87
  if (promptText === undefined) return;
94
88
  const prompt = promptText.trim();
95
- if (!goalBound && !prompt) {
96
- ctx.ui.notify(
97
- "A loop with no active goal needs its own objective, so no loop was started.",
98
- "warning",
99
- );
89
+ if (!prompt) {
90
+ ctx.ui.notify("A loop needs an objective, so no loop was started.", "warning");
100
91
  return;
101
92
  }
102
- controller.startLoop(ctx, {
93
+ const result = controller.startLoop(ctx, {
103
94
  kind: "start",
104
95
  requestedMs: interval.requestedMs,
105
96
  intervalMs: interval.effectiveMs,
106
97
  clamped: interval.clamped,
107
98
  ...(prompt ? { prompt } : {}),
108
99
  });
100
+ if (!result.ok) ctx.ui.notify(result.message, "error");
109
101
  }
110
102
 
111
103
  async function editPrompt(controller: LoopController, ctx: ExtensionCommandContext): Promise<void> {
112
104
  const loop = controller.state;
113
105
  if (!loop || loop.status === "stopped") return;
114
- const next = await ctx.ui.input("Loop focus (optional, added to every goal poke)", loop.prompt ?? "");
106
+ const next = await ctx.ui.input(
107
+ "Loop focus (optional, restated on every loop message)",
108
+ loop.prompt ?? "",
109
+ );
115
110
  if (next === undefined) return;
116
111
  const prompt = next.trim();
117
112
  if (prompt) controller.state = { ...loop, prompt };
package/src/messages.ts CHANGED
@@ -10,7 +10,7 @@ import { LOOP_OK_TOKEN } from "./ack.js";
10
10
  import { formatDuration } from "./interval.js";
11
11
  import { CRITERIA_FILE, type LedgerPaths, PROGRESS_FILE } from "./ledger.js";
12
12
  import { appendContinuationMarker, appendPokeMarker } from "./markers.js";
13
- import type { GoalSnapshot, LoopState } from "./state.js";
13
+ import type { LoopState } from "./state.js";
14
14
 
15
15
  /** Why the loop is talking: the first turn, an ordinary turn, or after a compaction. */
16
16
  export type ContinuationKind = "kickoff" | "continue" | "reanchor";
@@ -21,30 +21,10 @@ function formatIteration(loop: LoopState): string {
21
21
  }
22
22
 
23
23
  /**
24
- * The goal-bound poke: wake the session and point at the goal without
25
- * restating it. Loops require an active pi-goal goal, so every poke turn
26
- * already carries the objective, goal_id, and goal-mode rules through
27
- * pi-goal's system prompt append; restating them here would store duplicate
28
- * tokens in the conversation on every wake (see README: cross-extension
29
- * assumption). Only the dynamic per-wake state (iteration, reason) belongs in
30
- * this tail message.
31
- */
32
- export function buildGoalPoke(loop: LoopState, reason: "goal-stalled" | "goal-waiting"): string {
33
- const lines = [
34
- `Scheduled loop wakeup ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}).`,
35
- reason === "goal-waiting"
36
- ? "This is the external wake for your waiting goal. Re-check whatever the goal was waiting on and continue."
37
- : "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.",
38
- ];
39
- if (loop.prompt) lines.push("", `Loop focus: ${loop.prompt}`);
40
- return appendPokeMarker(lines.join("\n"), loop.id, loop.iteration + 1);
41
- }
42
-
43
- /**
44
- * The standalone poke. Slim for the same reason the goal-bound one is: this
45
- * loop's own objective injection puts the objective and loop-mode rules in
46
- * the system prompt of every turn, so restating them here would store a
47
- * duplicate copy on every wake.
24
+ * The poke. Deliberately slim: the loop's own objective injection puts the
25
+ * objective and loop-mode rules in the system prompt of every turn, so
26
+ * restating them here would store a duplicate copy on every wake. Only the
27
+ * dynamic per-wake state (iteration, reason) belongs in this tail message.
48
28
  */
49
29
  export function buildObjectivePoke(
50
30
  loop: LoopState,
@@ -81,8 +61,8 @@ function addCancelledWaitHint(lines: string[], loop: LoopState): void {
81
61
  }
82
62
 
83
63
  /**
84
- * The settle-driven continuation: the message that actually paces a
85
- * standalone loop now. Pointer-sized for the same reason the pokes are — it
64
+ * The settle-driven continuation: the message that actually paces a loop.
65
+ * Pointer-sized for the same reason the pokes are — it
86
66
  * only ever fires while the loop is active, so the byte-stable system append
87
67
  * carrying the objective and loop-mode rules is guaranteed present on that
88
68
  * turn.
@@ -207,20 +187,16 @@ export function extractNextActions(summary: string, maxLength = 240): string | u
207
187
  */
208
188
  export function buildCompactionInstructions(
209
189
  loop: LoopState,
210
- /** The goal only when it is still active; a finished goal is not the objective. */
211
- goal: GoalSnapshot | undefined,
212
190
  override: string | null,
213
191
  /** The loop's ledger, when it has one. */
214
192
  ledger?: LedgerPaths,
215
193
  ): string {
216
194
  if (override) return override;
217
- const objective = goal
218
- ? `The session is working toward this goal: ${goal.text}`
219
- : loop.objective
220
- ? `The session is working toward this loop objective: ${loop.objective}`
221
- : loop.prompt
222
- ? `The session is running a recurring loop focused on: ${loop.prompt}`
223
- : "The session is running a recurring loop.";
195
+ const objective = loop.objective
196
+ ? `The session is working toward this loop objective: ${loop.objective}`
197
+ : loop.prompt
198
+ ? `The session is running a recurring loop focused on: ${loop.prompt}`
199
+ : "The session is running a recurring loop.";
224
200
  return [
225
201
  `${objective}`,
226
202
  "This summary must let that work continue seamlessly. Preserve verbatim:",
package/src/objective.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  /**
2
- * The standalone loop's objective injection.
2
+ * The loop's objective injection.
3
3
  *
4
- * A goal-bound loop gets the objective in front of the model for free:
5
- * pi-goal appends it to the system prompt on every active goal turn, which is
6
- * why pokes do not restate it. A standalone loop has no such provider, so it
7
- * has to carry its own and it does so under the same cache-stability
8
- * discipline, because the alternative (restating the objective in every poke)
9
- * is exactly the per-wake duplication that discipline exists to remove.
4
+ * The loop carries its own objective to the model on every active turn, which
5
+ * is what lets the pokes and continuations stay pointer-sized. The
6
+ * alternative restating the objective in every poke is exactly the
7
+ * per-wake duplication the cache-stability discipline below exists to remove.
10
8
  *
11
9
  * Cache-stability contract: this append lands inside the provider's cached
12
10
  * system block (Anthropic caches tools -> system -> messages as one prefix),
package/src/render.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * text plus a provenance marker comment. This transformer collapses each into
6
6
  * a one-line themed chip in the transcript. Display-only by Pi contract: the
7
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.
8
+ * delivered through sendUserMessage so the loop's own before_agent_start hook
9
+ * (which appends the objective) still fires for every poke turn.
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -29,11 +29,7 @@ export function compactPokeMessage(markdown: string) {
29
29
  if (!extractPokeMarker(markdown)) return undefined;
30
30
  const head = POKE_HEAD_PATTERN.exec(markdown);
31
31
  if (!head) return undefined;
32
- const reason = markdown.includes("external wake")
33
- ? "waiting"
34
- : markdown.includes("completion criteria are not met")
35
- ? "objective"
36
- : "stalled";
32
+ const reason = markdown.includes("wait you asked for has elapsed") ? "wait elapsed" : "stalled";
37
33
  const focus = POKE_FOCUS_PATTERN.exec(markdown)?.[1];
38
34
  return `*⏰ loop wake ${head[1]} · ${reason}${focus ? ` · ${focus}` : ""}*`;
39
35
  }
package/src/settings.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Global pi-loop settings: `~/.pi/agent/pi-loop.json`. Follows the sibling
3
- * convention (pi-goal, pi-plan-mode): an absent file means defaults and is
3
+ * convention (pi-plan-mode): an absent file means defaults and is
4
4
  * never created implicitly, saves are atomic and preserve unknown fields, and
5
5
  * an invalid file warns and falls back to defaults without being overwritten.
6
6
  */
@@ -37,6 +37,19 @@ export interface LoopSettings {
37
37
  noProgressTurns: number | null;
38
38
  /** Wall-clock expiry for a loop, e.g. "7d" (research: bound forgotten loops). */
39
39
  maxLoopDuration: string;
40
+ /**
41
+ * Detect an inline `/loop` token or a `loop:` prefixed line mid-prompt and
42
+ * point the model at the `loop_start` tool. Pi only dispatches `/loop` from
43
+ * position 0, so without this a mid-prompt invocation is silently prose.
44
+ */
45
+ inlineInvocation: boolean;
46
+ /**
47
+ * Fallback heartbeat used by an inline invocation that names no interval.
48
+ * In a settle-paced loop the interval is only a fallback — the settle
49
+ * boundary is the pacemaker — so this value is far less consequential than
50
+ * it looks; it is still clamped to MIN_INTERVAL_MS.
51
+ */
52
+ defaultInterval: string;
40
53
  compaction: LoopCompactionSettings;
41
54
  }
42
55
 
@@ -45,6 +58,8 @@ export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
45
58
  automaticTurns: 25,
46
59
  noProgressTurns: 3,
47
60
  maxLoopDuration: "7d",
61
+ inlineInvocation: true,
62
+ defaultInterval: "10m",
48
63
  compaction: {
49
64
  enabled: true,
50
65
  threshold: 0.7,
@@ -80,12 +95,24 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
80
95
  return undefined;
81
96
  }
82
97
 
98
+ const inlineInvocation = Object.hasOwn(record, "inlineInvocation")
99
+ ? record.inlineInvocation
100
+ : DEFAULT_LOOP_SETTINGS.inlineInvocation;
101
+ if (typeof inlineInvocation !== "boolean") return undefined;
102
+
103
+ const defaultInterval = Object.hasOwn(record, "defaultInterval")
104
+ ? record.defaultInterval
105
+ : DEFAULT_LOOP_SETTINGS.defaultInterval;
106
+ if (typeof defaultInterval !== "string" || parseDuration(defaultInterval) === undefined) {
107
+ return undefined;
108
+ }
109
+
83
110
  const compactionValue = Object.hasOwn(record, "compaction") ? record.compaction : undefined;
84
111
  if (compactionValue !== undefined && !ownRecord(compactionValue)) return undefined;
85
112
  const compactionRecord = ownRecord(compactionValue) ?? {};
86
- // `postCompactContinuation` was removed in favour of pi-goal owning the
87
- // post-compaction re-prompt; a file still carrying it is preserved as an
88
- // unknown field and ignored, never rejected.
113
+ // `postCompactContinuation` was removed in favour of the loop's own
114
+ // re-anchor; a file still carrying it is preserved as an unknown field and
115
+ // ignored, never rejected.
89
116
  const enabled = readBoolean(compactionRecord, "enabled", DEFAULT_LOOP_SETTINGS.compaction.enabled);
90
117
  const threshold = Object.hasOwn(compactionRecord, "threshold")
91
118
  ? compactionRecord.threshold
@@ -111,6 +138,8 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
111
138
  automaticTurns,
112
139
  noProgressTurns,
113
140
  maxLoopDuration,
141
+ inlineInvocation,
142
+ defaultInterval,
114
143
  compaction: { enabled, threshold, instructions },
115
144
  };
116
145
  }
@@ -199,6 +228,8 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
199
228
  automaticTurns: normalized.automaticTurns,
200
229
  noProgressTurns: normalized.noProgressTurns,
201
230
  maxLoopDuration: normalized.maxLoopDuration,
231
+ inlineInvocation: normalized.inlineInvocation,
232
+ defaultInterval: normalized.defaultInterval,
202
233
  compaction: { ...compaction, ...normalized.compaction },
203
234
  },
204
235
  null,
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `loop_start`: the model-invoked way into a loop.
3
+ *
4
+ * Pi only dispatches `/loop` when it starts the message, so a mid-prompt
5
+ * `quick check /loop 10m get CI green` arrives as ordinary prose. The
6
+ * inline-invocation hooks append a one-turn reminder to the system prompt for
7
+ * exactly that message, and this tool is what the reminder points at. It
8
+ * reuses `LoopController.startLoop`, the same path the `/loop` command takes,
9
+ * so replacement rules, the loop_complete availability guard, the ledger, the
10
+ * kickoff anchor, and persistence all behave identically.
11
+ *
12
+ * The hard armed-gate is the deliberate divergence from pi-goal's equivalent
13
+ * tool, which relied on prompt guidelines alone. A loop is *self-continuing*:
14
+ * a spurious start does not produce one unwanted answer, it produces turns
15
+ * until a cap. So the tool refuses outright unless the inline hint armed for
16
+ * the turn that is calling it.
17
+ *
18
+ * Registered unconditionally, like the other loop tools: the tool set is part
19
+ * of the cached request prefix, so it never changes with loop state.
20
+ */
21
+
22
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
+ import { Type } from "typebox";
24
+ import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
25
+ import { formatDuration, parseDuration, parseInterval } from "./interval.js";
26
+ import type { InlineInvocationState } from "./inline-invocation.js";
27
+ import type { LoopController } from "./loop.js";
28
+
29
+ export const LOOP_START_TOOL = "loop_start";
30
+
31
+ /** Long enough for a real objective, short enough to reject a pasted file. */
32
+ const MAX_OBJECTIVE_LENGTH = 4_000;
33
+
34
+ export function registerLoopStartTool(
35
+ pi: ExtensionAPI,
36
+ controller: LoopController,
37
+ invocation: InlineInvocationState,
38
+ ) {
39
+ pi.registerTool(
40
+ defineTool({
41
+ name: LOOP_START_TOOL,
42
+ label: "Loop Start",
43
+ description:
44
+ "Start a /loop for an objective the user explicitly invoked with an inline /loop or loop: token in their message. Only for explicit invocations: never start a loop from general conversation, your own initiative, or an instruction that merely sounds loop-like. The objective is the text following the token.",
45
+ promptSnippet:
46
+ "Start a /loop when the user's message contains an explicit inline /loop or loop: invocation",
47
+ promptGuidelines: [
48
+ "Call loop_start only when the user's message contains an explicit `/loop <objective>` or `loop: <objective>` token. Never start a loop without that token, no matter how loop-like the request sounds; the tool refuses when the turn carries no inline invocation.",
49
+ "If the user is discussing, quoting, or documenting the /loop command rather than invoking it — asking how it works, pasting a transcript, or editing text that mentions it — do not call loop_start.",
50
+ "Pass the objective text that follows the token, without the token itself. A leading interval (`10m`, `2h`) and flags like `--max 5` or `--expires 3d` become the interval, max, and expires parameters, not part of the objective.",
51
+ "Call loop_start before doing any of the objective's work, then continue working toward it in the same turn.",
52
+ "Never call loop_complete in the same turn as loop_start: the starting turn has not done the work, and completion needs cited evidence per criterion.",
53
+ ],
54
+ parameters: Type.Object({
55
+ objective: Type.String({
56
+ minLength: 1,
57
+ maxLength: MAX_OBJECTIVE_LENGTH,
58
+ description:
59
+ "The loop objective, including how the loop knows it is done: the user's text following the /loop or loop: token, verbatim, without the token, the interval, or flags.",
60
+ }),
61
+ interval: Type.Optional(
62
+ Type.String({
63
+ description:
64
+ "Fallback wake interval from the invocation, e.g. '10m' or '2h'. Omit when the user named none.",
65
+ }),
66
+ ),
67
+ max: Type.Optional(
68
+ Type.Integer({
69
+ minimum: 1,
70
+ description: "Wake cap from a --max flag in the invocation.",
71
+ }),
72
+ ),
73
+ expires: Type.Optional(
74
+ Type.String({
75
+ description: "Loop lifetime from an --expires flag in the invocation, e.g. '3d'.",
76
+ }),
77
+ ),
78
+ }),
79
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
80
+ // The gate. Everything below is ordinary validation; this is the
81
+ // one check that makes a self-continuing tool safe to expose.
82
+ if (!invocation.invokedThisTurn) {
83
+ return refusal(
84
+ `${LOOP_START_TOOL} is only available on a turn whose user message contains an explicit inline /loop or loop: invocation. This turn has none, so no loop was started. If the user wants one, they can type /loop <interval> <objective>.`,
85
+ {},
86
+ );
87
+ }
88
+ const objective = params.objective.trim();
89
+ if (!objective) {
90
+ return refusal("Loop not started: the objective is empty.", {});
91
+ }
92
+ const existing = controller.state;
93
+ if (existing && existing.status !== "stopped") {
94
+ return refusal(
95
+ `Loop not started: a loop already exists in this session (${existing.status}). The user can replace it with /loop, or stop it with /loop stop.`,
96
+ { existingLoopId: existing.id },
97
+ );
98
+ }
99
+ const intervalToken = params.interval?.trim() || controller.settings.defaultInterval;
100
+ const interval = parseInterval(intervalToken);
101
+ if (!interval) {
102
+ return refusal(
103
+ `Loop not started: invalid interval ${intervalToken}. Use <number><unit> with unit s, m, h, or d, e.g. 10m.`,
104
+ { objective },
105
+ );
106
+ }
107
+ let expiresInMs: number | undefined;
108
+ if (params.expires !== undefined) {
109
+ expiresInMs = parseDuration(params.expires.trim());
110
+ if (expiresInMs === undefined) {
111
+ return refusal(
112
+ `Loop not started: invalid expiry ${params.expires}. Use <number><unit> with unit s, m, h, or d, e.g. 3d.`,
113
+ { objective },
114
+ );
115
+ }
116
+ }
117
+ const result = controller.startLoop(ctx, {
118
+ kind: "start",
119
+ requestedMs: interval.requestedMs,
120
+ intervalMs: interval.effectiveMs,
121
+ clamped: interval.clamped,
122
+ ...(params.max === undefined ? {} : { maxIterations: params.max }),
123
+ ...(expiresInMs === undefined ? {} : { expiresInMs }),
124
+ prompt: objective,
125
+ });
126
+ if (!result.ok) return refusal(`Loop not started: ${result.message}`, { objective });
127
+ const loop = result.loop;
128
+ return {
129
+ content: toolContent(
130
+ `Loop started (loop_id ${loop.id}): ${objective}. Fallback wake every ${formatDuration(loop.intervalMs)}. Keep working the objective this turn; the loop continues at every idle boundary until you call ${LOOP_COMPLETE_TOOL} with this loop_id and cited evidence for every criterion, a cap is reached, or the user stops it. Do not call ${LOOP_COMPLETE_TOOL} in this turn.`,
131
+ ),
132
+ details: { loopId: loop.id, objective, intervalMs: loop.intervalMs },
133
+ };
134
+ },
135
+ }),
136
+ );
137
+ }
138
+
139
+ function refusal(text: string, details: Record<string, unknown>) {
140
+ return { content: toolContent(text), details, isError: true };
141
+ }
142
+
143
+ function toolContent(text: string) {
144
+ return [{ type: "text" as const, text }];
145
+ }
package/src/state.ts CHANGED
@@ -1,14 +1,12 @@
1
1
  /**
2
- * Loop state persisted as `loop-state` custom session entries, plus read-only
3
- * fail-open readers for the sibling extensions' entries: pi-goal's
4
- * `goal-state` and pi-plan-mode's `plan-mode-state`. The coupling is
5
- * deliberately loose — no package dependency, no RPC; an absent or
6
- * unrecognizable entry degrades pi-loop to standalone behavior, never crashes
7
- * it.
2
+ * Loop state persisted as `loop-state` custom session entries, plus one
3
+ * read-only fail-open reader for a sibling extension's entries:
4
+ * pi-plan-mode's `plan-mode-state`. The coupling is deliberately loose — no
5
+ * package dependency, no RPC; an absent or unrecognizable entry degrades
6
+ * pi-loop to "not planning", never crashes it.
8
7
  */
9
8
 
10
9
  export const LOOP_STATE_ENTRY_TYPE = "loop-state";
11
- export const GOAL_STATE_ENTRY_TYPE = "goal-state";
12
10
  export const PLAN_MODE_STATE_ENTRY_TYPE = "plan-mode-state";
13
11
 
14
12
  import { type LoopWait, normalizeLoopWait } from "./wait.js";
@@ -19,13 +17,16 @@ export type LoopStatus = (typeof LOOP_STATUSES)[number];
19
17
  export interface LoopState {
20
18
  id: string;
21
19
  status: LoopStatus;
22
- /** The recurring prompt; undefined for a goal-bound loop started bare. */
20
+ /**
21
+ * An optional recurring focus, restated on every loop message. Also the
22
+ * only field a loop persisted before 0.6.0 may carry instead of an
23
+ * objective; the restore shim adopts it as one.
24
+ */
23
25
  prompt?: string;
24
26
  /**
25
- * The loop's own objective and completion criteria. Its presence *is* the
26
- * loop's mode: set means standalone (this loop owns when the work is done,
27
- * ended by the `loop_complete` tool or a cap), absent means goal-bound (an
28
- * active pi-goal goal owns it, exactly as before).
27
+ * The loop's objective and completion criteria: what it works on, and what
28
+ * `loop_complete` answers for. Optional only because a loop persisted
29
+ * before 0.6.0 may predate it every loop started now has one.
29
30
  */
30
31
  objective?: string;
31
32
  intervalMs: number;
@@ -172,17 +173,11 @@ interface SessionEntryLike {
172
173
  }
173
174
 
174
175
  function lastCustomEntryData(entries: unknown[], customType: string): unknown {
175
- return lastCustomEntryDatas(entries, customType, 1)[0];
176
- }
177
-
178
- /** Newest-first data of the last `limit` custom entries of `customType`. */
179
- function lastCustomEntryDatas(entries: unknown[], customType: string, limit: number): unknown[] {
180
- const datas: unknown[] = [];
181
- for (let index = entries.length - 1; index >= 0 && datas.length < limit; index -= 1) {
176
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
182
177
  const entry = entries[index] as SessionEntryLike | undefined;
183
- if (entry?.type === "custom" && entry.customType === customType) datas.push(entry.data);
178
+ if (entry?.type === "custom" && entry.customType === customType) return entry.data;
184
179
  }
185
- return datas;
180
+ return undefined;
186
181
  }
187
182
 
188
183
  /** Restore the persisted loop state from a session branch, fail-open. */
@@ -193,77 +188,6 @@ export function restoreLoopState(entries: unknown[]): LoopState | undefined {
193
188
  return normalizeLoopState(record.loop);
194
189
  }
195
190
 
196
- /**
197
- * A standalone loop owns its own completion criteria; a goal-bound loop
198
- * delegates that to pi-goal. Presence of `objective` is the discriminator.
199
- */
200
- export function isStandaloneLoop(loop: LoopState): boolean {
201
- return loop.objective !== undefined;
202
- }
203
-
204
- export const GOAL_SAFETY_STATUSES = ["paused", "blocked", "usage_limited", "budget_limited"] as const;
205
-
206
- /** How many `goal-state` entries a clear may be scanned back through. */
207
- const GOAL_CLEAR_SCAN_LIMIT = 8;
208
-
209
- export interface GoalSnapshot {
210
- status: string;
211
- text: string;
212
- /** Present while the goal is in a goal_wait external-event wait. */
213
- waiting: boolean;
214
- iteration?: number;
215
- tokensUsed?: number;
216
- tokenBudget?: number;
217
- automaticModelTurns?: number;
218
- }
219
-
220
- /**
221
- * Read pi-goal's persisted goal, fail-open: undefined when absent or when the
222
- * entry shape is not recognizably a goal. Only fields pi-loop consumes are
223
- * extracted; unknown statuses are preserved verbatim so the caller can treat
224
- * anything outside its known sets conservatively.
225
- *
226
- * Completion race: pi-goal persists the finished goal (status "complete") and
227
- * then clears the entry (goal: null), so by the loop's next tick the last
228
- * entry is the clear. When the newest entry is a clear (or unreadable), scan
229
- * back over the consecutive run of clears for the goal they cleared: a
230
- * complete goal is reported, so the loop stops with "goal completed" instead
231
- * of pausing as goal-missing. A clear over any other status (user /goal clear
232
- * mid-flight) still reads as no goal. The scan is bounded so a long history of
233
- * clears cannot make the read walk the branch.
234
- */
235
- export function readGoalSnapshot(entries: unknown[]): GoalSnapshot | undefined {
236
- const datas = lastCustomEntryDatas(entries, GOAL_STATE_ENTRY_TYPE, GOAL_CLEAR_SCAN_LIMIT);
237
- const newest = parseGoalSnapshot(ownRecord(datas[0])?.goal);
238
- if (newest) return newest;
239
- for (let index = 1; index < datas.length; index += 1) {
240
- const cleared = parseGoalSnapshot(ownRecord(datas[index])?.goal);
241
- // Another clear or an unreadable entry: keep scanning back.
242
- if (!cleared) continue;
243
- return cleared.status === "complete" ? cleared : undefined;
244
- }
245
- return undefined;
246
- }
247
-
248
- function parseGoalSnapshot(value: unknown): GoalSnapshot | undefined {
249
- const goal = ownRecord(value);
250
- if (!goal) return undefined;
251
- const status = typeof goal.status === "string" ? goal.status : undefined;
252
- const text = typeof goal.text === "string" ? goal.text.trim() : "";
253
- if (!status || !text) return undefined;
254
- return {
255
- status,
256
- text,
257
- waiting: ownRecord(goal.waiting) !== undefined,
258
- ...(isNonNegativeNumber(goal.iteration) ? { iteration: goal.iteration } : {}),
259
- ...(isNonNegativeNumber(goal.tokensUsed) ? { tokensUsed: goal.tokensUsed } : {}),
260
- ...(isNonNegativeNumber(goal.tokenBudget) ? { tokenBudget: goal.tokenBudget } : {}),
261
- ...(isNonNegativeNumber(goal.automaticModelTurns)
262
- ? { automaticModelTurns: goal.automaticModelTurns }
263
- : {}),
264
- };
265
- }
266
-
267
191
  /** Read pi-plan-mode's persisted state, fail-open: absent or malformed = not planning. */
268
192
  export function readPlanModeEnabled(entries: unknown[]): boolean {
269
193
  const data = ownRecord(lastCustomEntryData(entries, PLAN_MODE_STATE_ENTRY_TYPE));
@@ -288,10 +212,6 @@ function isPositiveSafeInteger(value: unknown): value is number {
288
212
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
289
213
  }
290
214
 
291
- function isNonNegativeNumber(value: unknown): value is number {
292
- return typeof value === "number" && Number.isFinite(value) && value >= 0;
293
- }
294
-
295
215
  function isTimestamp(value: unknown): value is number {
296
216
  return (
297
217
  typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= MAX_TIMESTAMP
package/src/wait-tool.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * Registered unconditionally, like `loop_complete`, and for the same reason:
11
11
  * tools are part of the cached request prefix, so adding or removing one
12
12
  * mid-session invalidates the whole conversation cache. It refuses when no
13
- * standalone loop is active.
13
+ * loop is active.
14
14
  */
15
15
 
16
16
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -59,7 +59,7 @@ export function registerLoopWaitTool(pi: ExtensionAPI, controller: LoopControlle
59
59
  if (!loop || loop.objective === undefined) {
60
60
  return {
61
61
  content: toolContent(
62
- "No standalone /loop is active. loop_wait only applies to a loop that carries its own objective; a goal-bound loop waits through pi-goal.",
62
+ "No /loop with an objective is active, so there is nothing to wait on. Start one with /loop <interval> <objective>.",
63
63
  ),
64
64
  details: {},
65
65
  isError: true,