@hank-warren/pi-loop 0.4.1 → 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/state.ts CHANGED
@@ -1,29 +1,32 @@
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
 
12
+ import { type LoopWait, normalizeLoopWait } from "./wait.js";
13
+
14
14
  export const LOOP_STATUSES = ["active", "paused", "stopped"] as const;
15
15
  export type LoopStatus = (typeof LOOP_STATUSES)[number];
16
16
 
17
17
  export interface LoopState {
18
18
  id: string;
19
19
  status: LoopStatus;
20
- /** 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
+ */
21
25
  prompt?: string;
22
26
  /**
23
- * The loop's own objective and completion criteria. Its presence *is* the
24
- * loop's mode: set means standalone (this loop owns when the work is done,
25
- * ended by the `loop_complete` tool or a cap), absent means goal-bound (an
26
- * 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.
27
30
  */
28
31
  objective?: string;
29
32
  intervalMs: number;
@@ -31,11 +34,37 @@ export interface LoopState {
31
34
  maxIterations: number | null;
32
35
  /** Proactive-compaction threshold fraction, or null when disabled per loop. */
33
36
  compactAt: number | null;
34
- /** Delivered pokes so far. */
37
+ /**
38
+ * Turns this loop caused; null means unlimited. Counted separately from
39
+ * `maxIterations` because one wake now yields many turns: a settle-driven
40
+ * continuation chain runs without any wake at all.
41
+ */
42
+ maxAutomaticTurns: number | null;
43
+ /** Delivered wakes so far (fallback pokes only). */
35
44
  iteration: number;
45
+ /** Loop-caused turns so far (continuations + pokes). */
46
+ automaticTurns: number;
36
47
  startedAt: number;
37
48
  expiresAt: number;
38
49
  lastWakeAt?: number;
50
+ /** Set while the model has declared an external wait through `loop_wait`. */
51
+ waiting?: LoopWait;
52
+ /**
53
+ * The reason of a wait cancelled by something other than its own deadline,
54
+ * surfaced once in the next loop message so the context is not simply lost.
55
+ */
56
+ cancelledWaitReason?: string;
57
+ /** Consecutive tool-free loop turns with identical visible output. */
58
+ toolFreeRepeatCount?: number;
59
+ lastFingerprint?: string;
60
+ /** Why a paused loop paused, for the widget and status after a restore. */
61
+ pauseCause?: string;
62
+ /**
63
+ * Set once the expiry's final wake has been delivered. The loop is still
64
+ * active for exactly that one turn, so the objective append is present
65
+ * while it writes its state down; the next settle stops it.
66
+ */
67
+ expiring?: true;
39
68
  }
40
69
 
41
70
  const MAX_PROMPT_LENGTH = 100_000;
@@ -71,12 +100,47 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
71
100
  ) {
72
101
  return undefined;
73
102
  }
103
+ const maxAutomaticTurns = Object.hasOwn(record, "maxAutomaticTurns")
104
+ ? record.maxAutomaticTurns
105
+ : null;
106
+ if (maxAutomaticTurns !== null && !isPositiveSafeInteger(maxAutomaticTurns)) return undefined;
74
107
  const iteration = record.iteration;
75
108
  if (typeof iteration !== "number" || !Number.isSafeInteger(iteration) || iteration < 0) {
76
109
  return undefined;
77
110
  }
111
+ // Loops persisted before the two-counter split carry no automaticTurns; an
112
+ // absent counter restores as zero rather than rejecting the whole state.
113
+ const automaticTurns = Object.hasOwn(record, "automaticTurns") ? record.automaticTurns : 0;
114
+ if (
115
+ typeof automaticTurns !== "number" ||
116
+ !Number.isSafeInteger(automaticTurns) ||
117
+ automaticTurns < 0
118
+ ) {
119
+ return undefined;
120
+ }
78
121
  if (!isTimestamp(record.startedAt) || !isTimestamp(record.expiresAt)) return undefined;
79
122
  if (record.lastWakeAt !== undefined && !isTimestamp(record.lastWakeAt)) return undefined;
