@brettinternet/pi-loop 0.1.1 → 0.1.3

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 +24 -36
  2. package/index.ts +168 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,49 +1,37 @@
1
- # Pi Loop
1
+ # pi-loop
2
2
 
3
- `@brettinternet/pi-loop` runs one prompt for a bounded number of iterations or wall-clock duration. Every iteration gets a new Pi session, so files remain available while conversation history does not.
3
+ Run a prompt repeatedly, with a fresh session for every iteration.
4
4
 
5
- ## Commands
5
+ ```bash
6
+ pi install npm:@brettinternet/pi-loop
7
+ ```
6
8
 
7
9
  ```text
8
- /loop <count> [--delay <duration>] <prompt> Start a counted loop; count must be positive
9
- /loop for <duration> --delay <duration> <prompt> Start a timed loop with a required non-zero delay
10
- /loop <count> While a counted loop is active or stopping, replace its future-iteration budget
11
- /loop +<count> Add future iterations while active or stopping
12
- /loop -<count> Remove future iterations while active or stopping
13
- /loop delay <duration> Set the delay for an active, stopping, or paused loop
14
- /loop prompt <text> Replace the prompt for future iterations
15
- /loop append <text> Append instructions to the prompt for future iterations
16
- /loop status Show run, iteration, budget, retries, delay, and pause diagnostics
17
- /loop Gracefully end after the active iteration
18
- /loop end Gracefully end, or end a paused loop immediately
19
- /loop resume Retry a paused iteration in the same session, or cancel a pending end
20
- /loop next Complete a paused iteration and start the next one in a fresh session
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
21
25
  ```
22
26
 
23
- Durations use a number followed by `ms`, `s`, `m`, `h`, or `d` (for example `1000ms`, `2s`, `1m`, or `2d`). Delays must be at least `1s` and no more than `24h`. Counted loops may use `/loop delay off`; timed loops require a non-zero delay so they cannot accidentally spin until their deadline. A timed loop may run for up to `30d`, persists an absolute deadline across sessions and reloads, and starts no new iteration once that deadline is reached. An iteration already running may finish normally. The delay starts after a settled iteration and never delays the first iteration. While a delay is pending, ending the loop cancels it immediately; prompt, budget, and delay updates are applied to the next iteration safely.
24
-
25
- A retune changes only a counted loop's next-boundary future budget; it never changes the prompt. Timed loops have no iteration budget to retune. Retuning or resuming while a graceful end is pending cancels it and reuses the loop's prompt. A subtraction may reduce that budget to zero, ending the loop after the active iteration. Invalid or ambiguous forms are rejected instead of guessing.
26
-
27
- Prompt updates never affect the active iteration or change its budget. `prompt` replaces the complete prompt, including prior appended instructions. `append` adds a blank line and the supplied text. Updates persist across all not-yet-started iterations and may also be made while paused for the retried iteration. Updating a stopping loop preserves the pending end. Ordinary messages still belong only to the current session; use these commands to configure future fresh sessions.
28
-
29
- Loops continue only after `agent_settled`. An aborted or error assistant output is not considered terminal while Pi is automatically retrying or continuing after a permission prompt. After Pi fully settles with an error, the loop retries the same iteration in the same session up to three times, waiting 30 seconds, 1 minute, then 2 minutes. An explicit abort pauses immediately. Exhausted retries also pause without consuming the iteration; `/loop status` includes the retry count, reason, timestamp, and timed-loop deadline when applicable. Manual `/loop resume` resets the retry allowance.
30
-
31
- Every active loop session receives stable system guidance about its unattended-loop context and can call the `loop_pause` tool when useful work cannot continue without human input, credentials, permissions, or another non-transient external dependency. The guidance deliberately omits iteration and deadline values to preserve a stable prompt prefix for provider caching. The tool records the supplied reason, pauses without consuming the iteration, and terminates that agent turn. Temporary conditions expected to resolve in a later iteration should not pause the loop.
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.
32
28
 
33
- An active loop restored after an abrupt restart, session resume, tree navigation, or `/reload` continues its interrupted iteration in the same session. Persisted delay and retry deadlines are restored instead of repeating an iteration that had already settled. An intentional process exit ends automatic recovery by marking the current owner inactive. If a mid-run `/loop` command interrupts the assistant, the extension continues that iteration in the same session so completed work and conversation context are preserved. `/loop resume` continues a paused iteration in place; `/loop next` treats it as complete and advances to the next iteration in a fresh session. Only the boundary between completed iterations creates a fresh session. State is stored in custom session entries, and each replacement records its parent session while keeping conversational messages out of the new session. The compact status widget is shown only while a loop is active, stopping, or paused. It counts down (`loop active 4/4`, then `3/4`), shows the configured delay, and shows the prompt after a middle dot, truncated to one line at the current terminal width.
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.
34
30
 
