@brettinternet/pi-loop 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +41 -25
  2. package/index.ts +55 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,45 +1,61 @@
1
1
  # pi-loop
2
2
 
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.
3
+ Run a prompt repeatedly, each iteration in a fresh session.
4
4
 
5
- ```bash
5
+ ```sh
6
6
  pi install npm:@brettinternet/pi-loop
7
7
  ```
8
8
 
9
9
  ```text
10
- /loop <count> [--delay <duration>] <prompt>
11
- /loop for <duration> --delay <duration> <prompt>
12
- /loop <count>
13
- /loop +<count>
14
- /loop -<count>
15
- /loop time <duration>
16
- /loop delay <duration>
17
- /loop prompt <text>
18
- /loop append <text>
19
- /loop status
20
- /loop
21
- /loop pause
22
- /loop end
23
- /loop resume
24
- /loop next
10
+ /loop 5 Fix the next failing test
11
+ /loop 10 --delay 30m Check CI and fix failures
12
+ /loop for 8h --delay 1h Review new issues
13
+ /loop 10 /wait 10m /skill:myskill skill argument here
25
14
  ```
26
15
 
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
-
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.
16
+ The last example chains commands. Built-in interactive commands can't be chained.
30
17
 
31
- Chain commands in the prompt:
18
+ ## Control a running loop
32
19
 
33
20
  ```text
34
- /loop 10 /wait 10m /skill:myskill skill argument here
21
+ /loop status show state
22
+ /loop 3 run 3 more iterations from now
23
+ /loop +2 add 2 iterations
24
+ /loop -1 remove 1 iteration
25
+ /loop time 2h stop 2h from now (requires a delay)
26
+ /loop delay 5m change the gap between iterations
27
+ /loop prompt <text> replace the prompt
28
+ /loop append <text> append to the prompt
29
+ /loop pause pause after this iteration
30
+ /loop resume resume
31
+ /loop next skip a paused iteration and start the next
32
+ /loop end stop gracefully
33
+ /loop same as end
35
34
  ```
36
35
 
37
- Built-in interactive commands cannot be chained.
36
+ Durations use `ms`, `s`, `m`, `h`, or `d`. Delays range from 1s to 24h. Timed loops require `--delay` and run at most 30 days.
37
+
38
+ ## Behavior
39
+
40
+ | Event | Result |
41
+ | --- | --- |
42
+ | Model or thinking level changed mid-loop | The next iteration uses it; both initially match the session that invoked `/loop` |
43
+ | Model unavailable | Loop pauses; no fallback to the default |
44
+ | Error | Retries after 30s, 1m, and 2m, then pauses |
45
+ | Abort | Loop pauses |
46
+ | `/loop pause` between iterations | Pauses immediately |
47
+ | Agent calls `loop_pause` | Pauses mid-iteration; resume continues that iteration |
48
+ | Pending `/wait` or `until` watch | Iteration waits for its wake-up turn or cancellation |
49
+ | Paused wait or recurring watch | Iteration waits until resumed or completed |
50
+
51
+ Use `/loop delay` for a fixed gap, `/wait` for a same-session follow-up, and `until` for a condition that may become true sooner.
52
+
53
+ ## Run ID
38
54
 
39
- While a loop is active, every child command receives its stable run ID as `PI_LOOP_RUN_ID`:
55
+ Every command run during a loop gets the loop's ID:
40
56
 
41
57
  ```text
42
58
  PI_LOOP_RUN_ID=3992f183-e054-4068-a13e-11281d1747e2
