@brettinternet/pi-loop 0.1.3 → 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.
Files changed (3) hide show
  1. package/README.md +9 -1
  2. package/index.ts +83 -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
@@ -35,3 +35,11 @@ Chain commands in the prompt:
35
35
  ```
36
36
 
37
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,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
 
@@ -612,9 +654,19 @@ export default function loopExtension(pi: ExtensionAPI): void {
612
654
  return runState;
613
655
  }
614
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
+
615
666
  function persist(ctx: Pick<ExtensionAPI, "appendEntry">, state: LoopState): void {
616
667
  ctx.appendEntry(LOOP_STATE_ENTRY, state);
617
668
  runState = state;
669
+ syncLoopEnvironment(state);
618
670
  reportHerdrBlocked(state);
619
671
  }
620
672
 
@@ -885,6 +937,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
885
937
 
886
938
  function transferState(state: LoopState, manager: SessionManager): LoopState {
887
939
  const transferred = stateForSession({ ...state, status: "active" }, sessionIdentity(manager));
940
+ exposeLoopRunId(transferred.runId);
888
941
  manager.appendCustomEntry(LOOP_STATE_ENTRY, transferred);
889
942
  return transferred;
890
943
  }
@@ -893,9 +946,17 @@ export default function loopExtension(pi: ExtensionAPI): void {
893
946
  replacement: ReplacementContext,
894
947
  state: LoopState,
895
948
  ): 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.
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
+ }
899
960
  if (replacement.hasUI) showWidget(replacement, state);
900
961
  if (state.endsAt !== undefined && Date.now() >= state.endsAt) {
901
962
  await replacement.sendUserMessage(
@@ -908,6 +969,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
908
969
  }
909
970
 
910
971
  async function replaceForIteration(ctx: ExtensionCommandContext, next: LoopState): Promise<void> {
972
+ exposeLoopRunId(next.runId);
911
973
  clearContinuationWait();
912
974
  clearRetryWait();
913
975
  clearRecoveryTimer();
@@ -1068,6 +1130,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1068
1130
  } = state;
1069
1131
  const next: LoopState = {
1070
1132
  ...withoutPause,
1133
+ ...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}),
1071
1134
  currentIteration: state.currentIteration + 1,
1072
1135
  remainingBudget: state.endsAt === undefined ? nextBudget - 1 : 0,
1073
1136
  pendingRetune: null,
@@ -1119,6 +1182,16 @@ export default function loopExtension(pi: ExtensionAPI): void {
1119
1182
  return;
1120
1183
  }
1121
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
+
1122
1195
  if (parsed.kind === "pause") {
1123
1196
  const state = currentState(ctx);
1124
1197
  if (!state || state.runId !== parsed.runId || state.currentIteration !== parsed.iteration || !statusIsActive(state)) return;
@@ -1402,6 +1475,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1402
1475
  status: "active",
1403
1476
  retryCount: 0,
1404
1477
  phase: "running",
1478
+ ...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}),
1405
1479
  ...(timed ? { endsAt: Date.now() + parsed.duration } : {}),
1406
1480
  ...(contextIdentity(ctx).id ? { ownerSessionId: contextIdentity(ctx).id } : {}),
1407
1481
  ...(contextIdentity(ctx).file ? { ownerSessionFile: contextIdentity(ctx).file } : {}),
@@ -1454,6 +1528,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1454
1528
  const loaded = latestStateFromContext(ctx);
1455
1529
  const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
1456
1530
  runState = owned;
1531
+ syncLoopEnvironment(owned);
1457
1532
  reportHerdrBlocked(owned);
1458
1533
  if (!owned || owned.status === "inactive") clearWidget(ctx);
1459
1534
  else renderWidget(ctx, owned);
@@ -1533,6 +1608,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1533
1608
  const loaded = latestStateFromContext(ctx);
1534
1609
  const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
1535
1610
  runState = owned;
1611
+ syncLoopEnvironment(owned);
1536
1612
  reportHerdrBlocked(owned);
1537
1613
  if (!owned || owned.status === "inactive") clearWidget(ctx);
1538
1614
  else renderWidget(ctx, owned);
@@ -1553,6 +1629,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1553
1629
  // Shutdown may already have detached the runtime's append action.
1554
1630
  }
1555
1631
  }
1632
+ if (event.reason !== "reload" && !transitionInFlight) restoreLoopRunId(loaded?.runId);
1556
1633
  reportHerdrBlocked(undefined);
1557
1634
  clearWidget(ctx);
1558
1635
  currentSessionManagerRef = undefined;
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.4",
4
4
  "description": "Run a prompt repeatedly in fresh Pi sessions",
5
5
  "type": "module",
6
6
  "license": "MIT",