35
- Prompts that begin with an extension command, skill command, or prompt template are dispatched on every iteration. This allows chains such as:
31
+ Chain commands in the prompt:
36
32
 
37
33
  ```text
38
34
  /loop 10 /wait 10m /skill:myskill skill argument here
39
35
  ```
40
36
 
41
- Here `/loop` dispatches `/wait` for each iteration, and `/wait` dispatches `/skill:myskill` after the timeout. Built-in interactive commands such as `/model` and `/settings` cannot be chained because Pi does not expose them through programmatic prompt dispatch.
42
-
43
- The extension does not use dialogs and is safe to load in print, JSON, and RPC modes.
44
-
45
- Install it with:
46
-
47
- ```sh
48
- pi install npm:@brettinternet/pi-loop
49
- ```
37
+ Built-in interactive commands cannot be chained.
package/index.ts CHANGED
@@ -12,7 +12,7 @@ import { Type } from "typebox";
12
12
  export const LOOP_STATE_ENTRY = "pi-loop-state-v1";
13
13
  export const LOOP_WIDGET_KEY = "pi-loop";
14
14
  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";
15
+ "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
16
 
17
17
  export const MIN_LOOP_DELAY_MS = 1_000;
18
18
  export const MAX_LOOP_DELAY_MS = 24 * 60 * 60 * 1_000;