43
59
  ```
44
60
 
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.
61
+ The UUID stays the same for every iteration of one loop and differs between loops. When the loop ends, the previous value is restored or the variable is removed.
package/index.ts CHANGED
@@ -9,6 +9,8 @@ import type {
9
9
  import { truncateToWidth } from "@earendil-works/pi-tui";
10
10
  import { Type } from "typebox";
11
11
 
12
+ type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
13
+
12
14
  export const LOOP_STATE_ENTRY = "pi-loop-state-v1";
13
15
  export const LOOP_WIDGET_KEY = "pi-loop";
14
16
  export const LOOP_RUN_ID_ENV = "PI_LOOP_RUN_ID";
@@ -49,6 +51,7 @@ export interface LoopState {
49
51
  ownerSessionId?: string;
50
52
  ownerSessionFile?: string;
51
53
  model?: { provider: string; id: string };
54
+ thinkingLevel?: ThinkingLevel;
52
55
  }
53
56
 
54
57
  export type ParsedLoopCommand =
@@ -445,6 +448,11 @@ export function parseLoopState(value: unknown): LoopState | undefined {
445
448
  if (value.ownerSessionId !== undefined && typeof value.ownerSessionId !== "string") return undefined;
446
449
  if (value.ownerSessionFile !== undefined && typeof value.ownerSessionFile !== "string") return undefined;
447
450
  const model = value.model;
451
+ const thinkingLevel = value.thinkingLevel;
452
+ if (
453
+ thinkingLevel !== undefined &&
454
+ !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinkingLevel as string)
455
+ ) return undefined;
448
456
  if (
449
457
  model !== undefined &&
450
458
  (!isRecord(model) || typeof model.provider !== "string" || !model.provider ||
@@ -472,6 +480,7 @@ export function parseLoopState(value: unknown): LoopState | undefined {
472
480
  ...(isRecord(model) && typeof model.provider === "string" && typeof model.id === "string"
473
481
  ? { model: { provider: model.provider, id: model.id } }
474
482
  : {}),
483
+ ...(thinkingLevel !== undefined ? { thinkingLevel: thinkingLevel as ThinkingLevel } : {}),
475
484
  };
476
485
  }
477
486
 
@@ -639,6 +648,24 @@ export default function loopExtension(pi: ExtensionAPI): void {
639
648
  let pendingFailure: PendingFailure | undefined;
640
649
  let currentSessionManagerRef: unknown;
641
650
  let herdrBlocked = false;
651
+ const pendingWakes = new Map<string, boolean>();
652
+ let waitingForWake = false;
653
+ let wakeContext: ExtensionContext | undefined;
654
+
655
+ for (const channel of ["pi-until:busy", "pi-wait:busy"]) {
656
+ pi.events.on(channel, (value) => {
657
+ if (typeof value !== "boolean") return;
658
+ pendingWakes.set(channel, value);
659
+ if (!waitingForWake || [...pendingWakes.values()].some(Boolean)) return;
660
+ const ctx = wakeContext;
661
+ if (!ctx || !ctx.isIdle()) return;
662
+ const state = currentState(ctx);
663
+ if (!state || !statusIsActive(state) || transitionInFlight) return;
664
+ waitingForWake = false;
665
+ handledSettlementKey = stateKey(ctx, state);
666
+ scheduleContinuation(ctx, state);
667
+ });
668
+ }
642
669
 
643
670
  function reportHerdrBlocked(state: LoopState | undefined): void {
644
671
  const blocked = state?.status === "paused";
@@ -946,14 +973,14 @@ export default function loopExtension(pi: ExtensionAPI): void {
946
973
  replacement: ReplacementContext,
947
974
  state: LoopState,
948
975
  ): Promise<void> {
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) {
976
+ // The replacement owns the new extension runtime. Restore its model and
977
+ // thinking level before starting a turn, rather than using the previous runtime.
978
+ if (state.model || state.thinkingLevel !== undefined) {
952
979
  await replacement.sendUserMessage(
953
980
  `/loop __restore_model ${state.runId} ${state.currentIteration}`,
954
981
  { expandPromptTemplates: true },
955
982
  );
956
- if (replacement.model?.provider !== state.model.provider || replacement.model.id !== state.model.id) {
983
+ if (state.model && (replacement.model?.provider !== state.model.provider || replacement.model.id !== state.model.id)) {
957
984
  throw new Error(`loop model unavailable: ${state.model.provider}/${state.model.id}`);
958
985
  }
959
986
  }
@@ -1131,6 +1158,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1131
1158
  const next: LoopState = {
1132
1159
  ...withoutPause,
1133
1160
  ...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}),
1161
+ thinkingLevel: pi.getThinkingLevel(),
1134
1162
  currentIteration: state.currentIteration + 1,
1135
1163
  remainingBudget: state.endsAt === undefined ? nextBudget - 1 : 0,
1136
1164
  pendingRetune: null,
@@ -1184,11 +1212,14 @@ export default function loopExtension(pi: ExtensionAPI): void {
1184
1212
 
1185
1213
  if (parsed.kind === "restoreModel") {
1186
1214
  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}`);
