@brettinternet/pi-loop 0.1.3 → 0.1.5
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 +11 -1
- package/index.ts +116 -6
- 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
|
|
@@ -26,6 +26,8 @@ pi install npm:@brettinternet/pi-loop
|
|
|
26
26
|
|
|
27
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.
|
|
28
28
|
|
|
29
|
+
A pending `/wait` or `until` watch keeps the current iteration's session alive until its wake-up turn settles (or it is cancelled). A paused wait or recurring watch holds the iteration until resumed or completed. Use `/loop delay` for a simple fixed gap between iterations; use `/wait` for a same-session follow-up and `until` for a condition that might become true earlier than a fixed deadline.
|
|
30
|
+
|
|
29
31
|
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.
|
|
30
32
|
|
|
31
33
|
Chain commands in the prompt:
|
|
@@ -35,3 +37,11 @@ Chain commands in the prompt:
|
|
|
35
37
|
```
|
|
36
38
|
|
|
37
39
|
Built-in interactive commands cannot be chained.
|
|
40
|
+
|
|
41
|
+
While a loop is active, every child command receives its stable run ID as `PI_LOOP_RUN_ID`:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
PI_LOOP_RUN_ID=3992f183-e054-4068-a13e-11281d1747e2
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
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,6 +11,7 @@ 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
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
|
|
|
@@ -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 =
|
|
@@ -64,12 +66,41 @@ export type ParsedLoopCommand =
|
|
|
64
66
|
| { kind: "next" }
|
|
65
67
|
| { kind: "end" }
|
|
66
68
|
| { kind: "continue"; runId: string; iteration: number }
|
|
67
|
-
| { kind: "pause"; runId: string; iteration: number }
|
|
69
|
+
| { kind: "pause"; runId: string; iteration: number }
|
|
70
|
+
| { kind: "restoreModel"; runId: string; iteration: number };
|
|
68
71
|
|
|
69
72
|
const ACTIVE_STATUSES = new Set<LoopStatus>(["active", "pausing", "stopping"]);
|
|
70
73
|
const VISIBLE_STATUSES = new Set<LoopStatus>(["active", "pausing", "stopping", "paused"]);
|
|
71
74
|
const TERMINAL_STATUSES = new Set<LoopStatus>(["completed", "stopped", "inactive"]);
|
|
72
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
|
+
|
|
73
104
|
type ArgumentCompletion = { value: string; label: string; description?: string };
|
|
74
105
|
|
|
75
106
|
function completeArguments(
|
|
@@ -312,7 +343,7 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
|
|
|
312
343
|
// These commands are only emitted by the extension itself. Keeping them in
|
|
313
344
|
// the same dispatcher gives boundary transitions command-only session APIs
|
|
314
345
|
// while preventing user input from accidentally looking like one.
|
|
315
|
-
if (first === "__continue" || first === "__pause") {
|
|
346
|
+
if (first === "__continue" || first === "__pause" || first === "__restore_model") {
|
|
316
347
|
const fields = rest.split(/\s+/).filter(Boolean);
|
|
317
348
|
if (fields.length !== 2 || !/^[A-Za-z0-9_-]+$/.test(fields[0])) {
|
|
318
349
|
throw new Error("invalid internal loop command");
|
|
@@ -323,7 +354,9 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
|
|
|
323
354
|
}
|
|
324
355
|
return first === "__continue"
|
|
325
356
|
? { kind: "continue", runId: fields[0], iteration }
|
|
326
|
-
:
|
|
357
|
+
: first === "__pause"
|
|
358
|
+
? { kind: "pause", runId: fields[0], iteration }
|
|
359
|
+
: { kind: "restoreModel", runId: fields[0], iteration };
|
|
327
360
|
}
|
|
328
361
|
|
|
329
362
|
if (/^[+\-]\d/.test(first)) {
|
|
@@ -411,6 +444,12 @@ export function parseLoopState(value: unknown): LoopState | undefined {
|
|
|
411
444
|
if (value.pausedAt !== undefined && !isNonNegativeInteger(value.pausedAt)) return undefined;
|
|
412
445
|
if (value.ownerSessionId !== undefined && typeof value.ownerSessionId !== "string") return undefined;
|
|
413
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;
|
|
414
453
|
|
|
415
454
|
return {
|
|
416
455
|
version: 1,
|
|
@@ -430,6 +469,9 @@ export function parseLoopState(value: unknown): LoopState | undefined {
|
|
|
430
469
|
...(value.pausedAt !== undefined ? { pausedAt: value.pausedAt } : {}),
|
|
431
470
|
...(value.ownerSessionId ? { ownerSessionId: value.ownerSessionId } : {}),
|
|
432
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
|
+
: {}),
|
|
433
475
|
};
|
|
434
476
|
}
|
|
435
477
|
|
|
@@ -597,6 +639,24 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
597
639
|
let pendingFailure: PendingFailure | undefined;
|
|
598
640
|
let currentSessionManagerRef: unknown;
|
|
599
641
|
let herdrBlocked = false;
|
|
642
|
+
const pendingWakes = new Map<string, boolean>();
|
|
643
|
+
let waitingForWake = false;
|
|
644
|
+
let wakeContext: ExtensionContext | undefined;
|
|
645
|
+
|
|
646
|
+
for (const channel of ["pi-until:busy", "pi-wait:busy"]) {
|
|
647
|
+
pi.events.on(channel, (value) => {
|
|
648
|
+
if (typeof value !== "boolean") return;
|
|
649
|
+
pendingWakes.set(channel, value);
|
|
650
|
+
if (!waitingForWake || [...pendingWakes.values()].some(Boolean)) return;
|
|
651
|
+
const ctx = wakeContext;
|
|
652
|
+
if (!ctx || !ctx.isIdle()) return;
|
|
653
|
+
const state = currentState(ctx);
|
|
654
|
+
if (!state || !statusIsActive(state) || transitionInFlight) return;
|
|
655
|
+
waitingForWake = false;
|
|
656
|
+
handledSettlementKey = stateKey(ctx, state);
|
|
657
|
+
scheduleContinuation(ctx, state);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
600
660
|
|
|
601
661
|
function reportHerdrBlocked(state: LoopState | undefined): void {
|
|
602
662
|
const blocked = state?.status === "paused";
|
|
@@ -612,9 +672,19 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
612
672
|
return runState;
|
|
613
673
|
}
|
|
614
674
|
|
|
675
|
+
function syncLoopEnvironment(state: LoopState | undefined): void {
|
|
676
|
+
const runId = state?.runId;
|
|
677
|
+
if (state && statusIsVisible(state)) {
|
|
678
|
+
exposeLoopRunId(state.runId);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (!transitionInFlight) restoreLoopRunId(runId);
|
|
682
|
+
}
|
|
683
|
+
|
|
615
684
|
function persist(ctx: Pick<ExtensionAPI, "appendEntry">, state: LoopState): void {
|
|
616
685
|
ctx.appendEntry(LOOP_STATE_ENTRY, state);
|
|
617
686
|
runState = state;
|
|
687
|
+
syncLoopEnvironment(state);
|
|
618
688
|
reportHerdrBlocked(state);
|
|
619
689
|
}
|
|
620
690
|
|
|
@@ -885,6 +955,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
885
955
|
|
|
886
956
|
function transferState(state: LoopState, manager: SessionManager): LoopState {
|
|
887
957
|
const transferred = stateForSession({ ...state, status: "active" }, sessionIdentity(manager));
|
|
958
|
+
exposeLoopRunId(transferred.runId);
|
|
888
959
|
manager.appendCustomEntry(LOOP_STATE_ENTRY, transferred);
|
|
889
960
|
return transferred;
|
|
890
961
|
}
|
|
@@ -893,9 +964,17 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
893
964
|
replacement: ReplacementContext,
|
|
894
965
|
state: LoopState,
|
|
895
966
|
): Promise<void> {
|
|
896
|
-
// The new extension
|
|
897
|
-
//
|
|
898
|
-
|
|
967
|
+
// The replacement owns the new extension runtime. Restore its model before
|
|
968
|
+
// starting a turn, rather than using the invalidated previous runtime.
|
|
969
|
+
if (state.model) {
|
|
970
|
+
await replacement.sendUserMessage(
|
|
971
|
+
`/loop __restore_model ${state.runId} ${state.currentIteration}`,
|
|
972
|
+
{ expandPromptTemplates: true },
|
|
973
|
+
);
|
|
974
|
+
if (replacement.model?.provider !== state.model.provider || replacement.model.id !== state.model.id) {
|
|
975
|
+
throw new Error(`loop model unavailable: ${state.model.provider}/${state.model.id}`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
899
978
|
if (replacement.hasUI) showWidget(replacement, state);
|
|
900
979
|
if (state.endsAt !== undefined && Date.now() >= state.endsAt) {
|
|
901
980
|
await replacement.sendUserMessage(
|
|
@@ -908,6 +987,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
908
987
|
}
|
|
909
988
|
|
|
910
989
|
async function replaceForIteration(ctx: ExtensionCommandContext, next: LoopState): Promise<void> {
|
|
990
|
+
exposeLoopRunId(next.runId);
|
|
911
991
|
clearContinuationWait();
|
|
912
992
|
clearRetryWait();
|
|
913
993
|
clearRecoveryTimer();
|
|
@@ -1068,6 +1148,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1068
1148
|
} = state;
|
|
1069
1149
|
const next: LoopState = {
|
|
1070
1150
|
...withoutPause,
|
|
1151
|
+
...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}),
|
|
1071
1152
|
currentIteration: state.currentIteration + 1,
|
|
1072
1153
|
remainingBudget: state.endsAt === undefined ? nextBudget - 1 : 0,
|
|
1073
1154
|
pendingRetune: null,
|
|
@@ -1119,6 +1200,16 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1119
1200
|
return;
|
|
1120
1201
|
}
|
|
1121
1202
|
|
|
1203
|
+
if (parsed.kind === "restoreModel") {
|
|
1204
|
+
const state = currentState(ctx);
|
|
1205
|
+
if (!state || state.runId !== parsed.runId || state.currentIteration !== parsed.iteration || !state.model) return;
|
|
1206
|
+
const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
|
|
1207
|
+
if (!model || !(await pi.setModel(model))) {
|
|
1208
|
+
throw new Error(`loop model unavailable: ${state.model.provider}/${state.model.id}`);
|
|
1209
|
+
}
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1122
1213
|
if (parsed.kind === "pause") {
|
|
1123
1214
|
const state = currentState(ctx);
|
|
1124
1215
|
if (!state || state.runId !== parsed.runId || state.currentIteration !== parsed.iteration || !statusIsActive(state)) return;
|
|
@@ -1402,6 +1493,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1402
1493
|
status: "active",
|
|
1403
1494
|
retryCount: 0,
|
|
1404
1495
|
phase: "running",
|
|
1496
|
+
...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}),
|
|
1405
1497
|
...(timed ? { endsAt: Date.now() + parsed.duration } : {}),
|
|
1406
1498
|
...(contextIdentity(ctx).id ? { ownerSessionId: contextIdentity(ctx).id } : {}),
|
|
1407
1499
|
...(contextIdentity(ctx).file ? { ownerSessionFile: contextIdentity(ctx).file } : {}),
|
|
@@ -1449,11 +1541,15 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1449
1541
|
clearCommandInterruption();
|
|
1450
1542
|
pendingFailure = undefined;
|
|
1451
1543
|
currentSessionManagerRef = ctx.sessionManager;
|
|
1544
|
+
pendingWakes.clear();
|
|
1545
|
+
waitingForWake = false;
|
|
1546
|
+
wakeContext = ctx;
|
|
1452
1547
|
transitionInFlight = false;
|
|
1453
1548
|
handledSettlementKey = undefined;
|
|
1454
1549
|
const loaded = latestStateFromContext(ctx);
|
|
1455
1550
|
const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
|
|
1456
1551
|
runState = owned;
|
|
1552
|
+
syncLoopEnvironment(owned);
|
|
1457
1553
|
reportHerdrBlocked(owned);
|
|
1458
1554
|
if (!owned || owned.status === "inactive") clearWidget(ctx);
|
|
1459
1555
|
else renderWidget(ctx, owned);
|
|
@@ -1518,6 +1614,12 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1518
1614
|
return;
|
|
1519
1615
|
}
|
|
1520
1616
|
if (handledSettlementKey === key) return;
|
|
1617
|
+
if ([...pendingWakes.values()].some(Boolean)) {
|
|
1618
|
+
waitingForWake = true;
|
|
1619
|
+
wakeContext = ctx;
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
waitingForWake = false;
|
|
1521
1623
|
handledSettlementKey = key;
|
|
1522
1624
|
scheduleContinuation(ctx, loaded);
|
|
1523
1625
|
});
|
|
@@ -1529,10 +1631,14 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1529
1631
|
clearCommandInterruption();
|
|
1530
1632
|
pendingFailure = undefined;
|
|
1531
1633
|
currentSessionManagerRef = ctx.sessionManager;
|
|
1634
|
+
pendingWakes.clear();
|
|
1635
|
+
waitingForWake = false;
|
|
1636
|
+
wakeContext = ctx;
|
|
1532
1637
|
handledSettlementKey = undefined;
|
|
1533
1638
|
const loaded = latestStateFromContext(ctx);
|
|
1534
1639
|
const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
|
|
1535
1640
|
runState = owned;
|
|
1641
|
+
syncLoopEnvironment(owned);
|
|
1536
1642
|
reportHerdrBlocked(owned);
|
|
1537
1643
|
if (!owned || owned.status === "inactive") clearWidget(ctx);
|
|
1538
1644
|
else renderWidget(ctx, owned);
|
|
@@ -1553,9 +1659,13 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
1553
1659
|
// Shutdown may already have detached the runtime's append action.
|
|
1554
1660
|
}
|
|
1555
1661
|
}
|
|
1662
|
+
if (event.reason !== "reload" && !transitionInFlight) restoreLoopRunId(loaded?.runId);
|
|
1556
1663
|
reportHerdrBlocked(undefined);
|
|
1557
1664
|
clearWidget(ctx);
|
|
1558
1665
|
currentSessionManagerRef = undefined;
|
|
1666
|
+
waitingForWake = false;
|
|
1667
|
+
wakeContext = undefined;
|
|
1668
|
+
pendingWakes.clear();
|
|
1559
1669
|
});
|
|
1560
1670
|
|
|
1561
1671
|
pi.registerCommand("loop", {
|