@hank-warren/pi-loop 0.5.0 → 0.7.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/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,29 +17,30 @@ 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;
32
- /** Delivered-poke cap; null means unlimited. */
33
- maxIterations: number | null;
34
- /** Proactive-compaction threshold fraction, or null when disabled per loop. */
35
- compactAt: number | null;
36
33
  /**
37
- * Turns this loop caused; null means unlimited. Counted separately from
38
- * `maxIterations` because one wake now yields many turns: a settle-driven
39
- * continuation chain runs without any wake at all.
34
+ * Cap on the turns this loop causes (continuations plus pokes); null means
35
+ * unlimited. The only cap: a settle-driven continuation chain runs without
36
+ * any wake at all, so a wake cap bounded nothing this one does not.
40
37
  */
41
- maxAutomaticTurns: number | null;
42
- /** Delivered wakes so far (fallback pokes only). */
38
+ maxTurns: number | null;
39
+ /** Proactive-compaction threshold fraction, or null when disabled per loop. */
40
+ compactAt: number | null;
41
+ /** Delivered wakes so far (fallback pokes only); uncapped, and displayed. */
43
42
  iteration: number;
44
- /** Loop-caused turns so far (continuations + pokes). */
43
+ /** Loop-caused turns so far (continuations + pokes): what `maxTurns` caps. */
45
44
  automaticTurns: number;
46
45
  startedAt: number;
47
46
  expiresAt: number;
@@ -90,8 +89,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
90
89
  }
91
90
  const intervalMs = record.intervalMs;
92
91
  if (!isPositiveSafeInteger(intervalMs)) return undefined;
93
- const maxIterations = record.maxIterations;
94
- if (maxIterations !== null && !isPositiveSafeInteger(maxIterations)) return undefined;
92
+ const maxTurns = readTurnCap(record);
93
+ if (maxTurns === false) return undefined;
95
94
  const compactAt = record.compactAt;
96
95
  if (
97
96
  compactAt !== null &&
@@ -99,16 +98,12 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
99
98
  ) {
100
99
  return undefined;
101
100
  }
102
- const maxAutomaticTurns = Object.hasOwn(record, "maxAutomaticTurns")
103
- ? record.maxAutomaticTurns
104
- : null;
105
- if (maxAutomaticTurns !== null && !isPositiveSafeInteger(maxAutomaticTurns)) return undefined;
106
101
  const iteration = record.iteration;
107
102
  if (typeof iteration !== "number" || !Number.isSafeInteger(iteration) || iteration < 0) {
108
103
  return undefined;
109
104
  }
110
- // Loops persisted before the two-counter split carry no automaticTurns; an
111
- // absent counter restores as zero rather than rejecting the whole state.
105
+ // A loop persisted before the turn counter existed carries no automaticTurns;
106
+ // an absent counter restores as zero rather than rejecting the whole state.
112
107
  const automaticTurns = Object.hasOwn(record, "automaticTurns") ? record.automaticTurns : 0;
113
108
  if (
114
109
  typeof automaticTurns !== "number" ||
@@ -146,8 +141,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
146
141
  ...(prompt === undefined ? {} : { prompt }),
147
142
  ...(objective === undefined ? {} : { objective }),
148
143
  intervalMs,
149
- maxIterations: maxIterations as number | null,
150
- maxAutomaticTurns: maxAutomaticTurns as number | null,
144
+ maxTurns,
151
145
  compactAt: compactAt as number | null,
152
146
  iteration,
153
147
  automaticTurns,
@@ -163,6 +157,33 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
163
157
  };
164
158
  }
165
159
 
