@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.
Files changed (3) hide show
  1. package/README.md +11 -1
  2. package/index.ts +116 -6
  3. 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
- : { kind: "pause", runId: fields[0], iteration };
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 instance restores this entry in before_agent_start.
897
- // This callback still owns the command context, so it is the safe place to
898
- // start the turn after the replacement is complete.
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", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brettinternet/pi-loop",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Run a prompt repeatedly in fresh Pi sessions",
5
5
  "type": "module",
6
6
  "license": "MIT",