@hank-warren/pi-loop 0.4.1 → 0.5.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
@@ -3,11 +3,19 @@
3
3
  * loop-aware compaction, wired to Pi's extension events.
4
4
  *
5
5
  * Design invariants (approved plan):
6
- * - Timers are armed in session_start or a command handler, never the factory,
7
- * and cleared in an idempotent session_shutdown.
6
+ * - The settled idle boundary is the pacemaker for a standalone loop: an
7
+ * agent_end records a continuation *intent*, and the next fully settled
8
+ * boundary dispatches it. The interval is a fallback heartbeat, re-armed
9
+ * from the last settle, that fires only when the session has been idle a
10
+ * whole interval with the objective unfinished (a lost continuation, or an
11
+ * external wait). A goal-bound loop is unchanged: pi-goal owns its settle
12
+ * continuations, and the interval is still that loop's only driver.
13
+ * - Timers are armed in session_start, a settle, or a command handler, never
14
+ * the factory, and cleared in an idempotent session_shutdown.
8
15
  * - Pokes deliver only at a fully idle boundary; a tick that lands while the
9
16
  * agent is busy coalesces into a single pending wake delivered at the next
10
- * agent_settled. Missed ticks never stack.
17
+ * agent_settled. Missed ticks never stack, and a continuation supersedes a
18
+ * coalesced wake rather than delivering both.
11
19
  * - Loops require an active pi-goal goal to operate: pi-goal owns "whether
12
20
  * the work is done". Its safety states pause the loop, its completion stops
13
21
  * it, and a missing goal pauses the loop. Coupling is read-only session
@@ -27,9 +35,31 @@ import type {
27
35
  ExtensionContext,
28
36
  } from "@earendil-works/pi-coding-agent";
29
37
  import type { LoopStartArguments } from "./command.js";