160
+ /**
161
+ * The turn cap, adopting the caps a loop persisted by an older version
162
+ * carries: `maxAutomaticTurns` (turns) and `maxIterations` (wakes). An
163
+ * in-flight loop restored mid-upgrade keeps the tighter of them rather than
164
+ * having its bound widened or being dropped as unparsable; its wake *counter*
165
+ * is kept for display but no longer caps anything. Returns the cap, or false
166
+ * when a present value is invalid.
167
+ */
168
+ function readTurnCap(record: Record<string, unknown>): number | null | false {
169
+ if (Object.hasOwn(record, "maxTurns")) {
170
+ const value = record.maxTurns;
171
+ if (value === null) return null;
172
+ return isPositiveSafeInteger(value) ? value : false;
173
+ }
174
+ let adopted: number | null | undefined;
175
+ for (const key of ["maxAutomaticTurns", "maxIterations"]) {
176
+ if (!Object.hasOwn(record, key)) continue;
177
+ const value = record[key];
178
+ if (value !== null && !isPositiveSafeInteger(value)) return false;
179
+ const cap = value as number | null;
180
+ // null is unlimited, so it only wins when every legacy cap is unlimited.
181
+ if (adopted === undefined || adopted === null) adopted = cap;
182
+ else if (cap !== null) adopted = Math.min(adopted, cap);
183
+ }
184
+ return adopted === undefined ? null : adopted;
185
+ }
186
+
166
187
  // --- session-branch entry readers ---
167
188
 
168
189
  interface SessionEntryLike {
@@ -172,17 +193,11 @@ interface SessionEntryLike {
172
193
  }
173
194
 
174
195
  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) {
196
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
182
197
  const entry = entries[index] as SessionEntryLike | undefined;
183
- if (entry?.type === "custom" && entry.customType === customType) datas.push(entry.data);
198
+ if (entry?.type === "custom" && entry.customType === customType) return entry.data;
184
199
  }
185
- return datas;
200
+ return undefined;
186
201
  }
187
202
 
188
203
  /** Restore the persisted loop state from a session branch, fail-open. */
@@ -193,77 +208,6 @@ export function restoreLoopState(entries: unknown[]): LoopState | undefined {
193
208
  return normalizeLoopState(record.loop);
194
209
  }
195
210
 
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
211
  /** Read pi-plan-mode's persisted state, fail-open: absent or malformed = not planning. */
268
212
  export function readPlanModeEnabled(entries: unknown[]): boolean {
269
213
  const data = ownRecord(lastCustomEntryData(entries, PLAN_MODE_STATE_ENTRY_TYPE));
@@ -288,10 +232,6 @@ function isPositiveSafeInteger(value: unknown): value is number {
288
232
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
289
233
  }
290
234
 
291
- function isNonNegativeNumber(value: unknown): value is number {
292
- return typeof value === "number" && Number.isFinite(value) && value >= 0;
293
- }
294
-
295
235
  function isTimestamp(value: unknown): value is number {
296
236
  return (
297
237
  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,
package/src/widget.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The loop widget: a compact themed line above the editor mirroring the
3
- * footer status (interval · iteration/cap · next wake), with the loop focus
3
+ * footer status (interval · loop turns/cap · next wake), with the loop focus
4
4
  * dimmed below it when set.
5
5
  *
6
6
  * Presentation only: every entry point tolerates a host without setWidget
@@ -61,13 +61,15 @@ export function clearLoopWidget(ui: WidgetHost) {
61
61
  export function loopWidgetLine(view: LoopWidgetView) {
62
62
  const loop = view.loop;
63
63
  if (loop.status === "paused") return "⏸ loop paused";
64
- const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
64
+ const cap = loop.maxTurns === null ? "∞" : `${loop.maxTurns}`;
65
65
  const next = view.wakePending
66
66
  ? "next on idle"
67
67
  : view.nextWakeAt !== undefined
68
68
  ? `next ${formatClock(view.nextWakeAt)}`
69
69
  : "next unscheduled";
70
- return `⟳ loop every ${formatDuration(loop.intervalMs)} · ${loop.iteration}/${cap} · ${next}`;
70
+ // The turn counter, not the wake counter: the cap counts turns, so a
71
+ // progress line against that cap has to count the same thing.
72
+ return `⟳ loop every ${formatDuration(loop.intervalMs)} · ${loop.automaticTurns}/${cap} · ${next}`;
71
73
  }
72
74
 
73
75
  function identity(text: string) {