@@ -26,7 +26,7 @@ const LOOP_AGENT_GUIDANCE = `## Active Loop
26
26
 
27
27
  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
28
 
29
- export type LoopStatus = "active" | "stopping" | "paused" | "completed" | "stopped" | "inactive";
29
+ export type LoopStatus = "active" | "pausing" | "stopping" | "paused" | "completed" | "stopped" | "inactive";
30
30
  export type LoopPhase = "running" | "waiting" | "retrying";
31
31
 
32
32
  export interface LoopState {
@@ -54,18 +54,20 @@ export type ParsedLoopCommand =
54
54
  | { kind: "startTimed"; duration: number; delay: number; prompt: string }
55
55
  | { kind: "retune"; count: number }
56
56
  | { kind: "adjust"; delta: number }
57
+ | { kind: "time"; duration: number }
57
58
  | { kind: "delay"; delay: number }
58
59
  | { kind: "replacePrompt"; prompt: string }
59
60
  | { kind: "appendPrompt"; prompt: string }
60
61
  | { kind: "status" }
62
+ | { kind: "pauseAtBoundary" }
61
63
  | { kind: "resume" }
62
64
  | { kind: "next" }
63
65
  | { kind: "end" }
64
66
  | { kind: "continue"; runId: string; iteration: number }
65
67
  | { kind: "pause"; runId: string; iteration: number };
66
68
 
67
- const ACTIVE_STATUSES = new Set<LoopStatus>(["active", "stopping"]);
68
- const VISIBLE_STATUSES = new Set<LoopStatus>(["active", "stopping", "paused"]);
69
+ const ACTIVE_STATUSES = new Set<LoopStatus>(["active", "pausing", "stopping"]);
70
+ const VISIBLE_STATUSES = new Set<LoopStatus>(["active", "pausing", "stopping", "paused"]);
69
71
  const TERMINAL_STATUSES = new Set<LoopStatus>(["completed", "stopped", "inactive"]);
70
72
 
71
73
  type ArgumentCompletion = { value: string; label: string; description?: string };
@@ -100,6 +102,15 @@ function completeLoopArguments(prefix: string): ArgumentCompletion[] | null {
100
102
  const delayCommand = /^(delay|--delay)(?:\s+(.*))?$/.exec(input);
101
103
  if (delayCommand?.[2] !== undefined) return delayCompletions(prefix, delayCommand[1]);
102
104
 
105
+ const timeCommand = /^time(?:\s+(.*))?$/.exec(input);
106
+ if (timeCommand?.[1] !== undefined) {
107
+ return completeArguments(timeCommand[1], COMMON_LOOP_TIMEFRAMES.map((value) => ({
108
+ value: `time ${value}`,
109
+ label: `time ${value}`,
110
+ description: "Switch to timed mode or reset the remaining time",
111
+ })));
112
+ }
113
+
103
114
  const timedDelay = /^for\s+(\S+)\s+--delay(=|\s+)?(.*)$/.exec(input);
104
115
  if (timedDelay) {
105
116
  if (timedDelay[2] !== undefined) {
@@ -164,9 +175,11 @@ function completeLoopArguments(prefix: string): ArgumentCompletion[] | null {
164
175
 
165
176
  return completeArguments(prefix, [
166
177
  { value: "status", label: "status", description: "Show the current loop state" },
167
- { value: "resume", label: "resume", description: "Retry a paused iteration" },
178
+ { value: "pause", label: "pause", description: "Pause after the active iteration settles" },
179
+ { value: "resume", label: "resume", description: "Resume a paused loop" },
168
180
  { value: "next", label: "next", description: "Skip a paused iteration and start the next one" },
169
181
  { value: "end", label: "end", description: "End the loop gracefully" },
182
+ { value: "time ", label: "time <duration>", description: "Switch to timed mode or reset the remaining time" },
170
183
  { value: "delay ", label: "delay <duration>", description: "Set the delay between settled iterations" },
171
184
  { value: "prompt ", label: "prompt <text>", description: "Replace the future loop prompt" },
172
185
  { value: "append ", label: "append <text>", description: "Append to the future loop prompt" },
@@ -255,6 +268,10 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
255
268
  if (rest) throw new Error(`end does not accept arguments; ${LOOP_USAGE}`);
256
269
  return { kind: "end" };
257
270
  }
271
+ if (first === "pause") {
272
+ if (rest) throw new Error(`pause does not accept arguments; ${LOOP_USAGE}`);
273
+ return { kind: "pauseAtBoundary" };
274
+ }
258
275
  if (first === "resume") {
259
276
  if (rest) throw new Error(`resume does not accept arguments; ${LOOP_USAGE}`);
260
277
  return { kind: "resume" };
@@ -263,6 +280,11 @@ export function parseLoopCommand(args: string): ParsedLoopCommand {
263
280
  if (rest) throw new Error(`next does not accept arguments; ${LOOP_USAGE}`);
264
281
  return { kind: "next" };
265
282
  }
283
+ if (first === "time") {
284
+ const fields = rest.split(/\s+/).filter(Boolean);
285
+ if (fields.length !== 1) throw new Error(`time requires one duration; ${LOOP_USAGE}`);
286
+ return { kind: "time", duration: parseLoopTimeframe(fields[0]) };
287
+ }
266
288
  if (first === "delay") {
267
289
  const fields = rest.split(/\s+/).filter(Boolean);
268
290
  if (fields.length !== 1) throw new Error(`delay requires one duration; ${LOOP_USAGE}`);
@@ -352,6 +374,7 @@ export function parseLoopState(value: unknown): LoopState | undefined {
352
374
  const status = value.status;
353
375
  if (
354
376
  status !== "active" &&
377
+ status !== "pausing" &&
355
378
  status !== "stopping" &&
356
379
  status !== "paused" &&
357
380
  status !== "completed" &&
@@ -523,12 +546,13 @@ export function formatLoopWidget(state: LoopState, width: number, now = Date.now
523
546
  const retries = (state.retryCount ?? 0) > 0
524
547
  ? ` · retry ${state.retryCount}/${DEFAULT_LOOP_RETRIES}`
525
548
  : "";
526
- if (state.status === "stopping") {
527
- return truncateToWidth(`loop stopping${timeframe}${delay}${retries} · ${prompt}`, width, "");
549
+ const iteration = state.endsAt === undefined ? "" : ` · #${state.currentIteration}`;
550
+ if (state.status === "pausing" || state.status === "stopping") {
551
+ return truncateToWidth(`loop ${state.status}${iteration}${timeframe}${delay}${retries} · ${prompt}`, width, "…");
528
552
  }