30
- import { decideTick, type TickDecision, type TickEnvironment } from "./decide.js";
31
- import { formatClock, formatDuration, parseDuration } from "./interval.js";
32
- import { buildCompactionInstructions, buildGoalPoke, buildObjectivePoke } from "./messages.js";
38
+ import {
39
+ type ContinuationDecision,
40
+ decideContinuation,
41
+ decideTick,
42
+ type TickDecision,
43
+ type TickEnvironment,
44
+ } from "./decide.js";
45
+ import { formatClock, formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
46
+ import {
47
+ createLedger,
48
+ deriveCriteria,
49
+ type LedgerPaths,
50
+ ledgerPaths,
51
+ readCriteria,
52
+ } from "./ledger.js";
53
+ import {
54
+ buildCompactionInstructions,
55
+ buildContinuation,
56
+ buildExpiryWake,
57
+ buildGoalPoke,
58
+ buildKickoffAnchor,
59
+ buildObjectivePoke,
60
+ type ContinuationKind,
61
+ extractNextActions,
62
+ } from "./messages.js";
33
63
  import {
34
64
  DEFAULT_LOOP_SETTINGS,
35
65
  type LoopSettings,
@@ -44,13 +74,50 @@ import {
44
74
  readPlanModeEnabled,
45
75
  restoreLoopState,
46
76
  } from "./state.js";
77
+ import { isLoopOkAck } from "./ack.js";
78
+ import { calledTool, hasAssistantToolCall, nextNoProgressState } from "./safety.js";
79
+ import { classifyInterruption } from "./errors.js";
80
+ import { LOOP_WAIT_TOOL } from "./wait-tool.js";
81
+ import {
82
+ createLoopWait,
83
+ type LoopWait,
84
+ LoopWaitTimer,
85
+ resolveWaitDelay,
86
+ type ResolvedWaitDelay,
87
+ } from "./wait.js";
88
+ import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
47
89
  import { clearLoopWidget, updateLoopWidget } from "./widget.js";
48
90
 
49
91
  export const LOOP_STATUS_KEY = "loop";
50
92
 
93
+ /** Custom message type of the kickoff anchor. */
94
+ export const LOOP_ANCHOR_MESSAGE_TYPE = "loop-objective";
95
+
96
+ /**
97
+ * A fallback wake that produces a no-op turn doubles the next fallback delay,
98
+ * capped here. Deterministic and small: the point is to stop re-waking a loop
99
+ * that has nothing to do, not to invent an adaptive scheduler.
100
+ */
101
+ export const MAX_FALLBACK_BACKOFF = 4;
102
+
103
+ /** Consecutive loop deliveries that produce no run before the loop pauses. */
104
+ export const MAX_DEAD_DELIVERIES = 3;
105
+
106
+ /** Why the loop caused the run that is currently in flight. */
107
+ type RunOrigin = "continuation" | "fallback";
108
+
109
+ interface ContinuationIntent {
110
+ loopId: string;
111
+ kind: ContinuationKind;
112
+ /** Next actions carried out of a compaction summary, for a re-anchor. */
113
+ nextActions?: string;
114
+ }
115
+
51
116
  export interface LoopControllerOptions {
52
117
  settingsPath?: string;
53
118
  now?: () => number;
119
+ /** Root for the loop ledger; defaults to Pi's agent dir. Tests override it. */
120
+ agentDir?: string;
54
121
  }
55
122
 
56
123
  export class LoopController {
@@ -66,11 +133,31 @@ export class LoopController {
66
133
  private nextWakeAt: number | undefined;
67
134
  private wakePending = false;
68
135
  private sessionCtx: ExtensionContext | undefined;
136
+ private continuationIntent: ContinuationIntent | undefined;
137
+ private runOrigin: RunOrigin | undefined;
138
+ /** The active loop's ledger, or undefined when it could not be created. */
139
+ ledger: LedgerPaths | undefined;
140
+ private ledgerWarned = false;
141
+ private readonly agentDir: string | undefined;
142
+ /** Consecutive fallback wakes that produced a no-op turn. */
143
+ noOpStreak = 0;
144
+ lastContinuation: (ContinuationDecision & { at: number }) | undefined;
145
+ private readonly waitTimer = new LoopWaitTimer();
146
+ /**
147
+ * Loop-caused deliveries that never produced an agent run. A provider that
148
+ * refuses before the first token — no API key, a torn-down runner — never
149
+ * reaches agent_end, so no classifier ever sees it.
150
+ */
151
+ private deadDeliveries = 0;
152
+ private awaitingRun = false;
153
+ /** Set when an interrupted turn needs a compaction before the loop continues. */
154
+ private compactionRequested = false;
69
155
 
70
156
  constructor(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
71
157
  this.pi = pi;
72
158
  this.now = options.now ?? Date.now;
73
159
  this.settingsPath = options.settingsPath ?? loopSettingsPath();
160
+ this.agentDir = options.agentDir;
74
161
  }
75
162
 
76
163
  // --- lifecycle ---
@@ -80,6 +167,12 @@ export class LoopController {
80
167
  this.wakePending = false;
81
168
  this.compacting = false;
82
169
  this.lastDecision = undefined;
170
+ this.lastContinuation = undefined;
171
+ this.continuationIntent = undefined;
172
+ this.runOrigin = undefined;
173
+ this.noOpStreak = 0;
174
+ this.deadDeliveries = 0;
175
+ this.awaitingRun = false;
83
176
  this.sessionCtx = ctx;
84
177
 
85
178
  const loaded = readLoopSettings(this.settingsPath);
@@ -88,34 +181,210 @@ export class LoopController {
88
181
  ctx.ui.notify(`pi-loop settings ignored: ${loaded.reason}. Using defaults.`, "warning");
89
182
  }
90
183
 
184
+ this.ledger = undefined;
185
+ this.ledgerWarned = false;
91
186
  this.state = restoreLoopState(ctx.sessionManager.getBranch());
92
187
  if (this.state && this.state.status === "active") {
93
188
  if (this.now() >= this.state.expiresAt) {
94
189
  this.transition("stopped", "loop expired while the session was away");
95
190
  return;
96
191
  }
97
- this.scheduleTick(this.state.intervalMs);
192
+ if (!isStandaloneLoop(this.state) && this.migrateGoalBoundLoop(ctx)) return;
193
+ // A restored loop keeps its ledger: createLedger only ever creates
194
+ // PROGRESS.md, so days of agent-written state survive a restart.
195
+ this.openLedger(this.state);
196
+ // A wait whose deadline passed while the session was away is due now.
197
+ this.restoreWaitTimer();
198
+ this.armFallback();
98
199
  }
99
200
  this.updateWidget();
100
201
  }
101
202
 
102
203
  onSessionShutdown(): void {
103
204
  this.clearTimer();
205
+ this.waitTimer.clear();
104
206
  this.wakePending = false;
207
+ this.continuationIntent = undefined;
208
+ this.runOrigin = undefined;
105
209
  if (this.sessionCtx) clearLoopWidget(this.sessionCtx.ui);
106
210
  this.sessionCtx = undefined;
107
211
  }
108
212
 
213
+ /**
214
+ * A finished agent run with an active standalone loop is the signal that
215
+ * paces the loop: record the *intent* to continue here and let the settled
216
+ * boundary decide whether it may be delivered. Recording at agent_end (not
217
+ * at settle) is what makes the intent survive Pi's own retries and
218
+ * auto-compaction, which run between the two events.
219
+ */
220
+ /** A run started, so the delivery that caused it was not a dead one. */
221
+ onAgentStart(ctx: ExtensionContext): void {
222
+ this.sessionCtx = ctx;
223
+ this.awaitingRun = false;
224
+ this.deadDeliveries = 0;
225
+ }
226
+
227
+ onAgentEnd(ctx: ExtensionContext, messages: readonly unknown[] = []): void {
228
+ this.sessionCtx = ctx;
229
+ this.awaitingRun = false;
230
+ const origin = this.runOrigin;
231
+ this.runOrigin = undefined;
232
+ const loop = this.state;
233
+ if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return;
234
+ if (this.enforceToolAvailability(ctx)) return;
235
+ // The expiry's final turn is the last one: never queue a continuation
236
+ // behind it. The settle that follows stops the loop.
237
+ if (loop.expiring) return;
238
+ // A user turn cancels a wait: whatever they just said outranks it. The
239
+ // reason survives as a hint on the next loop message.
240
+ if (origin === undefined && loop.waiting) this.cancelWait("a user message arrived");
241
+ this.recordProgress(origin, messages);
242
+ if (this.classifyAndHandleInterruption(ctx, messages, origin)) return;
243
+ if (this.enforceNoProgress(ctx, messages, origin)) return;
244
+ // A turn that ended in loop_wait asked not to be continued.
245
+ if (this.state?.waiting) return;
246
+ if (this.state) this.requestContinuation(this.state);
247
+ }
248
+
249
+ /**
250
+ * Act on how the turn ended. A loop that answers every provider failure
251
+ * with "continue" retries into exhausted quotas and re-sends requests that
252
+ * are too large to succeed; each class needs its own answer. Returns true
253
+ * when the interruption was handled and no continuation should be recorded.
254
+ */
255
+ private classifyAndHandleInterruption(
256
+ ctx: ExtensionContext,
257
+ messages: readonly unknown[],
258
+ origin: RunOrigin | undefined,
259
+ ): boolean {
260
+ const loop = this.state;
261
+ if (!loop) return true;
262
+ switch (classifyInterruption(messages)) {
263
+ case "usage-limited":
264
+ this.transition(
265
+ "paused",
266
+ "the provider reports the usage limit is reached; resume with /loop resume once it resets",
267
+ "usage limit reached",
268
+ );
269
+ return true;
270
+ case "fatal":
271
+ this.transition(
272
+ "paused",
273
+ "the turn failed with an error a retry cannot fix; resolve it, then /loop resume",
274
+ "unrecoverable provider error",
275
+ );
276
+ return true;
277
+ case "aborted":
278
+ // Esc, or another extension stopping the turn. A loop-caused run
279
+ // that the user interrupted must not be immediately re-sent.
280
+ if (origin === undefined) return false;
281
+ this.transition("paused", "the turn was interrupted; resume with /loop resume", "interrupted");
282
+ return true;
283
+ case "context-overflow":
284
+ // The request no longer fits: compact first, then continue. The
285
+ // re-anchor that follows the compaction is the continuation.
286
+ this.compactionRequested = true;
287
+ ctx.ui.notify(
288
+ "pi-loop: the turn overflowed the context window; compacting before continuing.",
289
+ "warning",
290
+ );
291
+ return true;
292
+ default:
293
+ // "none" and "retryable" both continue: a transient provider error
294
+ // is exactly what the next continuation retries.
295
+ return false;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * The no-progress breaker: consecutive tool-free loop turns with identical
301
+ * visible output pause the loop instead of waking it again forever. It
302
+ * pauses rather than stops, so the loop stays configured and one
303
+ * `/loop resume` (or the next user prompt) puts it back to work.
304
+ */
305
+ private enforceNoProgress(
306
+ ctx: ExtensionContext,
307
+ messages: readonly unknown[],
308
+ origin: RunOrigin | undefined,
309
+ ): boolean {
310
+ const loop = this.state;
311
+ if (!loop) return true;
312
+ const limit = this.settings.noProgressTurns;
313
+ if (origin === undefined) {
314
+ // Any user input resets the safety epoch: the user has seen the
315
+ // output and chosen to keep going.
316
+ if (loop.toolFreeRepeatCount !== undefined || loop.lastFingerprint !== undefined) {
317
+ this.state = { ...loop, toolFreeRepeatCount: 0, lastFingerprint: undefined };
318
+ this.persist();
319
+ }
320
+ return false;
321
+ }
322
+ if (limit === null) return false;
323
+ // A turn that called loop_wait declared a wait; that is a decision, not a
324
+ // stall, and counting it is the classic false positive.
325
+ const toolAttempted = hasAssistantToolCall(messages) || calledTool(messages, LOOP_WAIT_TOOL);
326
+ const next = nextNoProgressState(
327
+ {
328
+ toolFreeRepeatCount: loop.toolFreeRepeatCount ?? 0,
329
+ ...(loop.lastFingerprint === undefined ? {} : { lastFingerprint: loop.lastFingerprint }),
330
+ },
331
+ messages,
332
+ toolAttempted,
333
+ );
334
+ this.state = {
335
+ ...loop,
336
+ toolFreeRepeatCount: next.toolFreeRepeatCount,
337
+ ...(next.lastFingerprint === undefined ? {} : { lastFingerprint: next.lastFingerprint }),
338
+ };
339
+ if (next.toolFreeRepeatCount < limit) {
340
+ this.persist();
341
+ return false;
342
+ }
343
+ void ctx;
344
+ this.transition(
345
+ "paused",
346
+ `${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`,
347
+ "no progress",
348
+ );
349
+ return true;
350
+ }
351
+
352
+ /**
353
+ * Fallback backoff bookkeeping. A user-driven turn, or any loop-caused turn
354
+ * that actually did something, resets the streak; only a fallback wake that
355
+ * produced a no-op turn grows the next fallback delay.
356
+ */
357
+ private recordProgress(origin: RunOrigin | undefined, messages: readonly unknown[]): void {
358
+ if (origin === undefined) {
359
+ // A turn the user drove: the loop is not the thing spinning.
360
+ this.noOpStreak = 0;
361
+ return;
362
+ }
363
+ // An explicit LOOP_OK is the deterministic version of the same signal:
364
+ // the model looked and there was nothing to do. It counts even when the
365
+ // turn used a tool to look.
366
+ if (!isNoOpRun(messages) && !isLoopOkAck(messages)) {
367
+ this.noOpStreak = 0;
368
+ return;
369
+ }
370
+ if (origin === "fallback") this.noOpStreak += 1;
371
+ }
372
+
109
373
  onAgentSettled(ctx: ExtensionContext): void {
110
374
  this.sessionCtx = ctx;
111
375
  if (!this.state || this.state.status !== "active") return;
112
376
  if (this.maybeStartCompaction(ctx)) return;
377
+ if (this.settleTerminalState(ctx)) return;
378
+ if (this.dispatchContinuationIfSettled(ctx)) return;
113
379
  if (this.wakePending) {
114
380
  this.wakePending = false;
115
381
  this.runTick(ctx);
116
382
  return;
117
383
  }
118
- this.settleTerminalState(ctx);
384
+ // Re-arm the heartbeat from this settle, so it can only fire after a full
385
+ // interval of genuine idleness. A goal-bound loop keeps the old cadence:
386
+ // its timer is the only driver it has.
387
+ if (isStandaloneLoop(this.state)) this.armFallback();
119
388
  }
120
389
 
121
390
  /**
@@ -125,20 +394,309 @@ export class LoopController {
125
394
  * later. Poke and skip decisions are deliberately ignored here: only the
126
395
  * timer pokes, and settling is not a schedule.
127
396
  */
128
- private settleTerminalState(ctx: ExtensionContext): void {
397
+ private settleTerminalState(ctx: ExtensionContext): boolean {
129
398
  const loop = this.state;
130
- if (!loop) return;
399
+ if (!loop) return false;
131
400
  const env = this.gatherEnvironment(ctx);
132
401
  const decision = decideTick(loop, env);
133
402
  if (decision.action !== "expire" && decision.action !== "stop" && decision.action !== "pause") {
134
- return;
403
+ return false;
135
404
  }
136
405
  this.lastDecision = { ...decision, at: env.now };
137
406
  this.applyTerminalDecision(loop, decision);
407
+ return true;
408
+ }
409
+
410
+ /**
411
+ * Migration for goal-bound loops, which are deprecated.
412
+ *
413
+ * A goal-bound loop delegated "is the work done" to pi-goal. Now that a
414
+ * standalone loop owns completion itself, that delegation is the deprecated
415
+ * path, and this converts one in place at restore rather than leaving the
416
+ * user with a loop that pauses forever the moment its goal is gone.
417
+ *
418
+ * The one case it must *not* convert is a still-active goal: pi-goal is
419
+ * driving that session's continuations, and a standalone loop driving them
420
+ * too would send two messages at every settle. That loop keeps its old
421
+ * behaviour and gets the notice instead.
422
+ *
423
+ * Returns true when the loop was stopped or paused and needs no timer.
424
+ */
425
+ private migrateGoalBoundLoop(ctx: ExtensionContext): boolean {
426
+ const loop = this.state;
427
+ if (!loop) return true;
428
+ const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
429
+ if (goal?.status === "active") {
430
+ ctx.ui.notify(
431
+ "This loop is bound to a /goal, which is deprecated: pi-loop now owns long-running work on its own. It keeps working as before for now — start future loops with /loop <interval> <objective>.",
432
+ "warning",
433
+ );
434
+ return false;
435
+ }
436
+ if (goal?.status === "complete") {
437
+ this.transition("stopped", "the goal completed");
438
+ return true;
439
+ }
440
+ // The goal is gone or held: nothing is driving this loop any more, so
441
+ // adopt whatever objective text is still readable and carry on.
442
+ const objective = goal?.text ?? loop.prompt;
443
+ if (!objective) {
444
+ this.transition(
445
+ "paused",
446
+ "it was bound to a goal that is gone, and it has no objective text of its own to adopt; start a new loop with /loop <interval> <objective>",
447
+ "goal-bound loop with no objective",
448
+ );
449
+ return true;
450
+ }
451
+ const { prompt: _prompt, ...rest } = loop;
452
+ this.state = { ...rest, objective };
453
+ this.persist();
454
+ ctx.ui.notify(
455
+ `Goal-bound loops are deprecated; this one now owns its objective directly: ${objective}`,
456
+ "info",
457
+ );
458
+ return false;
459
+ }
460
+
461
+ /**
462
+ * A standalone loop that cannot call `loop_complete` cannot end itself: it
463
+ * will work, finish, and then be told to keep working until it hits a cap.
464
+ * That happens whenever the tool set is restricted (`--tools`, `--no-tools`,
465
+ * a policy that drops extension tools), and it is invisible from inside the
466
+ * loop — so check the live tool set and pause instead of spinning.
467
+ *
468
+ * Returns true when the loop was paused.
469
+ */
470
+ private enforceToolAvailability(ctx: ExtensionContext): boolean {
471
+ const loop = this.state;
472
+ if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return false;
473
+ if (this.completeToolAvailable()) return false;
474
+ this.transition(
475
+ "paused",
476
+ `the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then /loop resume`,
477
+ "loop_complete unavailable",
478
+ );
479
+ void ctx;
480
+ return true;
481
+ }
482
+
483
+ private completeToolAvailable(): boolean {
484
+ const getActiveTools = (this.pi as { getActiveTools?: () => string[] }).getActiveTools;
485
+ if (typeof getActiveTools !== "function") return true;
486
+ try {
487
+ // Fail open: a host that cannot report its tools is not evidence that
488
+ // the tool is missing.
489
+ return getActiveTools.call(this.pi).includes(LOOP_COMPLETE_TOOL);
490
+ } catch {
491
+ return true;
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Count a delivery that never became a run, and pause once the loop is
497
+ * plainly shouting into a void. Without this a session whose provider
498
+ * refuses every request (no API key, a revoked token) keeps waking on the
499
+ * heartbeat until it exhausts its wake cap, because a run that fails before
500
+ * the first token never reaches agent_end and so is never classified.
501
+ *
502
+ * Returns true when the loop was paused.
503
+ */
504
+ private noteDelivery(): boolean {
505
+ if (this.awaitingRun) this.deadDeliveries += 1;
506
+ this.awaitingRun = true;
507
+ if (this.deadDeliveries < MAX_DEAD_DELIVERIES) return false;
508
+ this.transition(
509
+ "paused",
510
+ `${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then /loop resume`,
511
+ "deliveries produce no turns",
512
+ );
513
+ return true;
514
+ }
515
+
516
+ // --- loop_wait ---
517
+
518
+ /**
519
+ * Enter an external wait. The pacemaker is deliberately left armed: a wait
520
+ * supersedes the *next* fallback wake, it does not disable the heartbeat,
521
+ * so a wait whose event never arrives still ends in a wake rather than in
522
+ * silence.
523
+ */
524
+ enterWait(reason: string, resumeAfterMs: number | undefined): ResolvedWaitDelay | undefined {
525
+ const loop = this.state;
526
+ if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return undefined;
527
+ const resolved = resolveWaitDelay(resumeAfterMs);
528
+ const waiting = createLoopWait(reason, resumeAfterMs, this.now());
529
+ // The wait replaces any continuation already recorded for this turn.
530
+ this.continuationIntent = undefined;
531
+ const { cancelledWaitReason: _cancelled, ...rest } = loop;
532
+ this.state = { ...rest, waiting };
533
+ this.persist();
534
+ this.restoreWaitTimer();
535
+ this.updateWidget();
536
+ return resolved;
537
+ }
538
+
539
+ /**
540
+ * Drop a wait that something other than its own deadline ended, keeping its
541
+ * reason as a one-shot hint for the next loop message. There is no cancel
542
+ * tool: the events that legitimately cancel a wait are not the model's to
543
+ * report.
544
+ */
545
+ private cancelWait(_why: string): void {
546
+ const loop = this.state;
547
+ if (!loop?.waiting) return;
548
+ const { waiting, ...rest } = loop;
549
+ this.waitTimer.clear();
550
+ this.state = { ...rest, cancelledWaitReason: waiting.reason };
551
+ this.persist();
552
+ this.updateWidget();
553
+ }
554
+
555
+ /** Re-arm the wait deadline, including after a restore or a compaction. */
556
+ private restoreWaitTimer(): void {
557
+ this.waitTimer.clear();
558
+ const loop = this.state;
559
+ const resumeAt = loop?.status === "active" ? loop.waiting?.resumeAt : undefined;
560
+ if (resumeAt === undefined) return;
561
+ const loopId = loop?.id;
562
+ this.waitTimer.schedule(
563
+ resumeAt,
564
+ () => {
565
+ const ctx = this.sessionCtx;
566
+ if (!ctx || this.state?.id !== loopId) return;
567
+ try {
568
+ this.runTick(ctx);
569
+ } catch (error) {
570
+ ctx.ui.notify(`pi-loop wait deadline failed: ${formatError(error)}`, "warning");
571
+ }
572
+ },
573
+ this.now(),
574
+ );
575
+ }
576
+
577
+ /** The wait a delivered wake consumed, cleared as the wake goes out. */
578
+ private consumeWait(loop: LoopState): LoopState {
579
+ const { waiting: _waiting, cancelledWaitReason: _cancelled, ...rest } = loop;
580
+ this.waitTimer.clear();
581
+ return rest;
582
+ }
583
+
584
+ // --- ledger ---
585
+
586
+ /**
587
+ * Create (or adopt) the ledger for a standalone loop. Best-effort by
588
+ * design: a loop with no writable ledger still runs, it just loses the
589
+ * durable record, so the failure is warned once and never repeated.
590
+ */
591
+ private openLedger(loop: LoopState): void {
592
+ if (loop.objective === undefined) {
593
+ this.ledger = undefined;
594
+ return;
595
+ }
596
+ const paths = ledgerPaths(loop.id, this.agentDir);
597
+ const failure = createLedger(paths, loop.objective, deriveCriteria(loop.objective));
598
+ if (failure) {
599
+ this.ledger = undefined;
600
+ if (!this.ledgerWarned) {
601
+ this.ledgerWarned = true;
602
+ this.sessionCtx?.ui.notify(
603
+ `pi-loop could not write its ledger (${failure}). The loop runs without one.`,
604
+ "warning",
605
+ );
606
+ }
607
+ return;
608
+ }
609
+ this.ledger = paths;
610
+ }
611
+
612
+ /** The loop's criteria as last written to disk, fail-open. */
613
+ criteria() {
614
+ return this.ledger ? readCriteria(this.ledger) : undefined;
615
+ }
616
+
617
+ // --- settle-driven continuation ---
618
+
619
+ /** Record the intent to continue; the settled boundary decides delivery. */
620
+ private requestContinuation(
621
+ loop: LoopState,
622
+ kind: ContinuationKind = "continue",
623
+ nextActions?: string,
624
+ ): void {
625
+ // A re-anchor outranks an ordinary continuation already queued: after a
626
+ // compaction, "re-read the ledger" is strictly the better instruction.
627
+ if (this.continuationIntent?.loopId === loop.id && kind !== "reanchor") return;
628
+ this.continuationIntent = { loopId: loop.id, kind, ...(nextActions ? { nextActions } : {}) };
629
+ }
630
+
631
+ /**
632
+ * Deliver a recorded continuation, but only at a boundary where Pi will
633
+ * actually accept it. A skip leaves the intent in place for the next
634
+ * settle; a terminal decision consumes it.
635
+ */
636
+ dispatchContinuationIfSettled(ctx: ExtensionContext): boolean {
637
+ const intent = this.continuationIntent;
638
+ if (!intent) return false;
639
+ const loop = this.state;
640
+ if (!loop || loop.id !== intent.loopId) {
641
+ this.continuationIntent = undefined;
642
+ return false;
643
+ }
644
+ const env = this.gatherEnvironment(ctx);
645
+ const decision = decideContinuation(loop, env);
646
+ this.lastContinuation = { ...decision, at: env.now };
647
+ if (decision.action === "none") {
648
+ this.continuationIntent = undefined;
649
+ return false;
650
+ }
651
+ if (decision.action === "skip") return false;
652
+ if (decision.action !== "continue") {
653
+ this.continuationIntent = undefined;
654
+ this.lastDecision = { ...decision, at: env.now };
655
+ this.applyTerminalDecision(loop, decision);
656
+ return true;
657
+ }
658
+ try {
659
+ this.pi.sendUserMessage(buildContinuation(loop, intent.kind, intent.nextActions));
660
+ } catch (error) {
661
+ // Keep the intent: the next settle retries it, and the fallback
662
+ // heartbeat covers the case where no further settle arrives.
663
+ this.sessionCtx?.ui.notify(
664
+ `pi-loop could not continue the loop: ${formatError(error)}. Retrying at the next idle boundary.`,
665
+ "warning",
666
+ );
667
+ this.armFallback();
668
+ this.updateWidget();
669
+ return false;
670
+ }
671
+ this.continuationIntent = undefined;
672
+ this.runOrigin = "continuation";
673
+ if (this.noteDelivery()) return true;
674
+ // A continuation is the work the coalesced wake would have asked for.
675
+ this.wakePending = false;
676
+ this.state = { ...loop, automaticTurns: loop.automaticTurns + 1 };
677
+ this.persist();
678
+ this.armFallback();
679
+ this.updateWidget();
680
+ return true;
138
681
  }
139
682
 
140
683
  // --- tick machinery ---
141
684
 
685
+ /**
686
+ * Arm the fallback heartbeat, backing off while consecutive fallback wakes
687
+ * keep producing no-op turns.
688
+ */
689
+ private armFallback(): void {
690
+ const loop = this.state;
691
+ if (!loop || loop.status !== "active") return;
692
+ this.scheduleTick(this.fallbackDelayMs(loop));
693
+ }
694
+
695
+ fallbackDelayMs(loop: LoopState): number {
696
+ const multiplier = Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak);
697
+ return Math.min(MAX_INTERVAL_MS, loop.intervalMs * multiplier);
698
+ }
699
+
142
700
  private scheduleTick(delayMs: number): void {
143
701
  this.clearTimer();
144
702
  this.nextWakeAt = this.now() + delayMs;
@@ -185,10 +743,12 @@ export class LoopController {
185
743
  case "none":
186
744
  return;
187
745
  case "skip":
188
- if (decision.reason === "plan-mode-active") {
189
- // Plan mode may end without an agent_settled we can use, so
190
- // keep ticking until it does.
191
- this.scheduleTick(loop.intervalMs);
746
+ if (decision.reason === "plan-mode-active" || decision.reason === "loop-waiting") {
747
+ // Plan mode may end without an agent_settled we can use, and a
748
+ // wait supersedes only this one fallback wake — in both cases
749
+ // keep the heartbeat armed rather than coalescing a wake that
750
+ // would fire the moment the hold ends.
751
+ this.armFallback();
192
752
  } else {
193
753
  // Busy or compacting: coalesce into one pending wake that
194
754
  // the next agent_settled (or compaction onComplete) delivers.
@@ -211,14 +771,17 @@ export class LoopController {
211
771
  ): void {
212
772
  switch (decision.action) {
213
773
  case "expire":
214
- this.transition("stopped", "loop expired (maxLoopDuration reached)");
774
+ if (decision.reason === "expiry-final-wake" && this.deliverExpiryWake(loop)) return;
775
+ this.transition("stopped", "loop expired (the expiry was reached)");
215
776
  return;
216
777
  case "stop":
217
778
  this.transition(
218
779
  "stopped",
219
780
  decision.reason === "goal-complete"
220
781
  ? "the goal completed"
221
- : `the ${loop.maxIterations}-iteration cap was reached`,
782
+ : decision.reason === "max-automatic-turns"
783
+ ? `the ${loop.maxAutomaticTurns}-automatic-turn cap was reached`
784
+ : `the ${loop.maxIterations}-iteration cap was reached`,
222
785
  );
223
786
  return;
224
787
  case "pause":
@@ -232,6 +795,46 @@ export class LoopController {
232
795
  }
233
796
  }
234
797
 
798
+ /**
799
+ * One last turn at expiry, so the loop's most recent state lands in the
800
+ * ledger instead of only in a conversation that is about to be closed. The
801
+ * loop stays active for exactly that turn — the objective append has to be
802
+ * present while it writes — and `expiring` makes the next settle stop it.
803
+ *
804
+ * Returns false when the wake could not be delivered, in which case the
805
+ * caller stops the loop immediately rather than leaving it alive past its
806
+ * expiry waiting for a turn that will not happen.
807
+ */
808
+ private deliverExpiryWake(loop: LoopState): boolean {
809
+ if (loop.expiring) return false;
810
+ try {
811
+ this.pi.sendUserMessage(buildExpiryWake(loop, this.ledger));
812
+ } catch (error) {
813
+ this.sessionCtx?.ui.notify(
814
+ `pi-loop could not deliver the expiry wake: ${formatError(error)}. Stopping the loop.`,
815
+ "warning",
816
+ );
817
+ return false;
818
+ }
819
+ this.runOrigin = "fallback";
820
+ this.continuationIntent = undefined;
821
+ this.state = {
822
+ ...this.consumeWait(loop),
823
+ iteration: loop.iteration + 1,
824
+ automaticTurns: loop.automaticTurns + 1,
825
+ lastWakeAt: this.now(),
826
+ expiring: true,
827
+ };
828
+ this.clearTimer();
829
+ this.persist();
830
+ this.updateWidget();
831
+ this.sessionCtx?.ui.notify(
832
+ "Loop expired: one final turn to write the current state down, then it stops.",
833
+ "info",
834
+ );
835
+ return true;
836
+ }
837
+
235
838
  /**
236
839
  * Send first, then account. Pi can refuse the delivery (a busy or compacting
237
840
  * session), and an iteration persisted before the send would burn the
@@ -240,29 +843,39 @@ export class LoopController {
240
843
  */
241
844
  private deliverPoke(
242
845
  env: TickEnvironment,
243
- reason: "goal-stalled" | "goal-waiting" | "objective-stalled",
846
+ reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed",
244
847
  ): void {
245
848
  const loop = this.state;
246
849
  if (!loop) return;
850
+ const standalone = reason === "objective-stalled" || reason === "wait-elapsed";
247
851
  // A goal-bound poke restates nothing, so it is only meaningful while the
248
852
  // goal it points at is readable; a standalone poke needs no goal at all.
249
- if (reason !== "objective-stalled" && !env.goal) return;
853
+ if (!standalone && !env.goal) return;
250
854
  try {
251
855
  this.pi.sendUserMessage(
252
- reason === "objective-stalled" ? buildObjectivePoke(loop) : buildGoalPoke(loop, reason),
856
+ standalone ? buildObjectivePoke(loop, reason) : buildGoalPoke(loop, reason),
253
857
  );
254
858
  } catch (error) {
255
859
  this.sessionCtx?.ui.notify(
256
860
  `pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
257
861
  "warning",
258
862
  );
259
- this.scheduleTick(loop.intervalMs);
863
+ this.armFallback();
260
864
  this.updateWidget();
261
865
  return;
262
866
  }
263
- this.state = { ...loop, iteration: loop.iteration + 1, lastWakeAt: env.now };
867
+ this.runOrigin = "fallback";
868
+ if (this.noteDelivery()) return;
869
+ // The wake consumed the wait it was arranged for, and any one-shot
870
+ // cancelled-wait hint it just carried.
871
+ this.state = {
872
+ ...this.consumeWait(loop),
873
+ iteration: loop.iteration + 1,
874
+ automaticTurns: loop.automaticTurns + 1,
875
+ lastWakeAt: env.now,
876
+ };
264
877
  this.persist();
265
- this.scheduleTick(this.state.intervalMs);
878
+ this.armFallback();
266
879
  this.updateWidget();
267
880
  }
268
881
 
@@ -271,9 +884,16 @@ export class LoopController {
271
884
  if (!loop || loop.status !== "active" || loop.compactAt === null) return false;
272
885
  if (!this.settings.compaction.enabled || this.compacting) return false;
273
886
  if (!ctx.isIdle() || ctx.hasPendingMessages()) return false;
274
- const usage = ctx.getContextUsage();
275
- if (!usage || typeof usage.tokens !== "number" || !usage.contextWindow) return false;
276
- if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
887
+ // A turn that overflowed the context window compacts regardless of the
888
+ // threshold: the usage reading that would gate it is exactly the reading
889
+ // the failed request just disproved.
890
+ const requested = this.compactionRequested;
891
+ this.compactionRequested = false;
892
+ if (!requested) {
893
+ const usage = ctx.getContextUsage();
894
+ if (!usage || typeof usage.tokens !== "number" || !usage.contextWindow) return false;
895
+ if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
896
+ }
277
897
  const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
278
898
  this.compacting = true;
279
899
  try {
@@ -284,9 +904,11 @@ export class LoopController {
284
904
  // objective the summary must preserve.
285
905
  goal?.status === "active" ? goal : undefined,
286
906
  this.settings.compaction.instructions,
907
+ this.ledger,
287
908
  ),
288
- onComplete: () => {
909
+ onComplete: (result) => {
289
910
  this.compacting = false;
911
+ this.requestReAnchor(result);
290
912
  this.nudgeHeldWake();
291
913
  },
292
914
  onError: (error) => {
@@ -307,21 +929,41 @@ export class LoopController {
307
929
  }
308
930
 
309
931
  /**
310
- * A wake held during compaction delivers at the next settled boundary;
311
- * nudge in case that boundary already passed while compaction ran.
932
+ * Own the post-compaction re-anchor instead of leaving the loop silent
933
+ * until the next fallback wake. One pointer-sized continuation at the next
934
+ * settle: the objective is in the system append, the record is in the
935
+ * ledger, and the next actions ride out of the summary that just replaced
936
+ * the conversation.
937
+ */
938
+ private requestReAnchor(result: unknown): void {
939
+ const loop = this.state;
940
+ if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return;
941
+ const summary =
942
+ isRecord(result) && typeof result.summary === "string" ? result.summary : undefined;
943
+ this.requestContinuation(loop, "reanchor", summary ? extractNextActions(summary) : undefined);
944
+ }
945
+
946
+ /**
947
+ * A wake or continuation held during compaction delivers at the next
948
+ * settled boundary; nudge in case that boundary already passed while
949
+ * compaction ran.
312
950
  */
313
951
  private nudgeHeldWake(): void {
314
952
  const ctx = this.sessionCtx;
315
- if (ctx && this.wakePending) this.onAgentSettled(ctx);
953
+ if (ctx && (this.wakePending || this.continuationIntent)) this.onAgentSettled(ctx);
316
954
  }
317
955
 
318
956
  // --- state transitions & presentation ---
319
957
 
320
- private transition(status: "paused" | "stopped", why: string): void {
958
+ private transition(status: "paused" | "stopped", why: string, cause?: string): void {
321
959
  if (!this.state) return;
322
- this.state = { ...this.state, status };
960
+ const { waiting: _waiting, pauseCause: _pauseCause, ...rest } = this.state;
961
+ this.state = { ...rest, status, ...(cause ? { pauseCause: cause } : {}) };
323
962
  this.clearTimer();
963
+ this.waitTimer.clear();
324
964
  this.wakePending = false;
965
+ this.continuationIntent = undefined;
966
+ this.runOrigin = undefined;
325
967
  this.persist();
326
968
  this.sessionCtx?.ui.notify(`Loop ${status}: ${why}.`, "info");
327
969
  this.updateWidget();
@@ -345,7 +987,19 @@ export class LoopController {
345
987
  return;
346
988
  }
347
989
  if (loop.status === "paused") {
348
- ui.setStatus(LOOP_STATUS_KEY, "loop paused");
990
+ ui.setStatus(
991
+ LOOP_STATUS_KEY,
992
+ loop.pauseCause ? `loop paused · ${loop.pauseCause}` : "loop paused",
993
+ );
994
+ return;
995
+ }
996
+ if (loop.waiting) {
997
+ ui.setStatus(
998
+ LOOP_STATUS_KEY,
999
+ `loop waiting · ${loop.waiting.reason}${
1000
+ loop.waiting.resumeAt ? ` · until ${formatClock(loop.waiting.resumeAt)}` : ""
1001
+ }`,
1002
+ );
349
1003
  return;
350
1004
  }
351
1005
  const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
@@ -364,9 +1018,22 @@ export class LoopController {
364
1018
  const loop = this.state;
365
1019
  if (!loop) return ["No loop in this session. Start one with /loop <interval> [prompt]."];
366
1020
  const lines = [
367
- `Status: ${loop.status}`,
1021
+ `Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
1022
+ ...(loop.waiting
1023
+ ? [
1024
+ `Waiting: ${loop.waiting.reason}${
1025
+ loop.waiting.resumeAt
1026
+ ? ` (wakes ${formatClock(loop.waiting.resumeAt)})`
1027
+ : " (no deadline)"
1028
+ }`,
1029
+ ]
1030
+ : []),
1031
+ ...(loop.cancelledWaitReason
1032
+ ? [`Cancelled wait (reported on the next wake): ${loop.cancelledWaitReason}`]
1033
+ : []),
368
1034
  `Interval: every ${formatDuration(loop.intervalMs)}`,
369
- `Iterations: ${loop.iteration}${loop.maxIterations === null ? " (unlimited)" : ` of ${loop.maxIterations}`}`,
1035
+ `Wakes: ${loop.iteration}${loop.maxIterations === null ? " (unlimited)" : ` of ${loop.maxIterations}`}`,
1036
+ `Automatic turns: ${loop.automaticTurns}${loop.maxAutomaticTurns === null ? " (unlimited)" : ` of ${loop.maxAutomaticTurns}`}`,
370
1037
  `Started: ${new Date(loop.startedAt).toLocaleString()}`,
371
1038
  `Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
372
1039
  `Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
@@ -374,6 +1041,24 @@ export class LoopController {
374
1041
  if (loop.objective) {
375
1042
  lines.push("Mode: standalone (this loop owns its completion criteria)");
376
1043
  lines.push(`Objective: ${loop.objective}`);
1044
+ if (this.ledger) {
1045
+ const criteria = this.criteria();
1046
+ lines.push(`Ledger: ${this.ledger.dir}`);
1047
+ if (criteria) {
1048
+ const met = criteria.filter((criterion) => criterion.passes).length;
1049
+ lines.push(`Criteria: ${met}/${criteria.length} marked passing`);
1050
+ lines.push(
1051
+ ...criteria.map(
1052
+ (criterion) =>
1053
+ ` [${criterion.passes ? "x" : " "}] ${criterion.id}. ${criterion.description}`,
1054
+ ),
1055
+ );
1056
+ } else {
1057
+ lines.push("Criteria: unreadable (the loop runs without them)");
1058
+ }
1059
+ } else {
1060
+ lines.push("Ledger: unavailable (the loop runs without one)");
1061
+ }
377
1062
  } else {
378
1063
  lines.push("Mode: goal-bound (pi-goal owns completion)");
379
1064
  }
@@ -383,7 +1068,16 @@ export class LoopController {
383
1068
  : readGoalSnapshot(ctx.sessionManager.getBranch());
384
1069
  if (goal) lines.push(`Goal (pi-goal): ${goal.status} — ${goal.text}`);
385
1070
  if (this.nextWakeAt && loop.status === "active") {
386
- lines.push(`Next wake: ${formatClock(this.nextWakeAt)}`);
1071
+ lines.push(
1072
+ `${isStandaloneLoop(loop) ? "Next fallback wake" : "Next wake"}: ${formatClock(this.nextWakeAt)}${
1073
+ this.noOpStreak > 0
1074
+ ? ` (backed off ×${Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak)} after ${this.noOpStreak} no-op wake${this.noOpStreak === 1 ? "" : "s"})`
1075
+ : ""
1076
+ }`,
1077
+ );
1078
+ }
1079
+ if (this.continuationIntent) {
1080
+ lines.push("A continuation is queued for the next idle boundary.");
387
1081
  }
388
1082
  if (this.wakePending) lines.push("A wake is pending delivery at the next idle boundary.");
389
1083
  if (this.lastDecision) {
@@ -417,7 +1111,18 @@ export class LoopController {
417
1111
  );
418
1112
  return;
419
1113
  }
420
- const expiryMs = parseDuration(this.settings.maxLoopDuration) ?? 604_800_000;
1114
+ // A standalone loop with no way to call loop_complete would work, finish,
1115
+ // and then be told to keep working until it hit a cap. Refuse at the door
1116
+ // rather than after the first turn.
1117
+ if (objective && !this.completeToolAvailable()) {
1118
+ ctx.ui.notify(
1119
+ `This session has no ${LOOP_COMPLETE_TOOL} tool, so a loop could never end itself. Re-enable it (it is excluded by --tools/--no-tools or a tool policy) and start the loop again.`,
1120
+ "error",
1121
+ );
1122
+ return;
1123
+ }
1124
+ const expiryMs =
1125
+ start.expiresInMs ?? parseDuration(this.settings.maxLoopDuration) ?? 604_800_000;
421
1126
  const compactAt =
422
1127
  start.compactAt !== undefined
423
1128
  ? start.compactAt
@@ -434,24 +1139,85 @@ export class LoopController {
434
1139
  intervalMs: start.intervalMs,
435
1140
  maxIterations:
436
1141
  start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
1142
+ maxAutomaticTurns: this.settings.automaticTurns,
437
1143
  compactAt,
438
1144
  iteration: 0,
1145
+ automaticTurns: 0,
439
1146
  startedAt: now,
440
1147
  expiresAt: now + expiryMs,
441
1148
  };
442
1149
  this.wakePending = false;
1150
+ this.continuationIntent = undefined;
1151
+ this.noOpStreak = 0;
1152
+ this.ledgerWarned = false;
1153
+ this.openLedger(this.state);
443
1154
  this.persist();
444
1155
  this.scheduleTick(start.intervalMs);
445
1156
  this.updateWidget();
446
1157
  const clampNote = start.clamped
447
1158
  ? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
448
1159
  : "";
1160
+ if (goalBound) {
1161
+ ctx.ui.notify(
1162
+ "Binding a loop to an active /goal is deprecated and will be removed. pi-loop now owns long-running work on its own: stop the goal and run /loop <interval> <objective> instead.",
1163
+ "warning",
1164
+ );
1165
+ }
449
1166
  ctx.ui.notify(
450
1167
  goalBound
451
1168
  ? `Loop started: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, poking the active goal${start.prompt ? " with the loop focus" : ""}. Stop with /loop stop.`
452
- : `Loop started: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, working its own objective until the criteria are met (loop_complete), the cap is reached, or you run /loop stop.`,
1169
+ : `Loop started: working its own 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(start.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(expiryMs)} (one final turn to write its state down, then it stops).`,
453
1170
  "info",
454
1171
  );
1172
+ if (this.ledger) {
1173
+ const criteria = this.criteria() ?? [];
1174
+ ctx.ui.notify(
1175
+ [
1176
+ `Loop ledger: ${this.ledger.dir}`,
1177
+ `Completion criteria (${criteria.length}) — loop_complete answers for these:`,
1178
+ ...criteria.map((criterion) => ` ${criterion.id}. ${criterion.description}`),
1179
+ ].join("\n"),
1180
+ "info",
1181
+ );
1182
+ }
1183
+ // The kickoff anchor: one stored message per loop holding the objective
1184
+ // data, because the system append exists only while the loop is active.
1185
+ this.sendKickoffAnchor(ctx);
1186
+ // Immediate kickoff: a standalone loop starts working now instead of
1187
+ // burning its first interval idle. A busy session keeps the intent and
1188
+ // delivers it at the settle.
1189
+ if (this.state && isStandaloneLoop(this.state)) {
1190
+ this.requestContinuation(this.state, "kickoff");
1191
+ this.dispatchContinuationIfSettled(ctx);
1192
+ }
1193
+ }
1194
+
1195
+ /**
1196
+ * Store the objective as an ordinary message so it outlives the loop.
1197
+ *
1198
+ * At an idle boundary `sendMessage` appends the message with no turn, which
1199
+ * is exactly an anchor. While the agent streams the same call would *steer*
1200
+ * the running turn, so a busy session gets `nextTurn` instead: queued as
1201
+ * context alongside the next prompt, interrupting nothing.
1202
+ */
1203
+ private sendKickoffAnchor(ctx: ExtensionContext): void {
1204
+ const loop = this.state;
1205
+ if (!loop || !isStandaloneLoop(loop) || !this.ledger) return;
1206
+ const idle = ctx.isIdle() && !ctx.hasPendingMessages();
1207
+ try {
1208
+ this.pi.sendMessage(
1209
+ {
1210
+ customType: LOOP_ANCHOR_MESSAGE_TYPE,
1211
+ content: buildKickoffAnchor(loop, this.ledger),
1212
+ display: true,
1213
+ details: { loopId: loop.id },
1214
+ },
1215
+ idle ? {} : { deliverAs: "nextTurn" },
1216
+ );
1217
+ } catch (error) {
1218
+ // The anchor is a durability nicety; the loop runs without it.
1219
+ ctx.ui.notify(`pi-loop could not anchor the objective: ${formatError(error)}.`, "warning");
1220
+ }
455
1221
  }
456
1222
 
457
1223
  pauseLoop(ctx: ExtensionContext): void {
@@ -488,14 +1254,25 @@ export class LoopController {
488
1254
  );
489
1255
  return;
490
1256
  }
491
- this.state = { ...loop, status: "active" };
1257
+ // Resuming starts a fresh safety epoch: the user has seen why it paused
1258
+ // and chosen to continue, so the breaker must not trip on stale counters.
1259
+ const { pauseCause: _cause, lastFingerprint: _fingerprint, ...rest } = loop;
1260
+ this.state = { ...rest, status: "active", toolFreeRepeatCount: 0 };
1261
+ this.noOpStreak = 0;
492
1262
  this.persist();
493
1263
  this.scheduleTick(loop.intervalMs);
494
1264
  this.updateWidget();
495
1265
  ctx.ui.notify(
496
- `Loop resumed: next wake at ${formatClock(this.now() + loop.intervalMs)}.`,
1266
+ isStandaloneLoop(loop)
1267
+ ? `Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`
1268
+ : `Loop resumed: next wake at ${formatClock(this.now() + loop.intervalMs)}.`,
497
1269
  "info",
498
1270
  );
1271
+ // Resuming a standalone loop resumes the work, not just the heartbeat.
1272
+ if (this.state && isStandaloneLoop(this.state)) {
1273
+ this.requestContinuation(this.state);
1274
+ this.dispatchContinuationIfSettled(ctx);
1275
+ }
499
1276
  }
500
1277
 
501
1278
  /** Re-arm the timer after an interval edit while active. */
@@ -525,3 +1302,22 @@ export class LoopController {
525
1302
  function formatError(error: unknown): string {
526
1303
  return error instanceof Error ? error.message : String(error);
527
1304
  }
1305
+
1306
+ /**
1307
+ * A run that called no tool did nothing to the world. It is the signal the
1308
+ * fallback backoff needs today; Stage 6's `LOOP_OK` acknowledgement refines
1309
+ * the same counter rather than replacing it.
1310
+ */
1311
+ function isNoOpRun(messages: readonly unknown[]): boolean {
1312
+ for (const message of messages) {
1313
+ if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
1314
+ continue;
1315
+ }
1316
+ if (message.content.some((block) => isRecord(block) && block.type === "toolCall")) return false;
1317
+ }
1318
+ return true;
1319
+ }
1320
+
1321
+ function isRecord(value: unknown): value is Record<string, unknown> {
1322
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1323
+ }