@brettinternet/pi-loop 0.1.0 → 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +25 -18
  2. package/index.ts +768 -69
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,28 +1,35 @@
1
- # Pi Loop
1
+ # pi-loop
2
2
 
3
- `@brettinternet/pi-loop` runs one prompt for a bounded number of iterations. Every iteration gets a new Pi session, so files remain available while conversation history does not.
3
+ Run a prompt repeatedly, with a fresh session for every iteration.
4
4
 
5
- ## Commands
5
+ ```bash
6
+ pi install npm:@brettinternet/pi-loop
7
+ ```
6
8
 
7
9
  ```text
8
- /loop <count> <prompt> Start a loop; count must be positive
9
- /loop <count> While active or stopping, replace the future-iteration budget
10
- /loop +<count> Add future iterations while active or stopping
11
- /loop -<count> Remove future iterations while active or stopping
12
- /loop status Show run, iteration, budget, and pending retune
13
- /loop Gracefully stop after the active iteration
14
- /loop stop Gracefully stop, or stop a paused loop immediately
15
- /loop resume Retry a paused iteration, or cancel a pending stop
10
+ /loop <count> [--delay <duration>] <prompt>
11
+ /loop for <duration> --delay <duration> <prompt>
12
+ /loop <count>
13
+ /loop +<count>
14
+ /loop -<count>
15
+ /loop delay <duration>
16
+ /loop prompt <text>
17
+ /loop append <text>
18
+ /loop status
19
+ /loop
20
+ /loop end
21
+ /loop resume
22
+ /loop next
16
23
  ```
17
24
 
18
- A retune changes only the next boundary's future budget; it never changes the prompt. Retuning or resuming while a graceful stop is pending cancels the stop and reuses the loop's prompt. A subtraction may reduce that budget to zero, ending the loop after the active iteration. Invalid or ambiguous forms are rejected instead of guessing.
19
-
20
- Loops continue only after `agent_settled`. Aborted or error assistant output pauses the loop without consuming an iteration. State is stored in custom session entries, and each replacement records its parent session while keeping conversational messages out of the new session. The compact status widget is shown only while a loop is active, stopping, or paused. It counts down (`loop active 4/4`, then `3/4`) and shows the prompt after a middle dot, truncated to one line at the current terminal width.
25
+ Durations use `ms`, `s`, `m`, `h`, or `d`. Delays range from 1 second to 24 hours. Timed loops run for at most 30 days.
21
26
 
22
- The extension does not use dialogs and is safe to load in print, JSON, and RPC modes.
27
+ Errors retry after 30 seconds, 1 minute, and 2 minutes, then pause. Aborting pauses the loop. A `loop_pause` request pauses for human blockers. Recovery preserves the loop so you can resume or advance it.
23
28
 
24
- Install it with:
29
+ Chain commands in the prompt:
25
30
 