123
+ let waiting: LoopWait | undefined;
124
+ if (record.waiting !== undefined) {
125
+ waiting = normalizeLoopWait(record.waiting);
126
+ if (!waiting) return undefined;
127
+ }
128
+ const cancelledWaitReason = optionalText(record.cancelledWaitReason);
129
+ if (cancelledWaitReason === false) return undefined;
130
+ const pauseCause = optionalText(record.pauseCause);
131
+ if (pauseCause === false) return undefined;
132
+ const toolFreeRepeatCount = record.toolFreeRepeatCount;
133
+ if (
134
+ toolFreeRepeatCount !== undefined &&
135
+ (typeof toolFreeRepeatCount !== "number" ||
136
+ !Number.isSafeInteger(toolFreeRepeatCount) ||
137
+ toolFreeRepeatCount < 0)
138
+ ) {
139
+ return undefined;
140
+ }
141
+ const lastFingerprint = optionalText(record.lastFingerprint);
142
+ if (lastFingerprint === false) return undefined;
143
+ if (record.expiring !== undefined && record.expiring !== true) return undefined;
80
144
  return {
81
145
  id,
82
146
  status: status as LoopStatus,
@@ -84,11 +148,19 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
84
148
  ...(objective === undefined ? {} : { objective }),
85
149
  intervalMs,
86
150
  maxIterations: maxIterations as number | null,
151
+ maxAutomaticTurns: maxAutomaticTurns as number | null,
87
152
  compactAt: compactAt as number | null,
88
153
  iteration,
154
+ automaticTurns,
89
155
  startedAt: record.startedAt as number,
90
156
  expiresAt: record.expiresAt as number,
91
157
  ...(record.lastWakeAt === undefined ? {} : { lastWakeAt: record.lastWakeAt as number }),
158
+ ...(waiting === undefined ? {} : { waiting }),
159
+ ...(cancelledWaitReason === undefined ? {} : { cancelledWaitReason }),
160
+ ...(toolFreeRepeatCount === undefined ? {} : { toolFreeRepeatCount }),
161
+ ...(lastFingerprint === undefined ? {} : { lastFingerprint }),
162
+ ...(pauseCause === undefined ? {} : { pauseCause }),
163
+ ...(record.expiring === true ? { expiring: true as const } : {}),
92
164
  };
93
165
  }
94
166
 
@@ -101,17 +173,11 @@ interface SessionEntryLike {
101
173
  }
102
174
 
103
175
  function lastCustomEntryData(entries: unknown[], customType: string): unknown {
104
- return lastCustomEntryDatas(entries, customType, 1)[0];
105
- }
106
-
107
- /** Newest-first data of the last `limit` custom entries of `customType`. */
108
- function lastCustomEntryDatas(entries: unknown[], customType: string, limit: number): unknown[] {
109
- const datas: unknown[] = [];
110
- for (let index = entries.length - 1; index >= 0 && datas.length < limit; index -= 1) {
176
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
111
177
  const entry = entries[index] as SessionEntryLike | undefined;
112
- if (entry?.type === "custom" && entry.customType === customType) datas.push(entry.data);
178
+ if (entry?.type === "custom" && entry.customType === customType) return entry.data;
113
179
  }
114
- return datas;
180
+ return undefined;
115
181
  }
116
182
 
117
183
  /** Restore the persisted loop state from a session branch, fail-open. */
@@ -122,77 +188,6 @@ export function restoreLoopState(entries: unknown[]): LoopState | undefined {
122
188
  return normalizeLoopState(record.loop);
123
189
  }
124
190
 
