@brettinternet/pi-loop 0.1.2 → 0.1.4
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 +13 -3
- package/index.ts +251 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-loop
|
|
2
2
|
|
|
3
|
-
Run a prompt repeatedly, with a fresh session for every iteration.
|
|
3
|
+
Run a prompt repeatedly, with a fresh session for every iteration. Each new session uses the model selected in the preceding session (including a custom non-default model); changing models mid-loop takes effect on the next iteration. If that model is unavailable, the loop pauses rather than falling back to the default.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
pi install npm:@brettinternet/pi-loop
|
|
@@ -12,19 +12,21 @@ pi install npm:@brettinternet/pi-loop
|
|
|
12
12
|
/loop <count>
|
|
13
13
|
/loop +<count>
|
|
14
14
|
/loop -<count>
|
|
15
|
+
/loop time <duration>
|
|
15
16
|
/loop delay <duration>
|
|
16
17
|
/loop prompt <text>
|
|
17
18
|
/loop append <text>
|
|
18
19
|
/loop status
|
|
19
20
|
/loop
|
|
21
|
+
/loop pause
|
|
20
22
|
/loop end
|
|
21
23
|
/loop resume
|
|
22
24
|
/loop next
|
|
23
25
|
```
|
|
24
26
|
|
|
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.
|
|
27
|
+
Durations use `ms`, `s`, `m`, `h`, or `d`. Delays range from 1 second to 24 hours. Timed loops run for at most 30 days. During a loop, `/loop time <duration>` switches to a deadline from now or resets the current deadline; timed mode requires a non-zero delay. `/loop <count>` switches back to a count of future iterations.
|
|
26
28
|
|
|
27
|
-
Errors retry after 30 seconds, 1 minute, and 2 minutes, then pause. Aborting pauses the loop.
|
|
29
|
+
Errors retry after 30 seconds, 1 minute, and 2 minutes, then pause. Aborting pauses the loop. `/loop pause` pauses after the active iteration settles; if the loop is between iterations, it pauses immediately. Resuming then starts the next iteration. An agent `loop_pause` request pauses mid-iteration for human blockers, and resuming continues that iteration. Recovery preserves the loop so you can resume or advance it.
|
|
28
30
|
|
|
29
31
|
Chain commands in the prompt:
|
|
30
32
|
|
|
@@ -33,3 +35,11 @@ Chain commands in the prompt:
|
|
|
33
35
|
```
|
|
34
36
|
|
|
35
37
|
Built-in interactive commands cannot be chained.
|
|
38
|
+
|
|
39
|
+
While a loop is active, every child command receives its stable run ID as `PI_LOOP_RUN_ID`:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
PI_LOOP_RUN_ID=3992f183-e054-4068-a13e-11281d1747e2
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The value is always the full UUID, remains unchanged across every iteration and replacement session in that loop, and differs between independent loops. When the loop ends or its session closes, pi-loop restores the previous value or removes the variable if it was previously unset.
|
package/index.ts
CHANGED
|
@@ -11,8 +11,9 @@ import { Type } from "typebox";
|
|
|
11
11
|
|
|
12
12
|
export const LOOP_STATE_ENTRY = "pi-loop-state-v1";
|
|
13
13
|
export const LOOP_WIDGET_KEY = "pi-loop";
|
|
14
|
+
export const LOOP_RUN_ID_ENV = "PI_LOOP_RUN_ID";
|
|
14
15
|
export const LOOP_USAGE =
|
|
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
|
+
"usage: /loop <positive-count> [--delay <duration>] <prompt> | /loop for <duration> --delay <duration> <prompt> | /loop <positive-count> | /loop <+|-><count> | /loop time <duration> | /loop delay <duration> | /loop prompt <text> | /loop append <text> | /loop status | /loop pause | /loop resume | /loop next | /loop end";
|
|
16
17
|
|
|
17
18
|
export const MIN_LOOP_DELAY_MS = 1_000;
|
|
18
19
|
export const MAX_LOOP_DELAY_MS = 24 * 60 * 60 * 1_000;
|
|
@@ -26,7 +27,7 @@ const LOOP_AGENT_GUIDANCE = `## Active Loop
|
|
|
26
27
|
|
|
27
28
|
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.`;
|
|
28
29
|
|
|
29
|
-
export type LoopStatus = "active" | "stopping" | "paused" | "completed" | "stopped" | "inactive";
|
|
30
|
+
export type LoopStatus = "active" | "pausing" | "stopping" | "paused" | "completed" | "stopped" | "inactive";
|
|
30
31
|
export type LoopPhase = "running" | "waiting" | "retrying";
|
|
31
32
|
|
|
32
33
|
export interface LoopState {
|
|
@@ -47,6 +48,7 @@ export interface LoopState {
|
|
|
47
48
|
pausedAt?: number;
|
|
48
49
|
ownerSessionId?: string;
|
|
49
50
|
ownerSessionFile?: string;
|
|
51
|
+
model?: { provider: string; id: string };
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export type ParsedLoopCommand =
|
|
@@ -54,20 +56,51 @@ export type ParsedLoopCommand =
|
|
|
54
56
|
| { kind: "startTimed"; duration: number; delay: number; prompt: string }
|
|
55
57
|
| { kind: "retune"; count: number }
|
|
56
58
|
| { kind: "adjust"; delta: number }
|
|
59
|
+
| { kind: "time"; duration: number }
|
|
57
60
|
| { kind: "delay"; delay: number }
|
|
58
61
|
| { kind: "replacePrompt"; prompt: string }
|
|
59
62
|
| { kind: "appendPrompt"; prompt: string }
|
|
60
63
|
| { kind: "status" }
|
|
64
|
+
| { kind: "pauseAtBoundary" }
|
|
61
65
|
| { kind: "resume" }
|
|
62
66
|
| { kind: "next" }
|
|
63
67
|
| { kind: "end" }
|
|
64
68
|
| { kind: "continue"; runId: string; iteration: number }
|
|
65
|
-
| { kind: "pause"; runId: string; iteration: number }
|
|
69
|
+
| { kind: "pause"; runId: string; iteration: number }
|
|
70
|
+
| { kind: "restoreModel"; runId: string; iteration: number };
|
|
66
71
|
|
|
67
|
-
const ACTIVE_STATUSES = new Set<LoopStatus>(["active", "stopping"]);
|
|
68
|
-
const VISIBLE_STATUSES = new Set<LoopStatus>(["active", "stopping", "paused"]);
|
|
72
|
+
const ACTIVE_STATUSES = new Set<LoopStatus>(["active", "pausing", "stopping"]);
|
|
73
|
+
const VISIBLE_STATUSES = new Set<LoopStatus>(["active", "pausing", "stopping", "paused"]);
|
|
69
74
|
const TERMINAL_STATUSES = new Set<LoopStatus>(["completed", "stopped", "inactive"]);
|
|
70
75
|
|
|
76
|
+
const LOOP_ENVIRONMENT_STATE_KEY = "__piLoopRunEnvironmentV1";
|
|
77
|
+
type LoopEnvironmentState = { runId: string; originalValue: string | undefined };
|
|
78
|
+
|
|
79
|
+
function environmentState(): LoopEnvironmentState | undefined {
|
|
80
|
+
return (globalThis as Record<string, unknown>)[LOOP_ENVIRONMENT_STATE_KEY] as LoopEnvironmentState | undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function exposeLoopRunId(runId: string): void {
|
|
84
|
+
const active = environmentState();
|
|
85
|
+
if (active) {
|
|
86
|
+
active.runId = runId;
|
|
87
|
+
} else {
|
|
88
|
+
(globalThis as Record<string, unknown>)[LOOP_ENVIRONMENT_STATE_KEY] = {
|
|
89
|
+
runId,
|
|
90
|
+
originalValue: process.env[LOOP_RUN_ID_ENV],
|
|
91
|
+
} satisfies LoopEnvironmentState;
|
|
92
|
+
}
|
|
93
|
+
process.env[LOOP_RUN_ID_ENV] = runId;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function restoreLoopRunId(expectedRunId?: string): void {
|
|
97
|
+
const active = environmentState();
|
|
98
|
+
if (!active || (expectedRunId !== undefined && active.runId !== expectedRunId)) return;
|
|
99
|
+
if (active.originalValue === undefined) delete process.env[LOOP_RUN_ID_ENV];
|
|
100
|
+
else process.env[LOOP_RUN_ID_ENV] = active.originalValue;
|
|
101
|
+
delete (globalThis as Record<string, unknown>)[LOOP_ENVIRONMENT_STATE_KEY];
|
|
102
|
+
}
|
|
103
|
+
|
|
71
104
|
type ArgumentCompletion = { value: string; label: string; description?: string };
|
|
72
105
|
|
|
73
106
|
function completeArguments(
|
|
@@ -100,6 +133,15 @@ function completeLoopArguments(prefix: string): ArgumentCompletion[] | null {
|
|
|
100
133
|
const delayCommand = /^(delay|--delay)(?:\s+(.*))?$/.exec(input);
|
|
101
134
|
if (delayCommand?.[2] !== undefined) return delayCompletions(prefix, delayCommand[1]);
|
|
102
135
|
|
|
136
|
+
const timeCommand = /^time(?:\s+(.*))?$/.exec(input);
|
|
137
|
+
if (timeCommand?.[1] !== undefined) {
|
|
138
|
+
return completeArguments(timeCommand[1], COMMON_LOOP_TIMEFRAMES.map((value) => ({
|
|
139
|
+
value: `time ${value}`,
|
|
140
|
+
label: `time ${value}`,
|
|
141
|
+
description: "Switch to timed mode or reset the remaining time",
|
|
142
|
+
})));
|
|
143
|
+
}
|
|
144
|
+
|
|
103
145
|
const timedDelay = /^for\s+(\S+)\s+--delay(=|\s+)?(.*)$/.exec(input);
|
|
104
146
|
if (timedDelay) {
|
|
105
147
|
if (timedDelay[2] !== undefined) {
|
|
@@ -164,9 +206,11 @@ function completeLoopArguments(prefix: string): ArgumentCompletion[] | null {
|
|
|
164
206
|
|
|
165
207
|
return completeArguments(prefix, [
|
|
166
208
|
{ value: "status", label: "status", description: "Show the current loop state" },
|
|
167
|
-
{ value: "
|
|
209
|
+
{ value: "pause", label: "pause", description: "Pause after the active iteration settles" },
|
|
210
|
+
{ value: "resume", label: "resume", description: "Resume a paused loop" },
|
|
168
211
|
{ value: "next", label: "next", description: "Skip a paused iteration and start the next one" },
|
|
169
212
|
{ value: "end", label: "end", description: "End the loop gracefully" },
|
|
213
|
+
{ value: "time ", label: "time <duration>", description: "Switch to timed mode or reset the remaining time" },
|
|
170
214
|
{ value: "delay ", label: "delay <duration>", description: "Set the delay between settled iterations" },
|
|
171
215
|
{ value: "prompt ", label: "prompt <text>", description: "Replace the future loop prompt" },
|
|
172
216
|
{ value: "append ", label: "append <text>", description: "Append to the future loop prompt" },
|
|
@@ -255,6 +299,10 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
|
|
|
255
299
|
if (rest) throw new Error(`end does not accept arguments; ${LOOP_USAGE}`);
|
|
256
300
|
return { kind: "end" };
|
|
257
301
|
}
|
|
302
|
+
if (first === "pause") {
|
|
303
|
+
if (rest) throw new Error(`pause does not accept arguments; ${LOOP_USAGE}`);
|
|
304
|
+
return { kind: "pauseAtBoundary" };
|
|
305
|
+
}
|
|
258
306
|
if (first === "resume") {
|
|
259
307
|
if (rest) throw new Error(`resume does not accept arguments; ${LOOP_USAGE}`);
|
|
260
308
|
return { kind: "resume" };
|
|
@@ -263,6 +311,11 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
|
|
|
263
311
|
if (rest) throw new Error(`next does not accept arguments; ${LOOP_USAGE}`);
|
|
264
312
|
return { kind: "next" };
|
|
265
313
|
}
|
|
314
|
+
if (first === "time") {
|
|
315
|
+
const fields = rest.split(/\s+/).filter(Boolean);
|
|
316
|
+
if (fields.length !== 1) throw new Error(`time requires one duration; ${LOOP_USAGE}`);
|
|
317
|
+
return { kind: "time", duration: parseLoopTimeframe(fields[0]) };
|
|
318
|
+
}
|
|
266
319
|
if (first === "delay") {
|
|
267
320
|
const fields = rest.split(/\s+/).filter(Boolean);
|
|
268
321
|
if (fields.length !== 1) throw new Error(`delay requires one duration; ${LOOP_USAGE}`);
|
|
@@ -290,7 +343,7 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
|
|
|
290
343
|
// These commands are only emitted by the extension itself. Keeping them in
|
|
291
344
|
// the same dispatcher gives boundary transitions command-only session APIs
|
|
292
345
|
// while preventing user input from accidentally looking like one.
|
|
293
|
-
if (first === "__continue" || first === "__pause") {
|
|
346
|
+
if (first === "__continue" || first === "__pause" || first === "__restore_model") {
|
|
294
347
|
const fields = rest.split(/\s+/).filter(Boolean);
|
|
295
348
|
if (fields.length !== 2 || !/^[A-Za-z0-9_-]+$/.test(fields[0])) {
|
|
296
349
|
throw new Error("invalid internal loop command");
|
|
@@ -301,7 +354,9 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
|
|
|
301
354
|
}
|
|
302
355
|
return first === "__continue"
|
|
303
356
|
? { kind: "continue", runId: fields[0], iteration }
|
|
304
|
-
:
|
|
357
|
+
: first === "__pause"
|
|
358
|
+
? { kind: "pause", runId: fields[0], iteration }
|
|
359
|
+
: { kind: "restoreModel", runId: fields[0], iteration };
|
|
305
360
|
}
|
|
306
361
|
|
|
307
362
|
if (/^[+\-]\d/.test(first)) {
|
|
@@ -352,6 +407,7 @@ export function parseLoopState(value: unknown): LoopState | undefined {
|
|
|
352
407
|
const status = value.status;
|
|
353
408
|
if (
|
|
354
409
|
status !== "active" &&
|
|
410
|
+
status !== "pausing" &&
|
|
355
411
|
status !== "stopping" &&
|
|
356
412
|
status !== "paused" &&
|
|
357
413
|
status !== "completed" &&
|
|
@@ -388,6 +444,12 @@ export function parseLoopState(value: unknown): LoopState | undefined {
|
|
|
388
444
|
if (value.pausedAt !== undefined && !isNonNegativeInteger(value.pausedAt)) return undefined;
|
|
389
445
|
if (value.ownerSessionId !== undefined && typeof value.ownerSessionId !== "string") return undefined;
|
|
390
446
|
if (value.ownerSessionFile !== undefined && typeof value.ownerSessionFile !== "string") return undefined;
|
|
447
|
+
const model = value.model;
|
|
448
|
+
if (
|
|
449
|
+
model !== undefined &&
|
|
450
|
+
(!isRecord(model) || typeof model.provider !== "string" || !model.provider ||
|
|
451
|
+
typeof model.id !== "string" || !model.id)
|
|
452
|
+
) return undefined;
|
|
391
453
|
|
|
392
454
|
return {
|
|
393
455
|
version: 1,
|
|
@@ -407,6 +469,9 @@ export function parseLoopState(value: unknown): LoopState | undefined {
|
|
|
407
469
|
...(value.pausedAt !== undefined ? { pausedAt: value.pausedAt } : {}),
|
|
408
470
|
...(value.ownerSessionId ? { ownerSessionId: value.ownerSessionId } : {}),
|
|
409
471
|
...(value.ownerSessionFile ? { ownerSessionFile: value.ownerSessionFile } : {}),
|
|
472
|
+
...(isRecord(model) && typeof model.provider === "string" && typeof model.id === "string"
|
|
473
|
+
? { model: { provider: model.provider, id: model.id } }
|
|
474
|
+
: {}),
|
|
410
475
|
};
|
|
411
476
|
}
|
|
412
477
|
|
|
@@ -523,12 +588,13 @@ export function formatLoopWidget(state: LoopState, width: number, now = Date.now
|
|
|
523
588
|
const retries = (state.retryCount ?? 0) > 0
|
|
524
589
|
? ` · retry ${state.retryCount}/${DEFAULT_LOOP_RETRIES}`
|
|
525
590
|
: "";
|
|
526
|
-
|
|
527
|
-
|
|
591
|
+
const iteration = state.endsAt === undefined ? "" : ` · #${state.currentIteration}`;
|
|
592
|
+
if (state.status === "pausing" || state.status === "stopping") {
|
|
593
|
+
return truncateToWidth(`loop ${state.status}${iteration}${timeframe}${delay}${retries} · ${prompt}`, width, "…");
|
|
528
594
|
}
|
|
529
595
|
if (state.endsAt !== undefined) {
|
|
530
596
|
return truncateToWidth(
|
|
531
|
-
`loop ${state.status}${timeframe}${delay}${retries} · ${prompt}`,
|
|
597
|
+
`loop ${state.status}${iteration}${timeframe}${delay}${retries} · ${prompt}`,
|
|
532
598
|
width,
|
|
533
599
|
"…",
|
|
534
600
|
);
|
|
@@ -572,15 +638,36 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
572
638
|
let commandInterruptedKey: string | undefined;
|
|
573
639
|
let pendingFailure: PendingFailure | undefined;
|
|
574
640
|
let currentSessionManagerRef: unknown;
|
|
641
|
+
let herdrBlocked = false;
|
|
642
|
+
|
|
643
|
+
function reportHerdrBlocked(state: LoopState | undefined): void {
|
|
644
|
+
const blocked = state?.status === "paused";
|
|
645
|
+
if (blocked === herdrBlocked) return;
|
|
646
|
+
herdrBlocked = blocked;
|
|
647
|
+
pi.events.emit("herdr:blocked", blocked
|
|
648
|
+
? { active: true, label: `Loop paused: ${state.pauseReason ?? "human input required"}`, scope: "root" }
|
|
649
|
+
: { active: false, scope: "root" });
|
|
650
|
+
}
|
|
575
651
|
|
|
576
652
|
function stateFrom(ctx: ContextWithSession): LoopState | undefined {
|
|
577
653
|
runState = latestStateFromContext(ctx);
|
|
578
654
|
return runState;
|
|
579
655
|
}
|
|
580
656
|
|
|
657
|
+
function syncLoopEnvironment(state: LoopState | undefined): void {
|
|
658
|
+
const runId = state?.runId;
|
|
659
|
+
if (state && statusIsVisible(state)) {
|
|
660
|
+
exposeLoopRunId(state.runId);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (!transitionInFlight) restoreLoopRunId(runId);
|
|
664
|
+
}
|
|
665
|
+
|
|
581
666
|
function persist(ctx: Pick<ExtensionAPI, "appendEntry">, state: LoopState): void {
|
|
582
667
|
ctx.appendEntry(LOOP_STATE_ENTRY, state);
|
|
583
668
|
runState = state;
|
|
669
|
+
syncLoopEnvironment(state);
|
|
670
|
+
reportHerdrBlocked(state);
|
|
584
671
|
}
|
|
585
672
|
|
|
586
673
|
function notify(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info"): void {
|
|
@@ -602,6 +689,8 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
602
689
|
ctx.ui.setWidget(LOOP_WIDGET_KEY, [formatLoopWidget(state, Number.MAX_SAFE_INTEGER)], { placement: "belowEditor" });
|
|
603
690
|
return;
|
|
604
691
|
}
|
|
692
|
+
// Replacing the widget invalidates Pi's parent layout caches. Requesting a
|
|
693
|
+
// render alone can leave the previous line visible until another UI event.
|
|
605
694
|
ctx.ui.setWidget(LOOP_WIDGET_KEY, (_tui, _theme) => ({
|
|
606
695
|
render: (width) => [formatLoopWidget(state, width)],
|
|
607
696
|
invalidate: () => {},
|
|
@@ -625,8 +714,11 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
625
714
|
}
|
|
626
715
|
if (currentSessionManagerRef !== undefined && sessionManager !== currentSessionManagerRef) return undefined;
|
|
627
716
|
const loaded = stateFrom(ctx);
|
|
628
|
-
if (!loaded)
|
|
629
|
-
|
|
717
|
+
if (!loaded || !stateBelongsToContext(loaded, ctx)) {
|
|
718
|
+
reportHerdrBlocked(undefined);
|
|
719
|
+
return undefined;
|
|
720
|
+
}
|
|
721
|
+
reportHerdrBlocked(loaded);
|
|
630
722
|
return loaded;
|
|
631
723
|
}
|
|
632
724
|
|
|
@@ -679,7 +771,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
679
771
|
): void {
|
|
680
772
|
if (!statusIsActive(state) || transitionInFlight) return;
|
|
681
773
|
const nextBudget = state.pendingRetune ?? state.remainingBudget;
|
|
682
|
-
if (state.status === "stopping" || (state.endsAt === undefined && nextBudget <= 0) || state.delay === 0) {
|
|
774
|
+
if (state.status === "pausing" || state.status === "stopping" || (state.endsAt === undefined && nextBudget <= 0) || state.delay === 0) {
|
|
683
775
|
clearContinuationWait();
|
|
684
776
|
dispatchContinuation(ctx, state);
|
|
685
777
|
return;
|
|
@@ -845,6 +937,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
845
937
|
|
|
846
938
|
function transferState(state: LoopState, manager: SessionManager): LoopState {
|
|
847
939
|
const transferred = stateForSession({ ...state, status: "active" }, sessionIdentity(manager));
|
|
940
|
+
exposeLoopRunId(transferred.runId);
|
|
848
941
|
manager.appendCustomEntry(LOOP_STATE_ENTRY, transferred);
|
|
849
942
|
return transferred;
|
|
850
943
|
}
|
|
@@ -853,9 +946,17 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
853
946
|
replacement: ReplacementContext,
|
|
854
947
|
state: LoopState,
|
|
855
948
|
): Promise<void> {
|
|
856
|
-
// The new extension
|
|
857
|
-
//
|
|
858
|
-
|
|
949
|
+
// The replacement owns the new extension runtime. Restore its model before
|
|
950
|
+
// starting a turn, rather than using the invalidated previous runtime.
|
|
951
|
+
if (state.model) {
|
|
952
|
+
await replacement.sendUserMessage(
|
|
953
|
+
`/loop __restore_model ${state.runId} ${state.currentIteration}`,
|
|
954
|
+
{ expandPromptTemplates: true },
|
|
955
|
+
);
|
|
956
|
+
if (replacement.model?.provider !== state.model.provider || replacement.model.id !== state.model.id) {
|
|
957
|
+
throw new Error(`loop model unavailable: ${state.model.provider}/${state.model.id}`);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
859
960
|
if (replacement.hasUI) showWidget(replacement, state);
|
|
860
961
|
if (state.endsAt !== undefined && Date.now() >= state.endsAt) {
|
|
861
962
|
await replacement.sendUserMessage(
|
|
@@ -868,6 +969,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
868
969
|
}
|
|
869
970
|
|
|
870
971
|
async function replaceForIteration(ctx: ExtensionCommandContext, next: LoopState): Promise<void> {
|
|
972
|
+
exposeLoopRunId(next.runId);
|
|
871
973
|
clearContinuationWait();
|
|
872
974
|
clearRetryWait();
|
|
873
975
|
clearRecoveryTimer();
|
|
@@ -971,6 +1073,27 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
971
1073
|
clearContinuationWait();
|
|
972
1074
|
clearRetryWait();
|
|
973
1075
|
|
|
1076
|
+
if (state.status === "pausing") {
|
|
1077
|
+
const {
|
|
1078
|
+
nextActionAt: _nextActionAt,
|
|
1079
|
+
pauseReason: _pauseReason,
|
|
1080
|
+
...withoutSchedule
|
|
1081
|
+
} = state;
|
|
1082
|
+
const paused: LoopState = {
|
|
1083
|
+
...withoutSchedule,
|
|
1084
|
+
status: "paused",
|
|
1085
|
+
phase: "waiting",
|
|
1086
|
+
settledAt: Date.now(),
|
|
1087
|
+
pauseReason: "paused by user",
|
|
1088
|
+
pausedAt: Date.now(),
|
|
1089
|
+
};
|
|
1090
|
+
persist(pi, paused);
|
|
1091
|
+
renderWidget(ctx, paused);
|
|
1092
|
+
runState = paused;
|
|
1093
|
+
notify(ctx, "loop paused", "info");
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
974
1097
|
if (state.status === "stopping") {
|
|
975
1098
|
const stopped = { ...state, status: "stopped" as const };
|
|
976
1099
|
persist(pi, stopped);
|
|
@@ -1007,6 +1130,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1007
1130
|
} = state;
|
|
1008
1131
|
const next: LoopState = {
|
|
1009
1132
|
...withoutPause,
|
|
1133
|
+
...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}),
|
|
1010
1134
|
currentIteration: state.currentIteration + 1,
|
|
1011
1135
|
remainingBudget: state.endsAt === undefined ? nextBudget - 1 : 0,
|
|
1012
1136
|
pendingRetune: null,
|
|
@@ -1058,6 +1182,16 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1058
1182
|
return;
|
|
1059
1183
|
}
|
|
1060
1184
|
|
|
1185
|
+
if (parsed.kind === "restoreModel") {
|
|
1186
|
+
const state = currentState(ctx);
|
|
1187
|
+
if (!state || state.runId !== parsed.runId || state.currentIteration !== parsed.iteration || !state.model) return;
|
|
1188
|
+
const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
|
|
1189
|
+
if (!model || !(await pi.setModel(model))) {
|
|
1190
|
+
throw new Error(`loop model unavailable: ${state.model.provider}/${state.model.id}`);
|
|
1191
|
+
}
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1061
1195
|
if (parsed.kind === "pause") {
|
|
1062
1196
|
const state = currentState(ctx);
|
|
1063
1197
|
if (!state || state.runId !== parsed.runId || state.currentIteration !== parsed.iteration || !statusIsActive(state)) return;
|
|
@@ -1075,6 +1209,41 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1075
1209
|
return;
|
|
1076
1210
|
}
|
|
1077
1211
|
|
|
1212
|
+
if (parsed.kind === "time") {
|
|
1213
|
+
if (!state || isTerminal(state)) {
|
|
1214
|
+
notify(ctx, "a loop must be active, stopping, or paused to update its remaining time", "error");
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
if (state.delay === 0) {
|
|
1218
|
+
notify(ctx, "timed loops require a non-zero delay; set /loop delay <duration> first", "error");
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
const switchingModes = state.endsAt === undefined;
|
|
1222
|
+
const endsAt = Date.now() + parsed.duration;
|
|
1223
|
+
const nextActionAt = state.phase === "waiting" && state.nextActionAt !== undefined
|
|
1224
|
+
? Math.min((state.settledAt ?? state.nextActionAt - state.delay) + state.delay, endsAt)
|
|
1225
|
+
: state.nextActionAt;
|
|
1226
|
+
const updated = {
|
|
1227
|
+
...state,
|
|
1228
|
+
remainingBudget: 0,
|
|
1229
|
+
pendingRetune: null,
|
|
1230
|
+
endsAt,
|
|
1231
|
+
...(nextActionAt !== undefined ? { nextActionAt } : {}),
|
|
1232
|
+
};
|
|
1233
|
+
persist(pi, updated);
|
|
1234
|
+
renderWidget(ctx, updated);
|
|
1235
|
+
if (state.status === "active") rescheduleContinuation(ctx, updated);
|
|
1236
|
+
const duration = formatLoopDelay(parsed.duration);
|
|
1237
|
+
if (state.status === "paused") {
|
|
1238
|
+
notify(ctx, `loop ${switchingModes ? `changed to timed mode with ${duration} left` : `time left set to ${duration}`}; resume will use it`, "info");
|
|
1239
|
+
} else if (state.status === "stopping") {
|
|
1240
|
+
notify(ctx, `loop ${switchingModes ? `changed to timed mode with ${duration} left` : `time left set to ${duration}`}; loop is still stopping`, "info");
|
|
1241
|
+
} else {
|
|
1242
|
+
notify(ctx, switchingModes ? `loop changed to timed mode with ${duration} left` : `loop time left set to ${duration}`, "info");
|
|
1243
|
+
}
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1078
1247
|
if (parsed.kind === "delay") {
|
|
1079
1248
|
if (!state || isTerminal(state)) {
|
|
1080
1249
|
notify(ctx, "a loop must be active, stopping, or paused to update its delay", "error");
|
|
@@ -1108,6 +1277,35 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1108
1277
|
return;
|
|
1109
1278
|
}
|
|
1110
1279
|
|
|
1280
|
+
if (parsed.kind === "pauseAtBoundary") {
|
|
1281
|
+
if (!state || isTerminal(state)) {
|
|
1282
|
+
notify(ctx, "a loop must be active to pause", "error");
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (state.status === "paused") {
|
|
1286
|
+
notify(ctx, "loop is already paused", "info");
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
if (state.status === "pausing") {
|
|
1290
|
+
notify(ctx, "loop will pause after the active iteration", "info");
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
if (state.phase === "retrying") {
|
|
1294
|
+
pauseLoop(ctx, state, "paused by user");
|
|
1295
|
+
notify(ctx, "loop paused", "info");
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
const pausing: LoopState = { ...state, status: "pausing" };
|
|
1299
|
+
persist(pi, pausing);
|
|
1300
|
+
renderWidget(ctx, pausing);
|
|
1301
|
+
if (state.phase === "waiting") {
|
|
1302
|
+
await advanceAtBoundary(ctx, pausing.runId, pausing.currentIteration);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
notify(ctx, "loop will pause after the active iteration", "info");
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1111
1309
|
if (parsed.kind === "end") {
|
|
1112
1310
|
if (!state || state.status === "inactive" || state.status === "completed" || state.status === "stopped") {
|
|
1113
1311
|
notify(ctx, "loop: no active run", "info");
|
|
@@ -1145,7 +1343,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1145
1343
|
}
|
|
1146
1344
|
|
|
1147
1345
|
if (parsed.kind === "resume") {
|
|
1148
|
-
if (state?.status === "stopping") {
|
|
1346
|
+
if (state?.status === "pausing" || state?.status === "stopping") {
|
|
1149
1347
|
const resumed = { ...state, status: "active" as const };
|
|
1150
1348
|
persist(pi, resumed);
|
|
1151
1349
|
renderWidget(ctx, resumed);
|
|
@@ -1160,6 +1358,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1160
1358
|
await advanceAtBoundary(ctx, state.runId, state.currentIteration, true);
|
|
1161
1359
|
return;
|
|
1162
1360
|
}
|
|
1361
|
+
const pausedAtBoundary = state.phase === "waiting";
|
|
1163
1362
|
const {
|
|
1164
1363
|
pauseReason: _pauseReason,
|
|
1165
1364
|
pausedAt: _pausedAt,
|
|
@@ -1176,7 +1375,11 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1176
1375
|
persist(pi, resumed);
|
|
1177
1376
|
renderWidget(ctx, resumed);
|
|
1178
1377
|
handledSettlementKey = undefined;
|
|
1179
|
-
|
|
1378
|
+
if (pausedAtBoundary) {
|
|
1379
|
+
await advanceAtBoundary(ctx, resumed.runId, resumed.currentIteration);
|
|
1380
|
+
} else {
|
|
1381
|
+
continueCurrentIteration(ctx, resumed);
|
|
1382
|
+
}
|
|
1180
1383
|
return;
|
|
1181
1384
|
}
|
|
1182
1385
|
|
|
@@ -1214,12 +1417,18 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1214
1417
|
}
|
|
1215
1418
|
|
|
1216
1419
|
if (parsed.kind === "retune" || parsed.kind === "adjust") {
|
|
1217
|
-
|
|
1218
|
-
|
|
1420
|
+
const canRetune = state && (
|
|
1421
|
+
state.status === "active" ||
|
|
1422
|
+
state.status === "stopping" ||
|
|
1423
|
+
(parsed.kind === "retune" && state.status === "paused")
|
|
1424
|
+
);
|
|
1425
|
+
if (!canRetune) {
|
|
1426
|
+
notify(ctx, "a loop must be active, stopping, or paused to retune its remaining budget", "error");
|
|
1219
1427
|
return;
|
|
1220
1428
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1429
|
+
const switchingModes = state.endsAt !== undefined;
|
|
1430
|
+
if (switchingModes && parsed.kind === "adjust") {
|
|
1431
|
+
notify(ctx, "a timed loop has no iteration budget to adjust; use /loop <positive-count> to switch modes", "error");
|
|
1223
1432
|
return;
|
|
1224
1433
|
}
|
|
1225
1434
|
const currentBudget = state.pendingRetune ?? state.remainingBudget;
|
|
@@ -1228,11 +1437,20 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1228
1437
|
notify(ctx, `cannot subtract more than the ${currentBudget} future iteration${currentBudget === 1 ? "" : "s"}`, "error");
|
|
1229
1438
|
return;
|
|
1230
1439
|
}
|
|
1231
|
-
const
|
|
1440
|
+
const { endsAt: _endsAt, ...withoutDeadline } = state;
|
|
1441
|
+
const retuned = {
|
|
1442
|
+
...withoutDeadline,
|
|
1443
|
+
pendingRetune: nextBudget,
|
|
1444
|
+
status: state.status === "paused" ? "paused" as const : "active" as const,
|
|
1445
|
+
};
|
|
1232
1446
|
persist(pi, retuned);
|
|
1233
1447
|
renderWidget(ctx, retuned);
|
|
1234
|
-
if (nextBudget <= 0) rescheduleContinuation(ctx, retuned);
|
|
1235
|
-
|
|
1448
|
+
if (switchingModes || nextBudget <= 0) rescheduleContinuation(ctx, retuned);
|
|
1449
|
+
if (state.status === "paused") {
|
|
1450
|
+
notify(ctx, `loop ${switchingModes ? "changed to" : "set to"} ${nextBudget} future iteration${nextBudget === 1 ? "" : "s"}; resume will use it`, "info");
|
|
1451
|
+
} else {
|
|
1452
|
+
notify(ctx, `loop ${switchingModes ? "changed to" : "will run"} ${nextBudget} future iteration${nextBudget === 1 ? "" : "s"}`, "info");
|
|
1453
|
+
}
|
|
1236
1454
|
return;
|
|
1237
1455
|
}
|
|
1238
1456
|
|
|
@@ -1257,6 +1475,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1257
1475
|
status: "active",
|
|
1258
1476
|
retryCount: 0,
|
|
1259
1477
|
phase: "running",
|
|
1478
|
+
...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}),
|
|
1260
1479
|
...(timed ? { endsAt: Date.now() + parsed.duration } : {}),
|
|
1261
1480
|
...(contextIdentity(ctx).id ? { ownerSessionId: contextIdentity(ctx).id } : {}),
|
|
1262
1481
|
...(contextIdentity(ctx).file ? { ownerSessionFile: contextIdentity(ctx).file } : {}),
|
|
@@ -1309,6 +1528,8 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1309
1528
|
const loaded = latestStateFromContext(ctx);
|
|
1310
1529
|
const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
|
|
1311
1530
|
runState = owned;
|
|
1531
|
+
syncLoopEnvironment(owned);
|
|
1532
|
+
reportHerdrBlocked(owned);
|
|
1312
1533
|
if (!owned || owned.status === "inactive") clearWidget(ctx);
|
|
1313
1534
|
else renderWidget(ctx, owned);
|
|
1314
1535
|
// New-session setup writes transferred state after this event and starts
|
|
@@ -1387,6 +1608,8 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1387
1608
|
const loaded = latestStateFromContext(ctx);
|
|
1388
1609
|
const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
|
|
1389
1610
|
runState = owned;
|
|
1611
|
+
syncLoopEnvironment(owned);
|
|
1612
|
+
reportHerdrBlocked(owned);
|
|
1390
1613
|
if (!owned || owned.status === "inactive") clearWidget(ctx);
|
|
1391
1614
|
else renderWidget(ctx, owned);
|
|
1392
1615
|
if (owned && statusIsActive(owned)) scheduleStartupRecovery(ctx, owned);
|
|
@@ -1406,6 +1629,8 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1406
1629
|
// Shutdown may already have detached the runtime's append action.
|
|
1407
1630
|
}
|
|
1408
1631
|
}
|
|
1632
|
+
if (event.reason !== "reload" && !transitionInFlight) restoreLoopRunId(loaded?.runId);
|
|
1633
|
+
reportHerdrBlocked(undefined);
|
|
1409
1634
|
clearWidget(ctx);
|
|
1410
1635
|
currentSessionManagerRef = undefined;
|
|
1411
1636
|
});
|