@hank-warren/pi-loop 0.9.0 → 1.1.0

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.
package/src/loop.ts CHANGED
@@ -89,6 +89,8 @@ import {
89
89
  buildProposal,
90
90
  type LoopPlanningState,
91
91
  type LoopProposal,
92
+ type LoopProposalOverrides,
93
+ normalizeGroundRules,
92
94
  } from "./planning.js";
93
95
  import {
94
96
  clearLoopWidget,
@@ -119,6 +121,15 @@ export const MAX_FALLBACK_BACKOFF = 4;
119
121
  * would otherwise be the first sign anything was wrong.
120
122
  */
121
123
  export const STALL_ATTENTION_MS = 900_000;
124
+ /**
125
+ * How long the expiry wake has to become a turn before the loop stops anyway.
126
+ *
127
+ * `sendUserMessage` is fire-and-forget: Pi swallows an asynchronous delivery
128
+ * failure (an expired credential, a torn-down runner), so a successful return
129
+ * is not proof a turn will start. The ordinary dead-delivery counter cannot
130
+ * catch this one, because after expiry there is no next delivery to count.
131
+ */
132
+ export const EXPIRY_TURN_GRACE_MS = 60_000;
122
133
 
123
134
  /** Consecutive loop deliveries that produce no run before the loop pauses. */
124
135
  export const MAX_DEAD_DELIVERIES = 3;
@@ -130,10 +141,9 @@ type RunOrigin = "continuation" | "fallback";
130
141
  * The outcome of a start attempt.
131
142
  *
132
143
  * `startLoop` used to report its refusals by calling `ctx.ui.notify` itself,
133
- * which tied the only start path to a UI. Two callers now share that path —
134
- * the `/loop` command and the `loop_start` tool and the tool has to turn
135
- * the same refusal into tool content rather than a toast, so the decision is
136
- * returned and each caller renders it.
144
+ * which tied the only start path to a UI. The approval card's two start
145
+ * actions share that path one installs the loop here, the other hands it to
146
+ * a fresh session so the decision is returned and each caller renders it.
137
147
  */
138
148
  export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
139
149
 
@@ -164,6 +174,8 @@ export interface LoopControllerOptions {
164
174
  now?: () => number;
165
175
  /** Root for the loop ledger; defaults to Pi's agent dir. Tests override it. */
166
176
  agentDir?: string;
177
+ /** How long the final expiry turn has to start before the loop gives up. */
178
+ expiryTurnGraceMs?: number;
167
179
  }
168
180
 
169
181
  export class LoopController {
@@ -176,6 +188,7 @@ export class LoopController {
176
188
  private readonly now: () => number;
177
189
  readonly settingsPath: string;
178
190
  private timer: NodeJS.Timeout | undefined;
191
+ private expiryTimer: NodeJS.Timeout | undefined;
179
192
  private nextWakeAt: number | undefined;
180
193
  private wakePending = false;
181
194
  private sessionCtx: ExtensionContext | undefined;
@@ -185,6 +198,7 @@ export class LoopController {
185
198
  ledger: LedgerPaths | undefined;
186
199
  private ledgerWarned = false;
187
200
  private readonly agentDir: string | undefined;
201
+ private readonly expiryTurnGraceMs: number;
188
202
  /** Consecutive fallback wakes that produced a no-op turn. */
189
203
  noOpStreak = 0;
190
204
  lastContinuation: (ContinuationDecision & { at: number }) | undefined;
@@ -228,12 +242,14 @@ export class LoopController {
228
242
  this.now = options.now ?? Date.now;
229
243
  this.settingsPath = options.settingsPath ?? loopSettingsPath();
230
244
  this.agentDir = options.agentDir;
245
+ this.expiryTurnGraceMs = options.expiryTurnGraceMs ?? EXPIRY_TURN_GRACE_MS;
231
246
  }
232
247
 
233
248
  // --- lifecycle ---
234
249
 
235
250
  onSessionStart(ctx: ExtensionContext): void {
236
251
  this.clearTimer();
252
+ this.clearExpiryWatchdog();
237
253
  this.wakePending = false;
238
254
  this.compacting = false;
239
255
  this.lastDecision = undefined;
@@ -273,6 +289,7 @@ export class LoopController {
273
289
  // A wait whose deadline passed while the session was away is due now.
274
290
  this.restoreWaitTimer();
275
291
  this.armFallback();
292
+ this.armExpiryWatchdog();
276
293
  // A loop handed over from another session has never had its first turn.
277
294
  if (this.state.handoff) this.consumeHandoff(ctx);
278
295
  }
@@ -293,7 +310,7 @@ export class LoopController {
293
310
  this.state = rest;
294
311
  this.persist();
295
312
  ctx.ui.notify(
296
- "Loop started in this session: only the objective crossed over, not the planning conversation. It works from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you run /loop stop.",
313
+ "Loop started in this session: only the objective crossed over, not the planning conversation. It works from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you stop it from the /loop menu.",
297
314
  "info",
298
315
  );
299
316
  this.sendKickoffAnchor(ctx);
@@ -305,6 +322,7 @@ export class LoopController {
305
322
  // Withdraw the signal: the process may outlive this session.
306
323
  publishLoopEnv(undefined);
307
324
  this.clearTimer();
325
+ this.clearExpiryWatchdog();
308
326
  this.waitTimer.clear();
309
327
  this.wakePending = false;
310
328
  this.continuationIntent = undefined;
@@ -371,14 +389,14 @@ export class LoopController {
371
389
  case "usage-limited":
372
390
  this.transition(
373
391
  "paused",
374
- "the provider reports the usage limit is reached; resume with /loop resume once it resets",
392
+ "the provider reports the usage limit is reached; resume it from the /loop menu once it resets",
375
393
  "usage limit reached",
376
394
  );
377
395
  return true;
378
396
  case "fatal":
379
397
  this.transition(
380
398
  "paused",
381
- "the turn failed with an error a retry cannot fix; resolve it, then /loop resume",
399
+ "the turn failed with an error a retry cannot fix; resolve it, then resume it from the /loop menu",
382
400
  "unrecoverable provider error",
383
401
  );
384
402
  return true;
@@ -386,7 +404,7 @@ export class LoopController {
386
404
  // Esc, or another extension stopping the turn. A loop-caused run
387
405
  // that the user interrupted must not be immediately re-sent.
388
406
  if (origin === undefined) return false;
389
- this.transition("paused", "the turn was interrupted; resume with /loop resume", "interrupted");
407
+ this.transition("paused", "the turn was interrupted; resume it from the /loop menu", "interrupted");
390
408
  return true;
391
409
  case "context-overflow":
392
410
  // The request no longer fits: compact first, then continue. The
@@ -408,7 +426,7 @@ export class LoopController {
408
426
  * The no-progress breaker: consecutive tool-free loop turns with identical
409
427
  * visible output pause the loop instead of waking it again forever. It
410
428
  * pauses rather than stops, so the loop stays configured and one
411
- * `/loop resume` (or the next user prompt) puts it back to work.
429
+ * Resuming from the /loop menu (or the next user prompt) puts it back to work.
412
430
  */
413
431
  private enforceNoProgress(
414
432
  ctx: ExtensionContext,
@@ -451,7 +469,7 @@ export class LoopController {
451
469
  void ctx;
452
470
  this.transition(
453
471
  "paused",
454
- `${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so /loop resume (or your next message) continues it`,
472
+ `${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so resuming from the /loop menu (or your next message) continues it`,
455
473
  "no progress",
456
474
  );
457
475
  return true;
@@ -534,7 +552,7 @@ export class LoopController {
534
552
  if (!objective) {
535
553
  this.transition(
536
554
  "paused",
537
- "it was bound to a goal that is gone and has no objective of its own; start a new loop with /loop <interval> <objective>",
555
+ "it was bound to a goal that is gone and has no objective of its own; run /loop to plan and approve a new one",
538
556
  "loop with no objective",
539
557
  );
540
558
  return true;
@@ -564,7 +582,7 @@ export class LoopController {
564
582
  if (this.completeToolAvailable()) return false;
565
583
  this.transition(
566
584
  "paused",
567
- `the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then /loop resume`,
585
+ `the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then resume the loop from the /loop menu`,
568
586
  "loop_complete unavailable",
569
587
  );
570
588
  void ctx;
@@ -598,7 +616,7 @@ export class LoopController {
598
616
  if (this.deadDeliveries < MAX_DEAD_DELIVERIES) return false;
599
617
  this.transition(
600
618
  "paused",
601
- `${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then /loop resume`,
619
+ `${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then resume the loop from the /loop menu`,
602
620
  "deliveries produce no turns",
603
621
  );
604
622
  return true;
@@ -679,7 +697,7 @@ export class LoopController {
679
697
  * no writable ledger still runs, it just loses the durable record, so the
680
698
  * failure is warned once and never repeated.
681
699
  *
682
- * `criteria` is passed at start: the criteria proposed at `loop_start`, or
700
+ * `criteria` is passed at start: the criteria approved with the draft, or
683
701
  * the deterministic split of the objective. On restore it is omitted, and
684
702
  * the criteria already on disk are authoritative — they are the ones the
685
703
  * user saw echoed, and re-deriving them would both discard a proposed set
@@ -817,7 +835,7 @@ export class LoopController {
817
835
  // Nothing is scheduled once the timer has fired. `runTick` re-arms it
818
836
  // through `scheduleTick` when it pokes, but a busy or compacting
819
837
  // session coalesces into `wakePending` instead — and leaving the old
820
- // deadline here made `/loop status` report a clock time that had
838
+ // deadline here made the /loop status screen report a clock time that had
821
839
  // already passed.
822
840
  this.nextWakeAt = undefined;
823
841
  const ctx = this.sessionCtx;
@@ -834,6 +852,63 @@ export class LoopController {
834
852
  this.nextWakeAt = undefined;
835
853
  }
836
854
 
855
+ private armExpiryWatchdog(): void {
856
+ this.clearExpiryWatchdog();
857
+ const loop = this.state;
858
+ if (!loop || loop.status !== "active") return;
859
+ const loopId = loop.id;
860
+ const delay = Math.min(MAX_INTERVAL_MS, Math.max(0, loop.expiresAt - this.now()));
861
+ this.expiryTimer = setTimeout(() => {
862
+ this.expiryTimer = undefined;
863
+ const current = this.state;
864
+ if (!current || current.id !== loopId || current.status !== "active") return;
865
+ if (this.now() < current.expiresAt) {
866
+ this.armExpiryWatchdog();
867
+ return;
868
+ }
869
+ const ctx = this.sessionCtx;
870
+ if (!ctx) {
871
+ this.transition("stopped", "loop expired without an active session context");
872
+ return;
873
+ }
874
+ const planActive = readPlanModeEnabled(ctx.sessionManager.getBranch());
875
+ const busy = !ctx.isIdle() || ctx.hasPendingMessages();
876
+ if (planActive || busy) {
877
+ this.transition(
878
+ "stopped",
879
+ planActive
880
+ ? "loop expired while Plan mode was active"
881
+ : "loop expired while the agent was busy",
882
+ );
883
+ return;
884
+ }
885
+ this.runTick(ctx);
886
+ }, delay);
887
+ this.expiryTimer.unref?.();
888
+ }
889
+
890
+ private clearExpiryWatchdog(): void {
891
+ if (this.expiryTimer) clearTimeout(this.expiryTimer);
892
+ this.expiryTimer = undefined;
893
+ }
894
+
895
+ /**
896
+ * The other half of expiry: the wake was handed to Pi, and this stops the
897
+ * loop if it never becomes a run. A run that *did* start leaves
898
+ * `awaitingRun` false, and the settle after it stops the loop normally.
899
+ */
900
+ private armExpiryTurnGuard(loopId: string): void {
901
+ this.clearExpiryWatchdog();
902
+ this.expiryTimer = setTimeout(() => {
903
+ this.expiryTimer = undefined;
904
+ const current = this.state;
905
+ if (!current || current.id !== loopId || current.status !== "active") return;
906
+ if (!this.awaitingRun) return;
907
+ this.transition("stopped", "the final expiry turn never started");
908
+ }, this.expiryTurnGraceMs);
909
+ this.expiryTimer.unref?.();
910
+ }
911
+
837
912
  private gatherEnvironment(ctx: ExtensionContext): TickEnvironment {
838
913
  const branch = ctx.sessionManager.getBranch();
839
914
  return {
@@ -921,6 +996,10 @@ export class LoopController {
921
996
  }
922
997
  this.runOrigin = "fallback";
923
998
  this.continuationIntent = undefined;
999
+ // Marked directly rather than through noteDelivery(): the dead-delivery
1000
+ // counter pauses a loop that should keep trying, and this one is already
1001
+ // ending. The guard below is what acts on it.
1002
+ this.awaitingRun = true;
924
1003
  this.state = {
925
1004
  ...this.consumeWait(loop),
926
1005
  iteration: loop.iteration + 1,
@@ -929,6 +1008,10 @@ export class LoopController {
929
1008
  expiring: true,
930
1009
  };
931
1010
  this.clearTimer();
1011
+ // The wake was accepted, not delivered. Hold one bounded guard so a final
1012
+ // turn that never starts still ends the loop instead of leaving it active
1013
+ // past its deadline with every timer cleared.
1014
+ this.armExpiryTurnGuard(loop.id);
932
1015
  this.persist();
933
1016
  this.updateWidget();
934
1017
  this.sessionCtx?.ui.notify(
@@ -1047,9 +1130,20 @@ export class LoopController {
1047
1130
 
1048
1131
  private transition(status: "paused" | "stopped", why: string, cause?: string): void {
1049
1132
  if (!this.state) return;
1050
- const { waiting: _waiting, pauseCause: _pauseCause, ...rest } = this.state;
1051
- this.state = { ...rest, status, ...(cause ? { pauseCause: cause } : {}) };
1133
+ const {
1134
+ waiting: _waiting,
1135
+ pauseCause: _pauseCause,
1136
+ terminalReason: _terminalReason,
1137
+ ...rest
1138
+ } = this.state;
1139
+ this.state = {
1140
+ ...rest,
1141
+ status,
1142
+ ...(cause ? { pauseCause: cause } : {}),
1143
+ ...(status === "stopped" ? { terminalReason: why } : {}),
1144
+ };
1052
1145
  this.clearTimer();
1146
+ this.clearExpiryWatchdog();
1053
1147
  this.waitTimer.clear();
1054
1148
  this.wakePending = false;
1055
1149
  this.continuationIntent = undefined;
@@ -1128,9 +1222,9 @@ export class LoopController {
1128
1222
 
1129
1223
  statusLines(ctx: ExtensionContext): string[] {
1130
1224
  const loop = this.state;
1131
- if (!loop) return ["No loop in this session. Start one with /loop <interval> [prompt]."];
1225
+ if (!loop) return ["No loop in this session. Run /loop to plan one."];
1132
1226
  const lines = [
1133
- `Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
1227
+ `Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : loop.terminalReason ? ` (${loop.terminalReason})` : ""}`,
1134
1228
  ...(loop.waiting
1135
1229
  ? [
1136
1230
  `Waiting: ${loop.waiting.reason}${
@@ -1208,10 +1302,7 @@ export class LoopController {
1208
1302
  }
1209
1303
 
1210
1304
  /** Record a drafted loop for approval, replacing any previous draft. */
1211
- propose(
1212
- objective: string,
1213
- overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
1214
- ): LoopProposal {
1305
+ propose(objective: string, overrides: LoopProposalOverrides = {}): LoopProposal {
1215
1306
  const proposal = buildProposal(
1216
1307
  objective,
1217
1308
  {
@@ -1287,7 +1378,7 @@ export class LoopController {
1287
1378
  return {
1288
1379
  ok: false,
1289
1380
  message:
1290
- "A loop needs something to work on. Give it an objective: /loop <interval> <objective with completion criteria>.",
1381
+ "A loop needs something to work on. Run /loop and draft an objective with completion criteria first.",
1291
1382
  };
1292
1383
  }
1293
1384
  // A loop with no way to call loop_complete would work, finish, and then be
@@ -1307,10 +1398,12 @@ export class LoopController {
1307
1398
  : this.settings.compaction.enabled
1308
1399
  ? this.settings.compaction.threshold
1309
1400
  : null;
1401
+ const groundRules = normalizeGroundRules(start.groundRules);
1310
1402
  const loop: LoopState = {
1311
1403
  id: randomUUID().slice(0, 8),
1312
1404
  status: "active",
1313
1405
  objective,
1406
+ ...(groundRules ? { groundRules } : {}),
1314
1407
  intervalMs: start.intervalMs,
1315
1408
  maxTurns: start.maxTurns !== undefined ? start.maxTurns : this.settings.maxTurns,
1316
1409
  compactAt,
@@ -1351,12 +1444,13 @@ export class LoopController {
1351
1444
  this.openLedger(started, built.criteria);
1352
1445
  this.persist();
1353
1446
  this.scheduleTick(started.intervalMs);
1447
+ this.armExpiryWatchdog();
1354
1448
  this.updateWidget();
1355
1449
  const clampNote = built.clamped
1356
1450
  ? ` (requested ${formatDuration(built.requestedMs)}, clamped to the ${formatDuration(started.intervalMs)} minimum)`
1357
1451
  : "";
1358
1452
  ctx.ui.notify(
1359
- `Loop started: working its objective from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you run /loop stop. Fallback wake every ${formatDuration(started.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(built.expiryMs)} (one final turn to write its state down, then it stops).`,
1453
+ `Loop started: working its objective from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you stop it from the /loop menu. Fallback wake every ${formatDuration(started.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(built.expiryMs)} (one final turn to write its state down, then it stops).`,
1360
1454
  "info",
1361
1455
  );
1362
1456
  if (this.ledger) {
@@ -1439,6 +1533,7 @@ export class LoopController {
1439
1533
  this.noOpStreak = 0;
1440
1534
  this.persist();
1441
1535
  this.scheduleTick(loop.intervalMs);
1536
+ this.armExpiryWatchdog();
1442
1537
  this.updateWidget();
1443
1538
  ctx.ui.notify(
1444
1539
  `Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`,