125
- /**
126
- * A standalone loop owns its own completion criteria; a goal-bound loop
127
- * delegates that to pi-goal. Presence of `objective` is the discriminator.
128
- */
129
- export function isStandaloneLoop(loop: LoopState): boolean {
130
- return loop.objective !== undefined;
131
- }
132
-
133
- export const GOAL_SAFETY_STATUSES = ["paused", "blocked", "usage_limited", "budget_limited"] as const;
134
-
135
- /** How many `goal-state` entries a clear may be scanned back through. */
136
- const GOAL_CLEAR_SCAN_LIMIT = 8;
137
-
138
- export interface GoalSnapshot {
139
- status: string;
140
- text: string;
141
- /** Present while the goal is in a goal_wait external-event wait. */
142
- waiting: boolean;
143
- iteration?: number;
144
- tokensUsed?: number;
145
- tokenBudget?: number;
146
- automaticModelTurns?: number;
147
- }
148
-
149
- /**
150
- * Read pi-goal's persisted goal, fail-open: undefined when absent or when the
151
- * entry shape is not recognizably a goal. Only fields pi-loop consumes are
152
- * extracted; unknown statuses are preserved verbatim so the caller can treat
153
- * anything outside its known sets conservatively.
154
- *
155
- * Completion race: pi-goal persists the finished goal (status "complete") and
156
- * then clears the entry (goal: null), so by the loop's next tick the last
157
- * entry is the clear. When the newest entry is a clear (or unreadable), scan
158
- * back over the consecutive run of clears for the goal they cleared: a
159
- * complete goal is reported, so the loop stops with "goal completed" instead
160
- * of pausing as goal-missing. A clear over any other status (user /goal clear
161
- * mid-flight) still reads as no goal. The scan is bounded so a long history of
162
- * clears cannot make the read walk the branch.
163
- */
164
- export function readGoalSnapshot(entries: unknown[]): GoalSnapshot | undefined {
165
- const datas = lastCustomEntryDatas(entries, GOAL_STATE_ENTRY_TYPE, GOAL_CLEAR_SCAN_LIMIT);
166
- const newest = parseGoalSnapshot(ownRecord(datas[0])?.goal);
167
- if (newest) return newest;
168
- for (let index = 1; index < datas.length; index += 1) {
169
- const cleared = parseGoalSnapshot(ownRecord(datas[index])?.goal);
170
- // Another clear or an unreadable entry: keep scanning back.
171
- if (!cleared) continue;
172
- return cleared.status === "complete" ? cleared : undefined;
173
- }
174
- return undefined;
175
- }
176
-
177
- function parseGoalSnapshot(value: unknown): GoalSnapshot | undefined {
178
- const goal = ownRecord(value);
179
- if (!goal) return undefined;
180
- const status = typeof goal.status === "string" ? goal.status : undefined;
181
- const text = typeof goal.text === "string" ? goal.text.trim() : "";
182
- if (!status || !text) return undefined;
183
- return {
184
- status,
185
- text,
186
- waiting: ownRecord(goal.waiting) !== undefined,
187
- ...(isNonNegativeNumber(goal.iteration) ? { iteration: goal.iteration } : {}),
188
- ...(isNonNegativeNumber(goal.tokensUsed) ? { tokensUsed: goal.tokensUsed } : {}),
189
- ...(isNonNegativeNumber(goal.tokenBudget) ? { tokenBudget: goal.tokenBudget } : {}),
190
- ...(isNonNegativeNumber(goal.automaticModelTurns)
191
- ? { automaticModelTurns: goal.automaticModelTurns }
192
- : {}),
193
- };
194
- }
195
-
196
191
  /** Read pi-plan-mode's persisted state, fail-open: absent or malformed = not planning. */
