@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.
- package/README.md +25 -18
- package/index.ts +768 -69
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,28 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-loop
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run a prompt repeatedly, with a fresh session for every iteration.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
```bash
|
|
6
|
+
pi install npm:@brettinternet/pi-loop
|
|
7
|
+
```
|
|
6
8
|
|
|
7
9
|
```text
|
|
8
|
-
/loop <count> <prompt>
|
|
9
|
-
/loop <
|
|
10
|
-
/loop
|
|
11
|
-
/loop
|
|
12
|
-
/loop
|
|
13
|
-
/loop
|
|
14
|
-
/loop
|
|
15
|
-
/loop
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
+
Chain commands in the prompt:
|
|
25
30
|
|
|
26
|
-
```
|
|
27
|
-
|
|
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
|
|
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: "
|
|
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: "
|
|
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 === "
|
|
81
|
-
if (rest) throw new Error(`
|
|
82
|
-
return { kind: "
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ${
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
...
|
|
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
|
|
1031
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
494
1032
|
try {
|
|
495
|
-
|
|
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: ${
|
|
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
|
|
1043
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
507
1044
|
try {
|
|
508
|
-
|
|
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: ${
|
|
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
|
-
|
|
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 === "
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
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", (
|
|
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
|
-
|
|
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 {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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", (
|
|
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> | <
|
|
707
|
-
getArgumentCompletions: (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);
|