529
553
  if (state.endsAt !== undefined) {
530
554
  return truncateToWidth(
531
- `loop ${state.status}${timeframe}${delay}${retries} · ${prompt}`,
555
+ `loop ${state.status}${iteration}${timeframe}${delay}${retries} · ${prompt}`,
532
556
  width,
533
557
  "…",
534
558
  );
@@ -572,6 +596,16 @@ export default function loopExtension(pi: ExtensionAPI): void {
572
596
  let commandInterruptedKey: string | undefined;
573
597
  let pendingFailure: PendingFailure | undefined;
574
598
  let currentSessionManagerRef: unknown;
599
+ let herdrBlocked = false;
600
+
601
+ function reportHerdrBlocked(state: LoopState | undefined): void {
602
+ const blocked = state?.status === "paused";
603
+ if (blocked === herdrBlocked) return;
604
+ herdrBlocked = blocked;
605
+ pi.events.emit("herdr:blocked", blocked
606
+ ? { active: true, label: `Loop paused: ${state.pauseReason ?? "human input required"}`, scope: "root" }
607
+ : { active: false, scope: "root" });
608
+ }
575
609
 
576
610
  function stateFrom(ctx: ContextWithSession): LoopState | undefined {
577
611
  runState = latestStateFromContext(ctx);
@@ -581,6 +615,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
581
615
  function persist(ctx: Pick<ExtensionAPI, "appendEntry">, state: LoopState): void {
582
616
  ctx.appendEntry(LOOP_STATE_ENTRY, state);
583
617
  runState = state;
618
+ reportHerdrBlocked(state);
584
619
  }
585
620
 
586
621
  function notify(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info"): void {
@@ -602,6 +637,8 @@ export default function loopExtension(pi: ExtensionAPI): void {
602
637
  ctx.ui.setWidget(LOOP_WIDGET_KEY, [formatLoopWidget(state, Number.MAX_SAFE_INTEGER)], { placement: "belowEditor" });
603
638
  return;
604
639
  }
640
+ // Replacing the widget invalidates Pi's parent layout caches. Requesting a
641
+ // render alone can leave the previous line visible until another UI event.
605
642
  ctx.ui.setWidget(LOOP_WIDGET_KEY, (_tui, _theme) => ({
606
643
  render: (width) => [formatLoopWidget(state, width)],
607
644
  invalidate: () => {},
@@ -625,8 +662,11 @@ export default function loopExtension(pi: ExtensionAPI): void {
625
662
  }
626
663
  if (currentSessionManagerRef !== undefined && sessionManager !== currentSessionManagerRef) return undefined;
627
664
  const loaded = stateFrom(ctx);
628
- if (!loaded) return undefined;
629
- if (!stateBelongsToContext(loaded, ctx)) return undefined;
665
+ if (!loaded || !stateBelongsToContext(loaded, ctx)) {
666
+ reportHerdrBlocked(undefined);
667
+ return undefined;
668
+ }
669
+ reportHerdrBlocked(loaded);
630
670
  return loaded;
631
671
  }
632
672
 
@@ -679,7 +719,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
679
719
  ): void {
680
720
  if (!statusIsActive(state) || transitionInFlight) return;
681
721
  const nextBudget = state.pendingRetune ?? state.remainingBudget;
682
- if (state.status === "stopping" || (state.endsAt === undefined && nextBudget <= 0) || state.delay === 0) {
722
+ if (state.status === "pausing" || state.status === "stopping" || (state.endsAt === undefined && nextBudget <= 0) || state.delay === 0) {
683
723
  clearContinuationWait();
684
724
  dispatchContinuation(ctx, state);
685
725
  return;
@@ -971,6 +1011,27 @@ export default function loopExtension(pi: ExtensionAPI): void {
971
1011
  clearContinuationWait();
972
1012
  clearRetryWait();
973
1013
 
1014
+ if (state.status === "pausing") {
1015
+ const {
1016
+ nextActionAt: _nextActionAt,
1017
+ pauseReason: _pauseReason,
1018
+ ...withoutSchedule
1019
+ } = state;
1020
+ const paused: LoopState = {
1021
+ ...withoutSchedule,
1022
+ status: "paused",
1023
+ phase: "waiting",
1024
+ settledAt: Date.now(),
1025
+ pauseReason: "paused by user",
1026
+ pausedAt: Date.now(),
1027
+ };
1028
+ persist(pi, paused);
1029
+ renderWidget(ctx, paused);
1030
+ runState = paused;
1031
+ notify(ctx, "loop paused", "info");
1032
+ return;
1033
+ }
1034
+
974
1035
  if (state.status === "stopping") {
975
1036
  const stopped = { ...state, status: "stopped" as const };
976
1037
  persist(pi, stopped);
@@ -1075,6 +1136,41 @@ export default function loopExtension(pi: ExtensionAPI): void {
1075
1136
  return;
1076
1137
  }
1077
1138
 
1139
+ if (parsed.kind === "time") {
1140
+ if (!state || isTerminal(state)) {
1141
+ notify(ctx, "a loop must be active, stopping, or paused to update its remaining time", "error");
1142
+ return;
1143
+ }
1144
+ if (state.delay === 0) {
1145
+ notify(ctx, "timed loops require a non-zero delay; set /loop delay <duration> first", "error");
1146
+ return;
1147
+ }
1148
+ const switchingModes = state.endsAt === undefined;
1149
+ const endsAt = Date.now() + parsed.duration;
1150
+ const nextActionAt = state.phase === "waiting" && state.nextActionAt !== undefined
1151
+ ? Math.min((state.settledAt ?? state.nextActionAt - state.delay) + state.delay, endsAt)
1152
+ : state.nextActionAt;
1153
+ const updated = {
1154
+ ...state,
1155
+ remainingBudget: 0,
1156
+ pendingRetune: null,
1157
+ endsAt,
1158
+ ...(nextActionAt !== undefined ? { nextActionAt } : {}),
1159
+ };
1160
+ persist(pi, updated);
1161
+ renderWidget(ctx, updated);
1162
+ if (state.status === "active") rescheduleContinuation(ctx, updated);
1163
+ const duration = formatLoopDelay(parsed.duration);
1164
+ if (state.status === "paused") {
1165
+ notify(ctx, `loop ${switchingModes ? `changed to timed mode with ${duration} left` : `time left set to ${duration}`}; resume will use it`, "info");
1166
+ } else if (state.status === "stopping") {
1167
+ notify(ctx, `loop ${switchingModes ? `changed to timed mode with ${duration} left` : `time left set to ${duration}`}; loop is still stopping`, "info");
1168
+ } else {
1169
+ notify(ctx, switchingModes ? `loop changed to timed mode with ${duration} left` : `loop time left set to ${duration}`, "info");
1170
+ }
1171
+ return;
1172
+ }
1173
+
1078
1174
  if (parsed.kind === "delay") {
1079
1175
  if (!state || isTerminal(state)) {
1080
1176
  notify(ctx, "a loop must be active, stopping, or paused to update its delay", "error");
@@ -1108,6 +1204,35 @@ export default function loopExtension(pi: ExtensionAPI): void {
1108
1204
  return;
1109
1205
  }
1110
1206
 
1207
+ if (parsed.kind === "pauseAtBoundary") {
1208
+ if (!state || isTerminal(state)) {
1209
+ notify(ctx, "a loop must be active to pause", "error");
1210
+ return;
1211
+ }
1212
+ if (state.status === "paused") {
1213
+ notify(ctx, "loop is already paused", "info");
1214
+ return;
1215
+ }
1216
+ if (state.status === "pausing") {
1217
+ notify(ctx, "loop will pause after the active iteration", "info");
1218
+ return;
1219
+ }
1220
+ if (state.phase === "retrying") {
1221
+ pauseLoop(ctx, state, "paused by user");
1222
+ notify(ctx, "loop paused", "info");
1223
+ return;
1224
+ }
1225
+ const pausing: LoopState = { ...state, status: "pausing" };
1226
+ persist(pi, pausing);
1227
+ renderWidget(ctx, pausing);
1228
+ if (state.phase === "waiting") {
1229
+ await advanceAtBoundary(ctx, pausing.runId, pausing.currentIteration);
1230
+ return;
1231
+ }
1232
+ notify(ctx, "loop will pause after the active iteration", "info");
1233
+ return;
1234
+ }
1235
+
1111
1236
  if (parsed.kind === "end") {
1112
1237
  if (!state || state.status === "inactive" || state.status === "completed" || state.status === "stopped") {
1113
1238
  notify(ctx, "loop: no active run", "info");
@@ -1145,7 +1270,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1145
1270
  }
1146
1271
 
1147
1272
  if (parsed.kind === "resume") {
1148
- if (state?.status === "stopping") {
1273
+ if (state?.status === "pausing" || state?.status === "stopping") {
1149
1274
  const resumed = { ...state, status: "active" as const };
1150
1275
  persist(pi, resumed);
1151
1276
  renderWidget(ctx, resumed);
@@ -1160,6 +1285,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1160
1285
  await advanceAtBoundary(ctx, state.runId, state.currentIteration, true);
1161
1286
  return;
1162
1287
  }
1288
+ const pausedAtBoundary = state.phase === "waiting";
1163
1289
  const {
1164
1290
  pauseReason: _pauseReason,
1165
1291
  pausedAt: _pausedAt,
@@ -1176,7 +1302,11 @@ export default function loopExtension(pi: ExtensionAPI): void {
1176
1302
  persist(pi, resumed);
1177
1303
  renderWidget(ctx, resumed);
1178
1304
  handledSettlementKey = undefined;
1179
- continueCurrentIteration(ctx, resumed);
1305
+ if (pausedAtBoundary) {
1306
+ await advanceAtBoundary(ctx, resumed.runId, resumed.currentIteration);
1307
+ } else {
1308
+ continueCurrentIteration(ctx, resumed);
1309
+ }
1180
1310
  return;
1181
1311
  }
1182
1312
 
@@ -1214,12 +1344,18 @@ export default function loopExtension(pi: ExtensionAPI): void {
1214
1344
  }
1215
1345
 
1216
1346
  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");
1347
+ const canRetune = state && (
1348
+ state.status === "active" ||
1349
+ state.status === "stopping" ||
1350
+ (parsed.kind === "retune" && state.status === "paused")
1351
+ );
1352
+ if (!canRetune) {
1353
+ notify(ctx, "a loop must be active, stopping, or paused to retune its remaining budget", "error");
1219
1354
  return;
1220
1355
  }
1221
- if (state.endsAt !== undefined) {
1222
- notify(ctx, "a timed loop has no iteration budget to retune", "error");
1356
+ const switchingModes = state.endsAt !== undefined;
1357
+ if (switchingModes && parsed.kind === "adjust") {
1358
+ notify(ctx, "a timed loop has no iteration budget to adjust; use /loop <positive-count> to switch modes", "error");
1223
1359
  return;
1224
1360
  }
1225
1361
  const currentBudget = state.pendingRetune ?? state.remainingBudget;
@@ -1228,11 +1364,20 @@ export default function loopExtension(pi: ExtensionAPI): void {
1228
1364
  notify(ctx, `cannot subtract more than the ${currentBudget} future iteration${currentBudget === 1 ? "" : "s"}`, "error");
1229
1365
  return;
1230
1366
  }
1231
- const retuned = { ...state, pendingRetune: nextBudget, status: "active" as const };
1367
+ const { endsAt: _endsAt, ...withoutDeadline } = state;
1368
+ const retuned = {
1369
+ ...withoutDeadline,
1370
+ pendingRetune: nextBudget,
1371
+ status: state.status === "paused" ? "paused" as const : "active" as const,
1372
+ };
1232
1373
  persist(pi, retuned);
1233
1374
  renderWidget(ctx, retuned);
1234
- if (nextBudget <= 0) rescheduleContinuation(ctx, retuned);
1235
- notify(ctx, `loop will run ${nextBudget} future iteration${nextBudget === 1 ? "" : "s"}`, "info");
1375
+ if (switchingModes || nextBudget <= 0) rescheduleContinuation(ctx, retuned);
1376
+ if (state.status === "paused") {
1377
+ notify(ctx, `loop ${switchingModes ? "changed to" : "set to"} ${nextBudget} future iteration${nextBudget === 1 ? "" : "s"}; resume will use it`, "info");
1378
+ } else {
1379
+ notify(ctx, `loop ${switchingModes ? "changed to" : "will run"} ${nextBudget} future iteration${nextBudget === 1 ? "" : "s"}`, "info");
1380
+ }
1236
1381
  return;
1237
1382
  }
1238
1383
 
@@ -1309,6 +1454,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1309
1454
  const loaded = latestStateFromContext(ctx);
1310
1455
  const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
1311
1456
  runState = owned;
1457
+ reportHerdrBlocked(owned);
1312
1458
  if (!owned || owned.status === "inactive") clearWidget(ctx);
1313
1459
  else renderWidget(ctx, owned);
1314
1460
  // New-session setup writes transferred state after this event and starts
@@ -1387,6 +1533,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1387
1533
  const loaded = latestStateFromContext(ctx);
1388
1534
  const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
1389
1535
  runState = owned;
1536
+ reportHerdrBlocked(owned);
1390
1537
  if (!owned || owned.status === "inactive") clearWidget(ctx);
1391
1538
  else renderWidget(ctx, owned);
1392
1539
  if (owned && statusIsActive(owned)) scheduleStartupRecovery(ctx, owned);
@@ -1406,6 +1553,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
1406
1553
  // Shutdown may already have detached the runtime's append action.
1407
1554
  }
1408
1555
  }
1556
+ reportHerdrBlocked(undefined);
1409
1557
  clearWidget(ctx);
1410
1558
  currentSessionManagerRef = undefined;
1411
1559
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brettinternet/pi-loop",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Run a prompt repeatedly in fresh Pi sessions",
5
5
  "type": "module",
6
6
  "license": "MIT",