1215
+ if (!state || state.runId !== parsed.runId || state.currentIteration !== parsed.iteration) return;
1216
+ if (state.model) {
1217
+ const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
1218
+ if (!model || !(await pi.setModel(model))) {
1219
+ throw new Error(`loop model unavailable: ${state.model.provider}/${state.model.id}`);
1220
+ }
1191
1221
  }
1222
+ if (state.thinkingLevel !== undefined) pi.setThinkingLevel(state.thinkingLevel);
1192
1223
  return;
1193
1224
  }
1194
1225
 
@@ -1476,6 +1507,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1476
1507
  retryCount: 0,
1477
1508
  phase: "running",
1478
1509
  ...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}),
1510
+ thinkingLevel: pi.getThinkingLevel(),
1479
1511
  ...(timed ? { endsAt: Date.now() + parsed.duration } : {}),
1480
1512
  ...(contextIdentity(ctx).id ? { ownerSessionId: contextIdentity(ctx).id } : {}),
1481
1513
  ...(contextIdentity(ctx).file ? { ownerSessionFile: contextIdentity(ctx).file } : {}),
@@ -1523,6 +1555,9 @@ export default function loopExtension(pi: ExtensionAPI): void {
1523
1555
  clearCommandInterruption();
1524
1556
  pendingFailure = undefined;
1525
1557
  currentSessionManagerRef = ctx.sessionManager;
1558
+ pendingWakes.clear();
1559
+ waitingForWake = false;
1560
+ wakeContext = ctx;
1526
1561
  transitionInFlight = false;
1527
1562
  handledSettlementKey = undefined;
1528
1563
  const loaded = latestStateFromContext(ctx);
@@ -1593,6 +1628,12 @@ export default function loopExtension(pi: ExtensionAPI): void {
1593
1628
  return;
1594
1629
  }
1595
1630
  if (handledSettlementKey === key) return;
1631
+ if ([...pendingWakes.values()].some(Boolean)) {
1632
+ waitingForWake = true;
1633
+ wakeContext = ctx;
1634
+ return;
1635
+ }
1636
+ waitingForWake = false;
1596
1637
  handledSettlementKey = key;
1597
1638
  scheduleContinuation(ctx, loaded);
1598
1639
  });
@@ -1604,6 +1645,9 @@ export default function loopExtension(pi: ExtensionAPI): void {
1604
1645
  clearCommandInterruption();
1605
1646
  pendingFailure = undefined;
1606
1647
  currentSessionManagerRef = ctx.sessionManager;
1648
+ pendingWakes.clear();
1649
+ waitingForWake = false;
1650
+ wakeContext = ctx;
1607
1651
  handledSettlementKey = undefined;
1608
1652
  const loaded = latestStateFromContext(ctx);
1609
1653
  const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
@@ -1633,6 +1677,9 @@ export default function loopExtension(pi: ExtensionAPI): void {
1633
1677
  reportHerdrBlocked(undefined);
1634
1678
  clearWidget(ctx);
1635
1679
  currentSessionManagerRef = undefined;
1680
+ waitingForWake = false;
1681
+ wakeContext = undefined;
1682
+ pendingWakes.clear();
1636
1683
  });
1637
1684
 
1638
1685
  pi.registerCommand("loop", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brettinternet/pi-loop",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Run a prompt repeatedly in fresh Pi sessions",
5
5
  "type": "module",
6
6
  "license": "MIT",