197
192
  export function readPlanModeEnabled(entries: unknown[]): boolean {
198
193
  const data = ownRecord(lastCustomEntryData(entries, PLAN_MODE_STATE_ENTRY_TYPE));
@@ -205,12 +200,16 @@ function ownRecord(value: unknown): Record<string, unknown> | undefined {
205
200
  : undefined;
206
201
  }
207
202
 
208
- function isPositiveSafeInteger(value: unknown): value is number {
209
- return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
203
+ /** A present-but-optional string: the value, undefined when absent, false when invalid. */
204
+ function optionalText(value: unknown): string | undefined | false {
205
+ if (value === undefined) return undefined;
206
+ if (typeof value !== "string") return false;
207
+ const trimmed = value.trim();
208
+ return trimmed && trimmed.length <= MAX_PROMPT_LENGTH ? trimmed : false;
210
209
  }
211
210
 
212
- function isNonNegativeNumber(value: unknown): value is number {
213
- return typeof value === "number" && Number.isFinite(value) && value >= 0;
211
+ function isPositiveSafeInteger(value: unknown): value is number {
212
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
214
213
  }
215
214
 
216
215
  function isTimestamp(value: unknown): value is number {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `loop_wait`: the loop's adaptive wake.
3
+ *
4
+ * Without it a loop has exactly one answer to "progress depends on something
5
+ * outside this session": keep continuing, and burn turns re-checking. With
6
+ * it, the model says what it is waiting for and roughly how long, the loop
7
+ * stops continuing on its own, and the wait's deadline becomes the next thing
8
+ * that speaks.
9
+ *
10
+ * Registered unconditionally, like `loop_complete`, and for the same reason:
11
+ * tools are part of the cached request prefix, so adding or removing one
12
+ * mid-session invalidates the whole conversation cache. It refuses when no
13
+ * loop is active.
14
+ */
15
+
16
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { Type } from "typebox";
18
+ import type { LoopController } from "./loop.js";
19
+ import { formatDuration } from "./interval.js";
20
+ import { MAX_WAIT_DELAY_MS, MAX_WAIT_REASON_LENGTH, MIN_WAIT_DELAY_MS } from "./wait.js";
21
+
22
+ export const LOOP_WAIT_TOOL = "loop_wait";
23
+
24
+ export function registerLoopWaitTool(pi: ExtensionAPI, controller: LoopController) {
25
+ pi.registerTool(
26
+ defineTool({
27
+ name: LOOP_WAIT_TOOL,
28
+ label: "Loop Wait",
29
+ description:
30
+ "Pause the active /loop's automatic continuations while progress depends on an external event. The loop stays active; it simply stops continuing on its own until resume_after_ms elapses or something else wakes the session. Never use it for ordinary unfinished work.",
31
+ promptSnippet: "Wait for an external event without ending the active /loop",
32
+ promptGuidelines: [
33
+ "Call loop_wait only when progress genuinely depends on a later external event (a CI run, a deploy, a human reply), never for ordinary unfinished work and never as a way to end a turn early.",
34
+ "Give a one-sentence reason: it is shown in the loop widget and status, and it is the only record of what the loop was waiting for.",
35
+ "Never poll for work Pi already notifies you about — background processes, subagents, and tool completions wake the session on their own.",
36
+ // The cache-window guidance: a wake just after the provider's
37
+ // prompt-cache TTL pays a full cache miss for nothing.
38
+ "Avoid resume_after_ms near 300000 (5 minutes): that is the prompt-cache dead zone, where the cache has just expired and the next turn re-reads the whole conversation at full price.",
39
+ "Use a value at or below 270000 only when actively polling external state that nothing else will report. Otherwise commit to 1200000 or more.",
40
+ `resume_after_ms is clamped to [${MIN_WAIT_DELAY_MS}, ${MAX_WAIT_DELAY_MS}]; the clamped value is echoed back. Omitting it waits until something else wakes the session.`,
41
+ "Call loop_wait alone: sibling tool calls in the same turn can prevent the turn from ending, which is the point of the wait.",
42
+ ],
43
+ parameters: Type.Object({
44
+ reason: Type.String({
45
+ minLength: 1,
46
+ maxLength: MAX_WAIT_REASON_LENGTH,
47
+ description:
48
+ "One sentence naming the external event being waited for. Shown in the loop widget and status.",
49
+ }),
50
+ resume_after_ms: Type.Optional(
51
+ Type.Number({
52
+ description:
53
+ "Optional bounded safety wake-up in milliseconds, clamped to [60000, 3600000]. Omit to wait until something else wakes the session.",
54
+ }),
55
+ ),
56
+ }),
57
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
58
+ const loop = controller.state;
59
+ if (!loop || loop.objective === undefined) {
60
+ return {
61
+ content: toolContent(
62
+ "No /loop with an objective is active, so there is nothing to wait on. Start one with /loop <interval> <objective>.",
63
+ ),
64
+ details: {},
65
+ isError: true,
66
+ };
67
+ }
68
+ if (loop.status !== "active") {
69
+ return {
70
+ content: toolContent(`The /loop is ${loop.status}; there is nothing to wait on.`),
71
+ details: { loopId: loop.id, status: loop.status },
72
+ isError: true,
73
+ };
74
+ }
75
+ const reason = params.reason.trim();
76
+ if (!reason) {
77
+ return {
78
+ content: toolContent("loop_wait needs a reason: name the external event in one sentence."),
79
+ details: { loopId: loop.id },
80
+ isError: true,
81
+ };
82
+ }
83
+ const wait = controller.enterWait(reason, params.resume_after_ms);
84
+ if (!wait) {
85
+ return {
86
+ content: toolContent("The /loop could not enter a wait; it is no longer active."),
87
+ details: { loopId: loop.id },
88
+ isError: true,
89
+ };
90
+ }
91
+ const deadline =
92
+ wait.effectiveMs === undefined
93
+ ? "No deadline: the loop stays quiet until something else wakes the session."
94
+ : `${wait.clamped ? `Requested ${Math.round((wait.requestedMs ?? 0) / 1000)}s, clamped to ` : "Waking in "}${formatDuration(wait.effectiveMs)}.`;
95
+ return {
96
+ content: toolContent(
97
+ `Loop waiting: ${reason}\n${deadline} Automatic continuations are held; the loop stays active.`,
98
+ ),
99
+ details: {
100
+ loopId: loop.id,
101
+ reason,
102
+ ...(wait.requestedMs === undefined ? {} : { requestedMs: wait.requestedMs }),
103
+ ...(wait.effectiveMs === undefined ? {} : { effectiveMs: wait.effectiveMs }),
104
+ clamped: wait.clamped,
105
+ },
106
+ };
107
+ },
108
+ }),
109
+ );
110
+ }
111
+
112
+ function toolContent(text: string) {
113
+ return [{ type: "text" as const, text }];
114
+ }
package/src/wait.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `loop_wait` state: the model's declaration that progress now depends on
3
+ * something outside the session.
4
+ *
5
+ * A wait does not stop the loop and does not cancel the pacemaker. It
6
+ * supersedes the *next* fallback wake — the loop stops continuing on its own
7
+ * and the wait's own deadline becomes the next thing that speaks. That is the
8
+ * whole difference between "waiting" and "paused": a paused loop needs the
9
+ * user, a waiting loop needs the world.
10
+ *
11
+ * The clamp is a range, not a floor. Below 60s a wait is polling, which is
12
+ * what the tool exists to replace; above an hour it stops being a wait and
13
+ * becomes an abandonment, and the fallback heartbeat covers that case better.
14
+ */
15
+
16
+ export interface LoopWait {
17
+ reason: string;
18
+ resumeAt?: number;
19
+ }
20
+
21
+ export const MAX_WAIT_REASON_LENGTH = 1_000;
22
+ export const MIN_WAIT_DELAY_MS = 60_000;
23
+ export const MAX_WAIT_DELAY_MS = 3_600_000;
24
+ const MAX_TIMESTAMP = 8_640_000_000_000_000;
25
+
26
+ export interface ResolvedWaitDelay {
27
+ requestedMs?: number;
28
+ effectiveMs?: number;
29
+ clamped: boolean;
30
+ }
31
+
32
+ export function resolveWaitDelay(resumeAfterMs: number | undefined): ResolvedWaitDelay {
33
+ if (resumeAfterMs === undefined || !Number.isFinite(resumeAfterMs)) return { clamped: false };
34
+ const effectiveMs = Math.min(MAX_WAIT_DELAY_MS, Math.max(MIN_WAIT_DELAY_MS, resumeAfterMs));
35
+ return { requestedMs: resumeAfterMs, effectiveMs, clamped: effectiveMs !== resumeAfterMs };
36
+ }
37
+
38
+ export function createLoopWait(
39
+ reason: string,
40
+ resumeAfterMs: number | undefined,
41
+ now: number,
42
+ ): LoopWait {
43
+ const { effectiveMs } = resolveWaitDelay(resumeAfterMs);
44
+ return {
45
+ reason,
46
+ ...(effectiveMs === undefined ? {} : { resumeAt: now + effectiveMs }),
47
+ };
48
+ }
49
+
50
+ export function normalizeLoopWait(value: unknown): LoopWait | undefined {
51
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
52
+ const record = value as Record<string, unknown>;
53
+ const reason = typeof record.reason === "string" ? record.reason.trim() : "";
54
+ if (!reason || reason.length > MAX_WAIT_REASON_LENGTH) return undefined;
55
+ if (!Object.hasOwn(record, "resumeAt")) return { reason };
56
+ const resumeAt = record.resumeAt;
57
+ if (
58
+ typeof resumeAt !== "number" ||
59
+ !Number.isSafeInteger(resumeAt) ||
60
+ resumeAt < 0 ||
61
+ resumeAt > MAX_TIMESTAMP
62
+ ) {
63
+ return undefined;
64
+ }
65
+ return { reason, resumeAt };
66
+ }
67
+
68
+ /**
69
+ * A single-slot timer whose callbacks are generation-guarded, so a wait that
70
+ * was cleared or replaced can never fire against the loop that replaced it.
71
+ */
72
+ export class LoopWaitTimer {
73
+ private generation = 0;
74
+ private timer: NodeJS.Timeout | undefined;
75
+
76
+ clear(): void {
77
+ this.generation += 1;
78
+ if (!this.timer) return;
79
+ clearTimeout(this.timer);
80
+ this.timer = undefined;
81
+ }
82
+
83
+ schedule(resumeAt: number, onDue: () => void, now: number = Date.now()): void {
84
+ this.clear();
85
+ const generation = this.generation;
86
+ const delay = Math.max(0, Math.min(MAX_WAIT_DELAY_MS, resumeAt - now));
87
+ this.timer = setTimeout(() => {
88
+ if (generation !== this.generation) return;
89
+ this.timer = undefined;
90
+ onDue();
91
+ }, delay);
92
+ // A pending wake must never hold the process open.
93
+ this.timer.unref?.();
94
+ }
95
+ }