26
- ```sh
27
- pi install npm:@brettinternet/pi-loop
31
+ ```text
32
+ /loop 10 /wait 10m /skill:myskill skill argument here
28
33
  ```
34
+
35
+ Built-in interactive commands cannot be chained.
package/index.ts CHANGED
@@ -7,13 +7,27 @@ import type {
7
7
  SessionManager,
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { truncateToWidth } from "@earendil-works/pi-tui";
10
+ import { Type } from "typebox";
10
11
 
11
12
  export const LOOP_STATE_ENTRY = "pi-loop-state-v1";
12
13
  export const LOOP_WIDGET_KEY = "pi-loop";
13
14
  export const LOOP_USAGE =
14
- "usage: /loop <positive-count> <prompt> | /loop <positive-count> | /loop <+|-><count> | /loop status | /loop resume | /loop stop";
15
+ "usage: /loop <positive-count> [--delay <duration>] <prompt> | /loop for <duration> --delay <duration> <prompt> | /loop <positive-count> | /loop <+|-><count> | /loop delay <duration> | /loop prompt <text> | /loop append <text> | /loop status | /loop resume | /loop next | /loop end";
16
+
17
+ export const MIN_LOOP_DELAY_MS = 1_000;
18
+ export const MAX_LOOP_DELAY_MS = 24 * 60 * 60 * 1_000;
19
+ export const MAX_LOOP_TIMEFRAME_MS = 30 * 24 * 60 * 60 * 1_000;
20
+ export const DEFAULT_LOOP_RETRIES = 3;
21
+ export const DEFAULT_LOOP_RETRY_DELAY_MS = 30_000;
22
+
23
+ const LOOP_CONTINUATION_PROMPT =
24
+ "Continue the current loop iteration from where you left off without repeating completed work.";
25
+ const LOOP_AGENT_GUIDANCE = `## Active Loop
26
+
27
+ This session is part of an active unattended loop. If no useful work can continue without human input, credentials, permissions, or another non-transient external dependency, call loop_pause with the blocker. Do not pause for a temporary condition expected to resolve in a later iteration.`;
15
28
 
16
29
  export type LoopStatus = "active" | "stopping" | "paused" | "completed" | "stopped" | "inactive";
30
+ export type LoopPhase = "running" | "waiting" | "retrying";
17
31
 
18
32
  export interface LoopState {
19
33
  version: 1;
@@ -22,18 +36,31 @@ export interface LoopState {
22
36
  currentIteration: number;
23
37
  remainingBudget: number;
24
38
  pendingRetune: number | null;
39
+ delay: number;
25
40
  status: LoopStatus;
41
+ retryCount?: number;
42
+ phase?: LoopPhase;
43
+ nextActionAt?: number;
44
+ settledAt?: number;
45
+ endsAt?: number;
46
+ pauseReason?: string;
47
+ pausedAt?: number;
26
48
  ownerSessionId?: string;
27
49
  ownerSessionFile?: string;
28
50
  }
29
51
 
30
52
  export type ParsedLoopCommand =
31
- | { kind: "start"; count: number; prompt: string }
53
+ | { kind: "start"; count: number; delay: number; prompt: string }
54
+ | { kind: "startTimed"; duration: number; delay: number; prompt: string }
32
55
  | { kind: "retune"; count: number }
33
56
  | { kind: "adjust"; delta: number }
57
+ | { kind: "delay"; delay: number }
58
+ | { kind: "replacePrompt"; prompt: string }
59
+ | { kind: "appendPrompt"; prompt: string }
34
60
  | { kind: "status" }
35
61
  | { kind: "resume" }
36
- | { kind: "stop" }
62
+ | { kind: "next" }
63
+ | { kind: "end" }
37
64
  | { kind: "continue"; runId: string; iteration: number }
38
65
  | { kind: "pause"; runId: string; iteration: number };
39
66
 
@@ -52,6 +79,106 @@ function completeArguments(
52
79
  return matches.length > 0 ? matches : null;
53
80
  }
54
81
 
82
+ const COMMON_LOOP_DELAYS = ["off", "1s", "5s", "10s", "30s", "1m", "5m", "1h", "24h"] as const;
83
+ const COMMON_LOOP_TIMEFRAMES = ["1h", "4h", "8h", "12h", "24h", "2d", "7d"] as const;
84
+
85
+ function delayCompletions(
86
+ prefix: string,
87
+ command: string,
88
+ values: readonly string[] = COMMON_LOOP_DELAYS,
89
+ separator = " ",
90
+ ): ArgumentCompletion[] | null {
91
+ return completeArguments(prefix, values.map((value) => ({
92
+ value: `${command}${separator}${value}`,
93
+ label: `${command}${separator}${value}`,
94
+ description: "Set the delay between settled iterations",
95
+ })));
96
+ }
97
+
98
+ function completeLoopArguments(prefix: string): ArgumentCompletion[] | null {
99
+ const input = prefix.trimStart();
100
+ const delayCommand = /^(delay|--delay)(?:\s+(.*))?$/.exec(input);
101
+ if (delayCommand?.[2] !== undefined) return delayCompletions(prefix, delayCommand[1]);
102
+
103
+ const timedDelay = /^for\s+(\S+)\s+--delay(=|\s+)?(.*)$/.exec(input);
104
+ if (timedDelay) {
105
+ if (timedDelay[2] !== undefined) {
106
+ const equals = timedDelay[2] === "=";
107
+ return delayCompletions(
108
+ prefix,
109
+ `for ${timedDelay[1]} --delay${equals ? "=" : ""}`,
110
+ COMMON_LOOP_DELAYS.filter((value) => value !== "off"),
111
+ equals ? "" : " ",
112
+ );
113
+ }
114
+ return [{
115
+ value: `for ${timedDelay[1]} --delay `,
116
+ label: `for ${timedDelay[1]} --delay <duration>`,
117
+ description: "Set the required delay between timed-loop iterations",
118
+ }];
119
+ }
120
+
121
+ const timedPrefix = /^for(?:\s+(.*))?$/.exec(input);
122
+ if (timedPrefix) {
123
+ const durationPrefix = timedPrefix[1] ?? "";
124
+ const exactDuration = COMMON_LOOP_TIMEFRAMES.find((value) => durationPrefix.trim() === value);
125
+ if (exactDuration && /\s$/.test(durationPrefix)) {
126
+ return [{
127
+ value: `for ${exactDuration} --delay `,
128
+ label: `for ${exactDuration} --delay <duration> <prompt>`,
129
+ description: `Run until ${exactDuration} elapses`,
130
+ }];
131
+ }
132
+ return completeArguments(durationPrefix, COMMON_LOOP_TIMEFRAMES.map((value) => ({
133
+ value: `for ${value} `,
134
+ label: `for ${value} --delay <duration> <prompt>`,
135
+ description: `Run until ${value} elapses`,
136
+ })));
137
+ }
138
+
139
+ const countDelay = /^(\d+)\s+--delay(=|\s+)?(.*)$/.exec(input);
140
+ if (countDelay) {
141
+ if (countDelay[2] !== undefined) {
142
+ const equals = countDelay[2] === "=";
143
+ return delayCompletions(
144
+ prefix,
145
+ `${countDelay[1]} --delay${equals ? "=" : ""}`,
146
+ COMMON_LOOP_DELAYS,
147
+ equals ? "" : " ",
148
+ );
149
+ }
150
+ return [{
151
+ value: `${countDelay[1]} --delay `,
152
+ label: `${countDelay[1]} --delay <duration>`,
153
+ description: "Set the delay between settled iterations for this loop",
154
+ }];
155
+ }
156
+
157
+ const countPrefix = /^(\d+)\s+$/.exec(input);
158
+ if (countPrefix) {
159
+ return [
160
+ { value: `${countPrefix[1]} `, label: `${countPrefix[1]} <prompt>`, description: `Run a prompt ${countPrefix[1]} time${countPrefix[1] === "1" ? "" : "s"}` },
161
+ { value: `${countPrefix[1]} --delay `, label: `${countPrefix[1]} --delay <duration>`, description: "Set the delay between settled iterations for this loop" },
162
+ ];
163
+ }
164
+
165
+ return completeArguments(prefix, [
166
+ { value: "status", label: "status", description: "Show the current loop state" },
167
+ { value: "resume", label: "resume", description: "Retry a paused iteration" },
168
+ { value: "next", label: "next", description: "Skip a paused iteration and start the next one" },
169
+ { value: "end", label: "end", description: "End the loop gracefully" },
170
+ { value: "delay ", label: "delay <duration>", description: "Set the delay between settled iterations" },
171
+ { value: "prompt ", label: "prompt <text>", description: "Replace the future loop prompt" },
172
+ { value: "append ", label: "append <text>", description: "Append to the future loop prompt" },
173
+ { value: "for ", label: "for <duration> --delay <duration> <prompt>", description: "Run until a wall-clock deadline" },
174
+ { value: "+1", label: "+1", description: "Add one future iteration" },
175
+ { value: "-1", label: "-1", description: "Remove one future iteration" },
176
+ { value: "1 ", label: "1 <prompt>", description: "Run a prompt once" },
177
+ { value: "3 ", label: "3 <prompt>", description: "Run a prompt three times" },
178
+ { value: "5 ", label: "5 <prompt>", description: "Run a prompt five times" },
179
+ ]);
180
+ }
181
+
55
182
  function isRecord(value: unknown): value is Record<string, unknown> {
56
183
  return typeof value === "object" && value !== null && !Array.isArray(value);
57
184
  }
@@ -64,10 +191,57 @@ function isNonNegativeInteger(value: unknown): value is number {
64
191
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
65
192
  }
66
193
 
194
+ function isValidLoopDelay(value: unknown): value is number {
195
+ return isNonNegativeInteger(value) &&
196
+ (value === 0 || (value >= MIN_LOOP_DELAY_MS && value <= MAX_LOOP_DELAY_MS));
197
+ }
198
+
199
+ const LOOP_DURATION_PATTERN = /^(\d+(?:\.\d+)?|\.\d+)(ms|s|m|h|d)$/;
200
+ const LOOP_DURATION_MULTIPLIERS: Record<string, number> = {
201
+ ms: 1,
202
+ s: 1_000,
203
+ m: 60_000,
204
+ h: 3_600_000,
205
+ d: 24 * 3_600_000,
206
+ };
207
+
208
+ function parseDuration(value: string, label: string, maximum: number, maximumLabel: string): number {
209
+ const match = LOOP_DURATION_PATTERN.exec(value.trim());
210
+ if (!match) {
211
+ throw new Error(`${label} must be a duration such as 1s, 5m, 4h, or 1d`);
212
+ }
213
+ const milliseconds = Number(match[1]) * LOOP_DURATION_MULTIPLIERS[match[2]];
214
+ if (!Number.isFinite(milliseconds) || milliseconds < MIN_LOOP_DELAY_MS) {
215
+ throw new Error(`${label} must be at least 1s`);
216
+ }
217
+ if (milliseconds > maximum) {
218
+ throw new Error(`${label} must not exceed ${maximumLabel}`);
219
+ }
220
+ return Math.round(milliseconds);
221
+ }
222
+
223
+ export function parseLoopDuration(value: string): number {
224
+ if (value.trim() === "off") return 0;
225
+ return parseDuration(value, "delay", MAX_LOOP_DELAY_MS, "24h");
226
+ }
227
+
228
+ export function parseLoopTimeframe(value: string): number {
229
+ return parseDuration(value, "timeframe", MAX_LOOP_TIMEFRAME_MS, "30d");
230
+ }
231
+
232
+ export function formatLoopDelay(delay: number): string {
233
+ if (delay === 0) return "off";
234
+ if (delay % (24 * 3_600_000) === 0) return `${delay / (24 * 3_600_000)}d`;
235
+ if (delay % 3_600_000 === 0) return `${delay / 3_600_000}h`;
236
+ if (delay % 60_000 === 0) return `${delay / 60_000}m`;
237
+ if (delay % 1_000 === 0) return `${delay / 1_000}s`;
238
+ return `${delay}ms`;
239
+ }
240
+
67
241
  /** Parse public and internal /loop arguments without consulting current run state. */
68
242
  export function parseLoopCommand(args: string): ParsedLoopCommand {
69
243
  const input = args.trim();
70
- if (!input) return { kind: "stop" };
244
+ if (!input) return { kind: "end" };
71
245
 
72
246
  const firstSpace = input.search(/\s/);
73
247
  const first = firstSpace < 0 ? input : input.slice(0, firstSpace);
@@ -77,14 +251,41 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
77
251
  if (rest) throw new Error(`status does not accept arguments; ${LOOP_USAGE}`);
78
252
  return { kind: "status" };
79
253
  }
80
- if (first === "stop") {
81
- if (rest) throw new Error(`stop does not accept arguments; ${LOOP_USAGE}`);
82
- return { kind: "stop" };
254
+ if (first === "end") {
255
+ if (rest) throw new Error(`end does not accept arguments; ${LOOP_USAGE}`);
256
+ return { kind: "end" };
83
257
  }
84
258
  if (first === "resume") {
85
259
  if (rest) throw new Error(`resume does not accept arguments; ${LOOP_USAGE}`);
86
260
  return { kind: "resume" };
87
261
  }
262
+ if (first === "next") {
263
+ if (rest) throw new Error(`next does not accept arguments; ${LOOP_USAGE}`);
264
+ return { kind: "next" };
265
+ }
266
+ if (first === "delay") {
267
+ const fields = rest.split(/\s+/).filter(Boolean);
268
+ if (fields.length !== 1) throw new Error(`delay requires one duration; ${LOOP_USAGE}`);
269
+ return { kind: "delay", delay: parseLoopDuration(fields[0]) };
270
+ }
271
+ if (first === "prompt" || first === "append") {
272
+ if (!rest) throw new Error(`${first} requires text; ${LOOP_USAGE}`);
273
+ return first === "prompt"
274
+ ? { kind: "replacePrompt", prompt: rest }
275
+ : { kind: "appendPrompt", prompt: rest };
276
+ }
277
+ if (first === "for") {
278
+ const timed = /^(\S+)\s+--delay(?:=|\s+)(\S+)(?:\s+([\s\S]+))?$/.exec(rest);
279
+ if (!timed) {
280
+ throw new Error(`timed loops require: for <duration> --delay <duration> <prompt>; ${LOOP_USAGE}`);
281
+ }
282
+ const duration = parseLoopTimeframe(timed[1]);
283
+ const delay = parseLoopDuration(timed[2]);
284
+ const prompt = timed[3]?.trim() ?? "";
285
+ if (delay === 0) throw new Error(`timed loops require a non-zero --delay; ${LOOP_USAGE}`);
286
+ if (!prompt) throw new Error(`a prompt is required after --delay; ${LOOP_USAGE}`);
287
+ return { kind: "startTimed", duration, delay, prompt };
288
+ }
88
289
 
89
290
  // These commands are only emitted by the extension itself. Keeping them in
90
291
  // the same dispatcher gives boundary transitions command-only session APIs
@@ -118,7 +319,19 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
118
319
  if (!/^\d+$/.test(first)) throw new Error(`count must be a positive integer; ${LOOP_USAGE}`);
119
320
  const count = Number(first);
120
321
  if (!isPositiveInteger(count)) throw new Error(`count must be a positive integer; ${LOOP_USAGE}`);
121
- return rest ? { kind: "start", count, prompt: rest } : { kind: "retune", count };
322
+ if (!rest) return { kind: "retune", count };
323
+
324
+ let delay = 0;
325
+ let prompt = rest;
326
+ const delayOption = /^(--delay)(?:=|\s+)(\S+)(?:\s+([\s\S]+))?$/.exec(rest);
327
+ if (delayOption) {
328
+ delay = parseLoopDuration(delayOption[2]);
329
+ prompt = delayOption[3]?.trim() ?? "";
330
+ if (!prompt) throw new Error(`a prompt is required after --delay; ${LOOP_USAGE}`);
331
+ } else if (/^--delay(?:\s|=|$)/.test(rest)) {
332
+ throw new Error(`--delay requires a duration and prompt; ${LOOP_USAGE}`);
333
+ }
334
+ return { kind: "start", count, delay, prompt };
122
335
  }
123
336
 
124
337
  throw new Error(`expected a positive count or a loop command; ${LOOP_USAGE}`);
@@ -147,6 +360,7 @@ export function parseLoopState(value: unknown): LoopState | undefined {
147
360
  ) {
148
361
  return undefined;
149
362
  }
363
+ const delay = value.delay === undefined ? 0 : value.delay;
150
364
  if (
151
365
  value.version !== 1 ||
152
366
  typeof value.runId !== "string" ||
@@ -155,10 +369,23 @@ export function parseLoopState(value: unknown): LoopState | undefined {
155
369
  !value.prompt.trim() ||
156
370
  !isPositiveInteger(value.currentIteration) ||
157
371
  !isNonNegativeInteger(value.remainingBudget) ||
158
- (value.pendingRetune !== null && !isNonNegativeInteger(value.pendingRetune))
372
+ (value.pendingRetune !== null && !isNonNegativeInteger(value.pendingRetune)) ||
373
+ !isValidLoopDelay(delay) ||
374
+ (value.retryCount !== undefined && !isNonNegativeInteger(value.retryCount))
159
375
  ) {
160
376
  return undefined;
161
377
  }
378
+ if (
379
+ value.phase !== undefined &&
380
+ value.phase !== "running" &&
381
+ value.phase !== "waiting" &&
382
+ value.phase !== "retrying"
383
+ ) return undefined;
384
+ if (value.nextActionAt !== undefined && !isNonNegativeInteger(value.nextActionAt)) return undefined;
385
+ if (value.settledAt !== undefined && !isNonNegativeInteger(value.settledAt)) return undefined;
386
+ if (value.endsAt !== undefined && (!isNonNegativeInteger(value.endsAt) || delay === 0)) return undefined;
387
+ if (value.pauseReason !== undefined && typeof value.pauseReason !== "string") return undefined;
388
+ if (value.pausedAt !== undefined && !isNonNegativeInteger(value.pausedAt)) return undefined;
162
389
  if (value.ownerSessionId !== undefined && typeof value.ownerSessionId !== "string") return undefined;
163
390
  if (value.ownerSessionFile !== undefined && typeof value.ownerSessionFile !== "string") return undefined;
164
391
 
@@ -169,7 +396,15 @@ export function parseLoopState(value: unknown): LoopState | undefined {
169
396
  currentIteration: value.currentIteration,
170
397
  remainingBudget: value.remainingBudget,
171
398
  pendingRetune: value.pendingRetune,
399
+ delay,
172
400
  status,
401
+ retryCount: value.retryCount ?? 0,
402
+ phase: value.phase ?? "running",
403
+ ...(value.nextActionAt !== undefined ? { nextActionAt: value.nextActionAt } : {}),
404
+ ...(value.settledAt !== undefined ? { settledAt: value.settledAt } : {}),
405
+ ...(value.endsAt !== undefined ? { endsAt: value.endsAt } : {}),
406
+ ...(value.pauseReason ? { pauseReason: value.pauseReason } : {}),
407
+ ...(value.pausedAt !== undefined ? { pausedAt: value.pausedAt } : {}),
173
408
  ...(value.ownerSessionId ? { ownerSessionId: value.ownerSessionId } : {}),
174
409
  ...(value.ownerSessionFile ? { ownerSessionFile: value.ownerSessionFile } : {}),
175
410
  };
@@ -182,8 +417,15 @@ export function formatLoopStatus(state: LoopState | undefined): string {
182
417
  `loop: ${state.status}`,
183
418
  `run: ${state.runId}`,
184
419
  `iteration: ${state.currentIteration}`,
185
- `remaining: ${state.remainingBudget}`,
186
- `pending retune: ${pending}`,
420
+ ...(state.endsAt !== undefined
421
+ ? [`ends at: ${new Date(state.endsAt).toISOString()}`]
422
+ : [`remaining: ${state.remainingBudget}`, `pending retune: ${pending}`]),
423
+ `delay: ${formatLoopDelay(state.delay ?? 0)}`,
424
+ `retries: ${state.retryCount ?? 0}/${DEFAULT_LOOP_RETRIES}`,
425
+ `phase: ${state.phase ?? "running"}`,
426
+ ...(state.nextActionAt ? [`next action: ${new Date(state.nextActionAt).toISOString()}`] : []),
427
+ ...(state.pauseReason ? [`pause reason: ${state.pauseReason}`] : []),
428
+ ...(state.pausedAt ? [`paused at: ${new Date(state.pausedAt).toISOString()}`] : []),
187
429
  ].join("\n");
188
430
  }
189
431
 
@@ -262,25 +504,73 @@ function isTerminal(state: LoopState | undefined): boolean {
262
504
  return Boolean(state && TERMINAL_STATUSES.has(state.status));
263
505
  }
264
506
 
265
- export function formatLoopWidget(state: LoopState, width: number): string {
507
+ function formatTimeRemaining(milliseconds: number): string {
508
+ const seconds = Math.max(1, Math.ceil(milliseconds / 1_000));
509
+ if (seconds >= 24 * 60 * 60) return `${Math.ceil(seconds / (24 * 60 * 60))}d`;
510
+ if (seconds >= 60 * 60) return `${Math.ceil(seconds / (60 * 60))}h`;
511
+ if (seconds >= 60) return `${Math.ceil(seconds / 60)}m`;
512
+ return `${seconds}s`;
513
+ }
514
+
515
+ export function formatLoopWidget(state: LoopState, width: number, now = Date.now()): string {
266
516
  const prompt = state.prompt.replace(/\s+/g, " ").trim();
517
+ const delay = state.delay > 0 ? ` · delay ${formatLoopDelay(state.delay)}` : "";
518
+ const timeframe = state.endsAt === undefined
519
+ ? ""
520
+ : state.endsAt <= now
521
+ ? " · deadline reached"
522
+ : ` · ${formatTimeRemaining(state.endsAt - now)} left`;
523
+ const retries = (state.retryCount ?? 0) > 0
524
+ ? ` · retry ${state.retryCount}/${DEFAULT_LOOP_RETRIES}`
525
+ : "";
267
526
  if (state.status === "stopping") {
268
- return truncateToWidth(`loop stopping · ${prompt}`, width, "…");
527
+ return truncateToWidth(`loop stopping${timeframe}${delay}${retries} · ${prompt}`, width, "…");
528
+ }
529
+ if (state.endsAt !== undefined) {
530
+ return truncateToWidth(
531
+ `loop ${state.status}${timeframe}${delay}${retries} · ${prompt}`,
532
+ width,
533
+ "…",
534
+ );
269
535
  }
270
536
  const futureIterations = state.pendingRetune ?? state.remainingBudget;
271
537
  const remainingIterations = futureIterations + 1;
272
538
  const totalIterations = state.currentIteration + futureIterations;
273
539
  return truncateToWidth(
274
- `loop ${state.status} ${remainingIterations}/${totalIterations} · ${prompt}`,
540
+ `loop ${state.status} ${remainingIterations}/${totalIterations}${delay}${retries} · ${prompt}`,
275
541
  width,
276
542
  "…",
277
543
  );
278
544
  }
279
545
 
546
+ type ContinuationWait = {
547
+ key: string;
548
+ settledAt: number;
549
+ timer: ReturnType<typeof setTimeout>;
550
+ };
551
+
552
+ type RetryWait = {
553
+ key: string;
554
+ retryCount: number;
555
+ timer: ReturnType<typeof setTimeout>;
556
+ };
557
+
558
+ type PendingFailure = {
559
+ key: string;
560
+ stopReason: "aborted" | "error";
561
+ reason: string;
562
+ };
563
+
280
564
  export default function loopExtension(pi: ExtensionAPI): void {
281
565
  let runState: LoopState | undefined;
282
566
  let transitionInFlight = false;
283
567
  let handledSettlementKey: string | undefined;
568
+ let continuationWait: ContinuationWait | undefined;
569
+ let retryWait: RetryWait | undefined;
570
+ let recoveryTimer: ReturnType<typeof setTimeout> | undefined;
571
+ let activeCommandKey: string | undefined;
572
+ let commandInterruptedKey: string | undefined;
573
+ let pendingFailure: PendingFailure | undefined;
284
574
  let currentSessionManagerRef: unknown;
285
575
 
286
576
  function stateFrom(ctx: ContextWithSession): LoopState | undefined {
@@ -345,6 +635,214 @@ export default function loopExtension(pi: ExtensionAPI): void {
345
635
  return `${state.runId}:${state.currentIteration}:${identity.token ?? "unknown"}`;
346
636
  }
347
637
 
638
+ function clearContinuationWait(): void {
639
+ if (!continuationWait) return;
640
+ clearTimeout(continuationWait.timer);
641
+ continuationWait = undefined;
642
+ }
643
+
644
+ function clearRetryWait(): void {
645
+ if (!retryWait) return;
646
+ clearTimeout(retryWait.timer);
647
+ retryWait = undefined;
648
+ }
649
+
650
+ function clearRecoveryTimer(): void {
651
+ if (!recoveryTimer) return;
652
+ clearTimeout(recoveryTimer);
653
+ recoveryTimer = undefined;
654
+ }
655
+
656
+ function pauseLoop(ctx: ExtensionContext, state: LoopState, reason: string): void {
657
+ clearContinuationWait();
658
+ clearRetryWait();
659
+ const {
660
+ nextActionAt: _nextActionAt,
661
+ settledAt: _settledAt,
662
+ ...withoutSchedule
663
+ } = state;
664
+ const paused = {
665
+ ...withoutSchedule,
666
+ status: "paused" as const,
667
+ phase: "running" as const,
668
+ pauseReason: reason,
669
+ pausedAt: Date.now(),
670
+ };
671
+ persist(pi, paused);
672
+ renderWidget(ctx, paused);
673
+ }
674
+
675
+ function scheduleContinuation(
676
+ ctx: ExtensionContext,
677
+ state: LoopState,
678
+ settledAt = Date.now(),
679
+ ): void {
680
+ if (!statusIsActive(state) || transitionInFlight) return;
681
+ const nextBudget = state.pendingRetune ?? state.remainingBudget;
682
+ if (state.status === "stopping" || (state.endsAt === undefined && nextBudget <= 0) || state.delay === 0) {
683
+ clearContinuationWait();
684
+ dispatchContinuation(ctx, state);
685
+ return;
686
+ }
687
+
688
+ const key = stateKey(ctx, state);
689
+ if (continuationWait?.key === key) return;
690
+ clearContinuationWait();
691
+ const nextActionAt = state.endsAt !== undefined
692
+ ? Math.min(settledAt + state.delay, state.endsAt)
693
+ : settledAt + state.delay;
694
+ const waiting: LoopState = { ...state, phase: "waiting", nextActionAt, settledAt };
695
+ persist(pi, waiting);
696
+ renderWidget(ctx, waiting);
697
+ const waitMs = Math.max(0, nextActionAt - Date.now());
698
+ if (waitMs === 0) {
699
+ dispatchContinuation(ctx, waiting);
700
+ return;
701
+ }
702
+
703
+ const timer = setTimeout(() => {
704
+ if (!continuationWait || continuationWait.key !== key || continuationWait.timer !== timer) return;
705
+ continuationWait = undefined;
706
+ const latest = currentState(ctx);
707
+ if (!latest || latest.runId !== state.runId || latest.currentIteration !== state.currentIteration) return;
708
+ if (!statusIsActive(latest) || latest.phase !== "waiting") return;
709
+ dispatchContinuation(ctx, latest);
710
+ }, waitMs);
711
+ continuationWait = { key, settledAt, timer };
712
+ }
713
+
714
+ function rescheduleContinuation(ctx: ExtensionContext, state: LoopState): void {
715
+ if (!continuationWait || continuationWait.key !== stateKey(ctx, state)) return;
716
+ const settledAt = continuationWait.settledAt;
717
+ clearContinuationWait();
718
+ scheduleContinuation(ctx, state, settledAt);
719
+ }
720
+
721
+ function isWaitingForContinuation(ctx: ContextWithSession, state: LoopState): boolean {
722
+ const key = stateKey(ctx, state);
723
+ return continuationWait?.key === key || retryWait?.key === key || recoveryTimer !== undefined;
724
+ }
725
+
726
+ function clearCommandInterruption(): void {
727
+ activeCommandKey = undefined;
728
+ commandInterruptedKey = undefined;
729
+ }
730
+
731
+ function continueCurrentIteration(ctx: ExtensionContext, state: LoopState): void {
732
+ if (state.endsAt !== undefined && Date.now() >= state.endsAt) {
733
+ dispatchContinuation(ctx, state);
734
+ return;
735
+ }
736
+ const content = `${LOOP_CONTINUATION_PROMPT}\n\nCurrent loop instructions:\n${state.prompt}`;
737
+ try {
738
+ pi.sendUserMessage(content, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
739
+ notify(ctx, "loop continuing the current iteration", "info");
740
+ } catch (error) {
741
+ const reason = error instanceof Error ? error.message : String(error);
742
+ pauseLoop(ctx, state, reason);
743
+ notify(ctx, `loop paused: ${reason}`, "error");
744
+ }
745
+ }
746
+
747
+ function recordAssistantOutcome(
748
+ ctx: ExtensionContext,
749
+ assistant: { stopReason?: string; errorMessage?: string } | undefined,
750
+ ): void {
751
+ const loaded = currentState(ctx);
752
+ if (!loaded || !statusIsActive(loaded) || transitionInFlight) return;
753
+ const key = stateKey(ctx, loaded);
754
+ const stopReason = assistant?.stopReason;
755
+ if (stopReason !== "aborted" && stopReason !== "error") {
756
+ if (pendingFailure?.key === key) pendingFailure = undefined;
757
+ return;
758
+ }
759
+ clearContinuationWait();
760
+ if (stopReason === "aborted" && activeCommandKey === key) {
761
+ pendingFailure = undefined;
762
+ commandInterruptedKey = key;
763
+ return;
764
+ }
765
+ clearCommandInterruption();
766
+ pendingFailure = {
767
+ key,
768
+ stopReason,
769
+ reason: stopReason === "error"
770
+ ? assistant?.errorMessage?.trim() || "assistant error"
771
+ : "assistant aborted",
772
+ };
773
+ }
774
+
775
+ function armRetry(ctx: ExtensionContext, state: LoopState, waitMs: number): void {
776
+ clearRetryWait();
777
+ const key = stateKey(ctx, state);
778
+ const retryCount = state.retryCount ?? 0;
779
+ const timer = setTimeout(() => {
780
+ if (!retryWait || retryWait.timer !== timer || retryWait.key !== key) return;
781
+ retryWait = undefined;
782
+ const latest = currentState(ctx);
783
+ if (!latest || !statusIsActive(latest) || stateKey(ctx, latest) !== key) return;
784
+ if ((latest.retryCount ?? 0) !== retryCount || latest.phase !== "retrying") return;
785
+ const {
786
+ nextActionAt: _nextActionAt,
787
+ settledAt: _settledAt,
788
+ ...withoutSchedule
789
+ } = latest;
790
+ const running: LoopState = { ...withoutSchedule, phase: "running" };
791
+ persist(pi, running);
792
+ renderWidget(ctx, running);
793
+ handledSettlementKey = undefined;
794
+ continueCurrentIteration(ctx, running);
795
+ }, waitMs);
796
+ retryWait = { key, retryCount, timer };
797
+ }
798
+
799
+ function scheduleRetry(ctx: ExtensionContext, state: LoopState, failure: PendingFailure): void {
800
+ const previousRetryCount = state.retryCount ?? 0;
801
+ const retryCount = previousRetryCount + 1;
802
+ const delay = DEFAULT_LOOP_RETRY_DELAY_MS * 2 ** previousRetryCount;
803
+ const nextActionAt = Date.now() + delay;
804
+ const { pauseReason: _pauseReason, pausedAt: _pausedAt, ...withoutPause } = state;
805
+ const retrying: LoopState = {
806
+ ...withoutPause,
807
+ retryCount,
808
+ status: "active",
809
+ phase: "retrying",
810
+ nextActionAt,
811
+ };
812
+ persist(pi, retrying);
813
+ renderWidget(ctx, retrying);
814
+ handledSettlementKey = stateKey(ctx, retrying);
815
+ notify(
816
+ ctx,
817
+ `loop retrying iteration ${retrying.currentIteration} in ${formatLoopDelay(delay)} after ${failure.reason} (${retryCount}/${DEFAULT_LOOP_RETRIES})`,
818
+ "warning",
819
+ );
820
+ armRetry(ctx, retrying, delay);
821
+ }
822
+
823
+ function scheduleStartupRecovery(ctx: ExtensionContext, state: LoopState): void {
824
+ clearRecoveryTimer();
825
+ const key = stateKey(ctx, state);
826
+ recoveryTimer = setTimeout(() => {
827
+ recoveryTimer = undefined;
828
+ const latest = currentState(ctx);
829
+ if (!latest || !statusIsActive(latest) || stateKey(ctx, latest) !== key) return;
830
+ handledSettlementKey = undefined;
831
+ if (latest.phase === "waiting") {
832
+ const settledAt = latest.settledAt ?? (latest.nextActionAt ?? Date.now()) - latest.delay;
833
+ scheduleContinuation(ctx, latest, settledAt);
834
+ return;
835
+ }
836
+ if (latest.phase === "retrying") {
837
+ const waitMs = Math.max(0, (latest.nextActionAt ?? Date.now()) - Date.now());
838
+ armRetry(ctx, latest, waitMs);
839
+ return;
840
+ }
841
+ notify(ctx, `loop recovering interrupted iteration ${latest.currentIteration}`, "warning");
842
+ continueCurrentIteration(ctx, latest);
843
+ }, 0);
844
+ }
845
+
348
846
  function transferState(state: LoopState, manager: SessionManager): LoopState {
349
847
  const transferred = stateForSession({ ...state, status: "active" }, sessionIdentity(manager));
350
848
  manager.appendCustomEntry(LOOP_STATE_ENTRY, transferred);
@@ -359,10 +857,20 @@ export default function loopExtension(pi: ExtensionAPI): void {
359
857
  // This callback still owns the command context, so it is the safe place to
360
858
  // start the turn after the replacement is complete.
361
859
  if (replacement.hasUI) showWidget(replacement, state);
362
- await replacement.sendUserMessage(state.prompt);
860
+ if (state.endsAt !== undefined && Date.now() >= state.endsAt) {
861
+ await replacement.sendUserMessage(
862
+ `/loop __continue ${state.runId} ${state.currentIteration}`,
863
+ { expandPromptTemplates: true },
864
+ );
865
+ return;
866
+ }
867
+ await replacement.sendUserMessage(state.prompt, { expandPromptTemplates: true });
363
868
  }
364
869
 
365
870
  async function replaceForIteration(ctx: ExtensionCommandContext, next: LoopState): Promise<void> {
871
+ clearContinuationWait();
872
+ clearRetryWait();
873
+ clearRecoveryTimer();
366
874
  const sourceIdentity = contextIdentity(ctx);
367
875
  const parentSession = sourceIdentity.file;
368
876
  const inactive = {
@@ -416,6 +924,8 @@ export default function loopExtension(pi: ExtensionAPI): void {
416
924
  const paused: LoopState = {
417
925
  ...next,
418
926
  status: "paused",
927
+ pauseReason: "session replacement was cancelled",
928
+ pausedAt: Date.now(),
419
929
  ...(sourceIdentity.id ? { ownerSessionId: sourceIdentity.id } : {}),
420
930
  ...(sourceIdentity.file ? { ownerSessionFile: sourceIdentity.file } : {}),
421
931
  };
@@ -429,26 +939,37 @@ export default function loopExtension(pi: ExtensionAPI): void {
429
939
  // A replacement can invalidate ctx before throwing. In that case the
430
940
  // inactive marker remains authoritative and a later resume is required.
431
941
  try {
942
+ const reason = error instanceof Error ? error.message : String(error);
432
943
  const paused: LoopState = {
433
944
  ...next,
434
945
  status: "paused",
946
+ pauseReason: reason,
947
+ pausedAt: Date.now(),
435
948
  ...(sourceIdentity.id ? { ownerSessionId: sourceIdentity.id } : {}),
436
949
  ...(sourceIdentity.file ? { ownerSessionFile: sourceIdentity.file } : {}),
437
950
  };
438
951
  persist(pi, paused);
439
952
  runState = paused;
440
953
  renderWidget(ctx, paused);
441
- notify(ctx, `loop paused: ${error instanceof Error ? error.message : String(error)}`, "error");
954
+ notify(ctx, `loop paused: ${reason}`, "error");
442
955
  } catch {
443
956
  console.error(`[pi-loop] session replacement failed: ${error instanceof Error ? error.message : String(error)}`);
444
957
  }
445
958
  }
446
959
  }
447
960
 
448
- async function advanceAtBoundary(ctx: ExtensionCommandContext, expectedRunId: string, expectedIteration: number): Promise<void> {
961
+ async function advanceAtBoundary(
962
+ ctx: ExtensionCommandContext,
963
+ expectedRunId: string,
964
+ expectedIteration: number,
965
+ allowPaused = false,
966
+ ): Promise<void> {
449
967
  const state = currentState(ctx);
450
968
  if (!state || state.runId !== expectedRunId || state.currentIteration !== expectedIteration) return;
451
- if (!statusIsActive(state) || transitionInFlight) return;
969
+ const canAdvance = ACTIVE_STATUSES.has(state.status) || (allowPaused && state.status === "paused");
970
+ if (!canAdvance || transitionInFlight) return;
971
+ clearContinuationWait();
972
+ clearRetryWait();
452
973
 
453
974
  if (state.status === "stopping") {
454
975
  const stopped = { ...state, status: "stopped" as const };
@@ -460,7 +981,15 @@ export default function loopExtension(pi: ExtensionAPI): void {
460
981
  }
461
982
 
462
983
  const nextBudget = state.pendingRetune ?? state.remainingBudget;
463
- if (nextBudget <= 0) {
984
+ if (state.endsAt !== undefined && Date.now() >= state.endsAt) {
985
+ const completed = { ...state, status: "completed" as const, pendingRetune: null };
986
+ persist(pi, completed);
987
+ clearWidget(ctx);
988
+ runState = completed;
989
+ notify(ctx, `loop completed at its deadline after ${state.currentIteration} iteration${state.currentIteration === 1 ? "" : "s"}`, "info");
990
+ return;
991
+ }
992
+ if (state.endsAt === undefined && nextBudget <= 0) {
464
993
  const completed = { ...state, status: "completed" as const, pendingRetune: null };
465
994
  persist(pi, completed);
466
995
  clearWidget(ctx);
@@ -469,11 +998,20 @@ export default function loopExtension(pi: ExtensionAPI): void {
469
998
  return;
470
999
  }
471
1000
 
1001
+ const {
1002
+ pauseReason: _pauseReason,
1003
+ pausedAt: _pausedAt,
1004
+ nextActionAt: _nextActionAt,
1005
+ settledAt: _settledAt,
1006
+ ...withoutPause
1007
+ } = state;
472
1008
  const next: LoopState = {
473
- ...state,
1009
+ ...withoutPause,
474
1010
  currentIteration: state.currentIteration + 1,
475
- remainingBudget: nextBudget - 1,
1011
+ remainingBudget: state.endsAt === undefined ? nextBudget - 1 : 0,
476
1012
  pendingRetune: null,
1013
+ retryCount: 0,
1014
+ phase: "running",
477
1015
  status: "active",
478
1016
  };
479
1017
  await replaceForIteration(ctx, next);
@@ -490,27 +1028,25 @@ export default function loopExtension(pi: ExtensionAPI): void {
490
1028
  void (result as Promise<unknown>).catch((error) => {
491
1029
  const latest = currentState(ctx);
492
1030
  if (!latest || latest.runId !== state.runId || latest.currentIteration !== state.currentIteration) return;
493
- const paused = { ...latest, status: "paused" as const };
1031
+ const reason = error instanceof Error ? error.message : String(error);
494
1032
  try {
495
- persist(pi, paused);
496
- renderWidget(ctx, paused);
1033
+ pauseLoop(ctx, latest, reason);
497
1034
  } catch {
498
1035
  // The runtime may already have replaced this session.
499
1036
  }
500
- console.error(`[pi-loop] continuation failed: ${error instanceof Error ? error.message : String(error)}`);
1037
+ console.error(`[pi-loop] continuation failed: ${reason}`);
501
1038
  });
502
1039
  }
503
1040
  } catch (error) {
504
1041
  const latest = currentState(ctx);
505
1042
  if (!latest || latest.runId !== state.runId || latest.currentIteration !== state.currentIteration) return;
506
- const paused = { ...latest, status: "paused" as const };
1043
+ const reason = error instanceof Error ? error.message : String(error);
507
1044
  try {
508
- persist(pi, paused);
509
- renderWidget(ctx, paused);
1045
+ pauseLoop(ctx, latest, reason);
510
1046
  } catch {
511
1047
  // The runtime may already have replaced this session.
512
1048
  }
513
- notify(ctx, `loop paused: ${error instanceof Error ? error.message : String(error)}`, "error");
1049
+ notify(ctx, `loop paused: ${reason}`, "error");
514
1050
  }
515
1051
  }
516
1052
 
@@ -525,25 +1061,70 @@ export default function loopExtension(pi: ExtensionAPI): void {
525
1061
  if (parsed.kind === "pause") {
526
1062
  const state = currentState(ctx);
527
1063
  if (!state || state.runId !== parsed.runId || state.currentIteration !== parsed.iteration || !statusIsActive(state)) return;
528
- const paused = { ...state, status: "paused" as const };
529
- persist(pi, paused);
530
- renderWidget(ctx, paused);
1064
+ pauseLoop(ctx, state, "iteration prompt failed to start");
531
1065
  return;
532
1066
  }
533
1067
 
534
1068
  const state = currentState(ctx);
1069
+ if (state && statusIsActive(state) && !ctx.isIdle()) {
1070
+ activeCommandKey = stateKey(ctx, state);
1071
+ }
535
1072
 
536
1073
  if (parsed.kind === "status") {
537
1074
  notify(ctx, formatLoopStatus(state), "info");
538
1075
  return;
539
1076
  }
540
1077
 
541
- if (parsed.kind === "stop") {
1078
+ if (parsed.kind === "delay") {
1079
+ if (!state || isTerminal(state)) {
1080
+ notify(ctx, "a loop must be active, stopping, or paused to update its delay", "error");
1081
+ return;
1082
+ }
1083
+ if (state.endsAt !== undefined && parsed.delay === 0) {
1084
+ notify(ctx, "timed loops require a non-zero delay", "error");
1085
+ return;
1086
+ }
1087
+ const nextActionAt = state.phase === "waiting" && state.nextActionAt !== undefined
1088
+ ? Math.min(
1089
+ (state.settledAt ?? state.nextActionAt - state.delay) + parsed.delay,
1090
+ state.endsAt ?? Number.MAX_SAFE_INTEGER,
1091
+ )
1092
+ : state.nextActionAt;
1093
+ const updated = {
1094
+ ...state,
1095
+ delay: parsed.delay,
1096
+ ...(nextActionAt !== undefined ? { nextActionAt } : {}),
1097
+ };
1098
+ persist(pi, updated);
1099
+ renderWidget(ctx, updated);
1100
+ if (state.status === "active") rescheduleContinuation(ctx, updated);
1101
+ if (state.status === "paused") {
1102
+ notify(ctx, `loop delay set to ${formatLoopDelay(parsed.delay)}; resume will use it`, "info");
1103
+ } else if (state.status === "stopping") {
1104
+ notify(ctx, `loop delay set to ${formatLoopDelay(parsed.delay)}; loop is still stopping`, "info");
1105
+ } else {
1106
+ notify(ctx, `loop delay set to ${formatLoopDelay(parsed.delay)}`, "info");
1107
+ }
1108
+ return;
1109
+ }
1110
+
1111
+ if (parsed.kind === "end") {
542
1112
  if (!state || state.status === "inactive" || state.status === "completed" || state.status === "stopped") {
543
1113
  notify(ctx, "loop: no active run", "info");
544
1114
  clearWidget(ctx);
545
1115
  return;
546
1116
  }
1117
+ if (isWaitingForContinuation(ctx, state)) {
1118
+ clearContinuationWait();
1119
+ clearRetryWait();
1120
+ clearRecoveryTimer();
1121
+ const stopped = { ...state, status: "stopped" as const };
1122
+ persist(pi, stopped);
1123
+ clearWidget(ctx);
1124
+ runState = stopped;
1125
+ notify(ctx, "loop stopped", "info");
1126
+ return;
1127
+ }
547
1128
  if (state.status === "paused") {
548
1129
  const stopped = { ...state, status: "stopped" as const };
549
1130
  persist(pi, stopped);
@@ -575,7 +1156,60 @@ export default function loopExtension(pi: ExtensionAPI): void {
575
1156
  notify(ctx, state && statusIsActive(state) ? "loop is already active" : "loop is not paused", "error");
576
1157
  return;
577
1158
  }
578
- await replaceForIteration(ctx, { ...state, status: "active" });
1159
+ if (state.endsAt !== undefined && Date.now() >= state.endsAt) {
1160
+ await advanceAtBoundary(ctx, state.runId, state.currentIteration, true);
1161
+ return;
1162
+ }
1163
+ const {
1164
+ pauseReason: _pauseReason,
1165
+ pausedAt: _pausedAt,
1166
+ nextActionAt: _nextActionAt,
1167
+ settledAt: _settledAt,
1168
+ ...withoutPause
1169
+ } = state;
1170
+ const resumed: LoopState = {
1171
+ ...withoutPause,
1172
+ status: "active",
1173
+ retryCount: 0,
1174
+ phase: "running",
1175
+ };
1176
+ persist(pi, resumed);
1177
+ renderWidget(ctx, resumed);
1178
+ handledSettlementKey = undefined;
1179
+ continueCurrentIteration(ctx, resumed);
1180
+ return;
1181
+ }
1182
+
1183
+ if (parsed.kind === "next") {
1184
+ if (!state || state.status !== "paused") {
1185
+ notify(ctx, state && statusIsActive(state) ? "loop is active; /loop next is only available while paused" : "loop is not paused; /loop next is only available while paused", "error");
1186
+ return;
1187
+ }
1188
+ await advanceAtBoundary(ctx, state.runId, state.currentIteration, true);
1189
+ return;
1190
+ }
1191
+
1192
+ if (parsed.kind === "replacePrompt" || parsed.kind === "appendPrompt") {
1193
+ if (!state || isTerminal(state)) {
1194
+ notify(ctx, "a loop must be active, stopping, or paused to update its prompt", "error");
1195
+ return;
1196
+ }
1197
+ const prompt = parsed.kind === "replacePrompt"
1198
+ ? parsed.prompt
1199
+ : `${state.prompt}\n\n${parsed.prompt}`;
1200
+ const updated = { ...state, prompt };
1201
+ persist(pi, updated);
1202
+ renderWidget(ctx, updated);
1203
+ const action = parsed.kind === "replacePrompt" ? "replaced" : "extended";
1204
+ if (state.status === "paused") {
1205
+ notify(ctx, `loop prompt ${action}; resume will use it`, "info");
1206
+ } else if (state.status === "stopping") {
1207
+ notify(ctx, `loop prompt ${action}; loop is still stopping`, "info");
1208
+ } else if (state.endsAt === undefined && (state.pendingRetune ?? state.remainingBudget) === 0) {
1209
+ notify(ctx, `future loop prompt ${action}; no future iteration is scheduled`, "info");
1210
+ } else {
1211
+ notify(ctx, `future loop prompt ${action}; active iteration unchanged`, "info");
1212
+ }
579
1213
  return;
580
1214
  }
581
1215
 
@@ -584,6 +1218,10 @@ export default function loopExtension(pi: ExtensionAPI): void {
584
1218
  notify(ctx, "a loop must be active to retune its remaining budget", "error");
585
1219
  return;
586
1220
  }
1221
+ if (state.endsAt !== undefined) {
1222
+ notify(ctx, "a timed loop has no iteration budget to retune", "error");
1223
+ return;
1224
+ }
587
1225
  const currentBudget = state.pendingRetune ?? state.remainingBudget;
588
1226
  const nextBudget = parsed.kind === "retune" ? parsed.count : currentBudget + parsed.delta;
589
1227
  if (nextBudget < 0) {
@@ -593,34 +1231,78 @@ export default function loopExtension(pi: ExtensionAPI): void {
593
1231
  const retuned = { ...state, pendingRetune: nextBudget, status: "active" as const };
594
1232
  persist(pi, retuned);
595
1233
  renderWidget(ctx, retuned);
1234
+ if (nextBudget <= 0) rescheduleContinuation(ctx, retuned);
596
1235
  notify(ctx, `loop will run ${nextBudget} future iteration${nextBudget === 1 ? "" : "s"}`, "info");
597
1236
  return;
598
1237
  }
599
1238
 
600
1239
  if (state && !isTerminal(state)) {
601
1240
  if (state.status === "paused") {
602
- notify(ctx, "loop is paused; use /loop resume or /loop stop", "error");
1241
+ notify(ctx, "loop is paused; use /loop resume or /loop end", "error");
603
1242
  } else {
604
1243
  notify(ctx, "a loop is already active; use /loop <positive-count> to retune it", "error");
605
1244
  }
606
1245
  return;
607
1246
  }
608
1247
 
1248
+ const timed = parsed.kind === "startTimed";
609
1249
  const initial: LoopState = {
610
1250
  version: 1,
611
1251
  runId: randomUUID(),
612
1252
  prompt: parsed.prompt,
613
1253
  currentIteration: 1,
614
- remainingBudget: parsed.count - 1,
1254
+ remainingBudget: timed ? 0 : parsed.count - 1,
615
1255
  pendingRetune: null,
1256
+ delay: parsed.delay,
616
1257
  status: "active",
1258
+ retryCount: 0,
1259
+ phase: "running",
1260
+ ...(timed ? { endsAt: Date.now() + parsed.duration } : {}),
617
1261
  ...(contextIdentity(ctx).id ? { ownerSessionId: contextIdentity(ctx).id } : {}),
618
1262
  ...(contextIdentity(ctx).file ? { ownerSessionFile: contextIdentity(ctx).file } : {}),
619
1263
  };
620
1264
  await replaceForIteration(ctx, initial);
621
1265
  }
622
1266
 
1267
+ pi.registerTool({
1268
+ name: "loop_pause",
1269
+ label: "Pause Loop",
1270
+ description: "Pause the active /loop when useful work cannot continue because of a non-transient external blocker. Use only when the system prompt says this session is in an active loop.",
1271
+ executionMode: "sequential",
1272
+ parameters: Type.Object({
1273
+ reason: Type.String({
1274
+ minLength: 1,
1275
+ maxLength: 500,
1276
+ description: "Specific human input, credential, permission, or external dependency required to continue",
1277
+ }),
1278
+ }),
1279
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1280
+ const state = currentState(ctx);
1281
+ if (!state || state.status !== "active") {
1282
+ return {
1283
+ content: [{ type: "text", text: "No active loop can be paused." }],
1284
+ details: { paused: false },
1285
+ };
1286
+ }
1287
+ const reason = params.reason.trim();
1288
+ if (!reason) throw new Error("A specific blocker reason is required");
1289
+ pauseLoop(ctx, state, reason);
1290
+ notify(ctx, `loop paused by agent: ${reason}`, "warning");
1291
+ ctx.abort();
1292
+ return {
1293
+ content: [{ type: "text", text: `Loop paused: ${reason}` }],
1294
+ details: { paused: true, reason },
1295
+ terminate: true,
1296
+ };
1297
+ },
1298
+ });
1299
+
623
1300
  pi.on("session_start", (event, ctx) => {
1301
+ clearContinuationWait();
1302
+ clearRetryWait();
1303
+ clearRecoveryTimer();
1304
+ clearCommandInterruption();
1305
+ pendingFailure = undefined;
624
1306
  currentSessionManagerRef = ctx.sessionManager;
625
1307
  transitionInFlight = false;
626
1308
  handledSettlementKey = undefined;
@@ -629,17 +1311,21 @@ export default function loopExtension(pi: ExtensionAPI): void {
629
1311
  runState = owned;
630
1312
  if (!owned || owned.status === "inactive") clearWidget(ctx);
631
1313
  else renderWidget(ctx, owned);
632
- // `event` is intentionally accepted so this handler is safe for all
633
- // startup/new/resume reasons. New-session setup writes the transferred
634
- // state just after this event; before_agent_start restores it lazily.
635
- void event;
1314
+ // New-session setup writes transferred state after this event and starts
1315
+ // its prompt explicitly. Existing active owners represent interrupted work.
1316
+ if (owned && statusIsActive(owned) && event.reason !== "new" && event.reason !== "fork") {
1317
+ scheduleStartupRecovery(ctx, owned);
1318
+ }
636
1319
  });
637
1320
 
638
- pi.on("before_agent_start", (_event, ctx) => {
1321
+ pi.on("before_agent_start", (event, ctx) => {
1322
+ clearRecoveryTimer();
639
1323
  const loaded = currentState(ctx);
640
1324
  if (!loaded || !statusIsActive(loaded)) return;
641
1325
  transitionInFlight = false;
642
1326
  renderWidget(ctx, loaded);
1327
+ if (loaded.status !== "active") return;
1328
+ return { systemPrompt: `${event.systemPrompt}\n\n${LOOP_AGENT_GUIDANCE}` };
643
1329
  });
644
1330
 
645
1331
  pi.on("agent_start", (_event, ctx) => {
@@ -649,37 +1335,53 @@ export default function loopExtension(pi: ExtensionAPI): void {
649
1335
 
650
1336
  pi.on("message_end", (event, ctx) => {
651
1337
  if (event.message.role !== "assistant") return;
652
- const stopReason = (event.message as { stopReason?: string }).stopReason;
653
- if (stopReason !== "aborted" && stopReason !== "error") return;
654
- const loaded = currentState(ctx);
655
- if (!loaded || !statusIsActive(loaded) || transitionInFlight) return;
656
- const paused = { ...loaded, status: "paused" as const };
657
- persist(pi, paused);
658
- renderWidget(ctx, paused);
1338
+ recordAssistantOutcome(ctx, event.message as { stopReason?: string; errorMessage?: string });
659
1339
  });
660
1340
 
661
1341
  pi.on("agent_end", (event, ctx) => {
662
1342
  const assistant = [...event.messages]
663
1343
  .reverse()
664
- .find((message) => message.role === "assistant") as { stopReason?: string } | undefined;
665
- if (!assistant || (assistant.stopReason !== "aborted" && assistant.stopReason !== "error")) return;
666
- const loaded = currentState(ctx);
667
- if (!loaded || !statusIsActive(loaded) || transitionInFlight) return;
668
- const paused = { ...loaded, status: "paused" as const };
669
- persist(pi, paused);
670
- renderWidget(ctx, paused);
1344
+ .find((message) => message.role === "assistant") as {
1345
+ stopReason?: string;
1346
+ errorMessage?: string;
1347
+ } | undefined;
1348
+ recordAssistantOutcome(ctx, assistant);
671
1349
  });
672
1350
 
673
1351
  pi.on("agent_settled", (_event, ctx) => {
674
1352
  const loaded = currentState(ctx);
675
1353
  if (!loaded || !statusIsActive(loaded) || transitionInFlight) return;
676
1354
  const key = stateKey(ctx, loaded);
1355
+ if (commandInterruptedKey === key && loaded.status === "active") {
1356
+ clearCommandInterruption();
1357
+ pendingFailure = undefined;
1358
+ handledSettlementKey = undefined;
1359
+ continueCurrentIteration(ctx, loaded);
1360
+ return;
1361
+ }
1362
+ clearCommandInterruption();
1363
+ if (pendingFailure?.key === key) {
1364
+ const failure = pendingFailure;
1365
+ pendingFailure = undefined;
1366
+ if (failure.stopReason === "error" && (loaded.retryCount ?? 0) < DEFAULT_LOOP_RETRIES) {
1367
+ scheduleRetry(ctx, loaded, failure);
1368
+ } else {
1369
+ pauseLoop(ctx, loaded, failure.reason);
1370
+ notify(ctx, `loop paused: ${failure.reason}`, "error");
1371
+ }
1372
+ return;
1373
+ }
677
1374
  if (handledSettlementKey === key) return;
678
1375
  handledSettlementKey = key;
679
- dispatchContinuation(ctx, loaded);
1376
+ scheduleContinuation(ctx, loaded);
680
1377
  });
681
1378
 
682
1379
  pi.on("session_tree", (_event, ctx) => {
1380
+ clearContinuationWait();
1381
+ clearRetryWait();
1382
+ clearRecoveryTimer();
1383
+ clearCommandInterruption();
1384
+ pendingFailure = undefined;
683
1385
  currentSessionManagerRef = ctx.sessionManager;
684
1386
  handledSettlementKey = undefined;
685
1387
  const loaded = latestStateFromContext(ctx);
@@ -687,11 +1389,17 @@ export default function loopExtension(pi: ExtensionAPI): void {
687
1389
  runState = owned;
688
1390
  if (!owned || owned.status === "inactive") clearWidget(ctx);
689
1391
  else renderWidget(ctx, owned);
1392
+ if (owned && statusIsActive(owned)) scheduleStartupRecovery(ctx, owned);
690
1393
  });
691
1394
 
692
- pi.on("session_shutdown", (_event, ctx) => {
1395
+ pi.on("session_shutdown", (event, ctx) => {
1396
+ clearContinuationWait();
1397
+ clearRetryWait();
1398
+ clearRecoveryTimer();
1399
+ clearCommandInterruption();
1400
+ pendingFailure = undefined;
693
1401
  const loaded = currentState(ctx);
694
- if (loaded && statusIsActive(loaded) && !transitionInFlight) {
1402
+ if (event.reason !== "reload" && loaded && statusIsActive(loaded) && !transitionInFlight) {
695
1403
  try {
696
1404
  persist(pi, { ...loaded, status: "inactive" });
697
1405
  } catch {
@@ -703,17 +1411,8 @@ export default function loopExtension(pi: ExtensionAPI): void {
703
1411
  });
704
1412
 
705
1413
  pi.registerCommand("loop", {
706
- description: "<count> <prompt> | <count> | ±<count> | status | resume | stop — Run a bounded fresh-session loop",
707
- getArgumentCompletions: (prefix) => completeArguments(prefix, [
708
- { value: "status", label: "status", description: "Show the current loop state" },
709
- { value: "resume", label: "resume", description: "Retry a paused iteration" },
710
- { value: "stop", label: "stop", description: "Stop gracefully" },
711
- { value: "+1", label: "+1", description: "Add one future iteration" },
712
- { value: "-1", label: "-1", description: "Remove one future iteration" },
713
- { value: "1 ", label: "1 <prompt>", description: "Run a prompt once" },
714
- { value: "3 ", label: "3 <prompt>", description: "Run a prompt three times" },
715
- { value: "5 ", label: "5 <prompt>", description: "Run a prompt five times" },
716
- ]),
1414
+ description: "<count> [--delay <duration>] <prompt> | for <duration> --delay <duration> <prompt> | controls — Run a bounded fresh-session loop",
1415
+ getArgumentCompletions: (prefix) => completeLoopArguments(prefix),
717
1416
  handler: async (args, ctx) => {
718
1417
  try {
719
1418
  await handleCommand(args, ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brettinternet/pi-loop",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Run a prompt repeatedly in fresh Pi sessions",
5
5
  "type": "module",
6
6
  "license": "MIT",