@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.
Files changed (3) hide show
  1. package/README.md +13 -3
  2. package/index.ts +251 -26
  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
@@ -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. A `loop_pause` request pauses for human blockers. Recovery preserves the loop so you can resume or advance it.
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: "resume", label: "resume", description: "Retry a paused iteration" },
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
- : { kind: "pause", runId: fields[0], iteration };
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
- if (state.status === "stopping") {
527
- return truncateToWidth(`loop stopping${timeframe}${delay}${retries} · ${prompt}`, width, "");
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) return undefined;
629
- if (!stateBelongsToContext(loaded, ctx)) return undefined;
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 instance restores this entry in before_agent_start.
857
- // This callback still owns the command context, so it is the safe place to
858
- // 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
+ }
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
- continueCurrentIteration(ctx, resumed);
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
- if (!state || (state.status !== "active" && state.status !== "stopping")) {
1218
- notify(ctx, "a loop must be active to retune its remaining budget", "error");
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
- if (state.endsAt !== undefined) {
1222
- notify(ctx, "a timed loop has no iteration budget to retune", "error");
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 retuned = { ...state, pendingRetune: nextBudget, status: "active" as 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
- notify(ctx, `loop will run ${nextBudget} future iteration${nextBudget === 1 ? "" : "s"}`, "info");
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brettinternet/pi-loop",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Run a prompt repeatedly in fresh Pi sessions",
5
5
  "type": "module",
6
6
  "license": "MIT",