@hank-warren/pi-loop 0.4.0 → 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,25 +394,320 @@ 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;
145
703
  this.timer = setTimeout(() => {
146
704
  this.timer = undefined;
705
+ // Nothing is scheduled once the timer has fired. `runTick` re-arms it
706
+ // through `scheduleTick` when it pokes, but a busy or compacting
707
+ // session coalesces into `wakePending` instead — and leaving the old
708
+ // deadline here made `/loop status` report a clock time that had
709
+ // already passed.
710
+ this.nextWakeAt = undefined;
147
711
  const ctx = this.sessionCtx;
148
712
  if (!ctx) return;
149
713
  this.runTick(ctx);
@@ -179,10 +743,12 @@ export class LoopController {
179
743
  case "none":
180
744
  return;
181
745
  case "skip":
182
- if (decision.reason === "plan-mode-active") {
183
- // Plan mode may end without an agent_settled we can use, so
184
- // keep ticking until it does.
185
- 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();
186
752
  } else {
187
753
  // Busy or compacting: coalesce into one pending wake that
188
754
  // the next agent_settled (or compaction onComplete) delivers.
@@ -205,14 +771,17 @@ export class LoopController {
205
771
  ): void {
206
772
  switch (decision.action) {
207
773
  case "expire":
208
- 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)");
209
776
  return;
210
777
  case "stop":
211
778
  this.transition(
212
779
  "stopped",
213
780
  decision.reason === "goal-complete"
214
781
  ? "the goal completed"
215
- : `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`,
216
785
  );
217
786
  return;
218
787
  case "pause":
@@ -226,6 +795,46 @@ export class LoopController {
226
795
  }
227
796
  }
228
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
+
229
838
  /**
230
839
  * Send first, then account. Pi can refuse the delivery (a busy or compacting
231
840
  * session), and an iteration persisted before the send would burn the
@@ -234,29 +843,39 @@ export class LoopController {
234
843
  */
235
844
  private deliverPoke(
236
845
  env: TickEnvironment,
237
- reason: "goal-stalled" | "goal-waiting" | "objective-stalled",
846
+ reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed",
238
847
  ): void {
239
848
  const loop = this.state;
240
849
  if (!loop) return;
850
+ const standalone = reason === "objective-stalled" || reason === "wait-elapsed";
241
851
  // A goal-bound poke restates nothing, so it is only meaningful while the
242
852
  // goal it points at is readable; a standalone poke needs no goal at all.
243
- if (reason !== "objective-stalled" && !env.goal) return;
853
+ if (!standalone && !env.goal) return;
244
854
  try {
245
855
  this.pi.sendUserMessage(
246
- reason === "objective-stalled" ? buildObjectivePoke(loop) : buildGoalPoke(loop, reason),
856
+ standalone ? buildObjectivePoke(loop, reason) : buildGoalPoke(loop, reason),
247
857
  );
248
858
  } catch (error) {
249
859
  this.sessionCtx?.ui.notify(
250
860
  `pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
251
861
  "warning",
252
862
  );
253
- this.scheduleTick(loop.intervalMs);
863
+ this.armFallback();
254
864
  this.updateWidget();
255
865
  return;
256
866
  }
257
- 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
+ };
258
877
  this.persist();
259
- this.scheduleTick(this.state.intervalMs);
878
+ this.armFallback();
260
879
  this.updateWidget();
261
880
  }
262
881
 
@@ -265,9 +884,16 @@ export class LoopController {
265
884
  if (!loop || loop.status !== "active" || loop.compactAt === null) return false;
266
885
  if (!this.settings.compaction.enabled || this.compacting) return false;
267
886
  if (!ctx.isIdle() || ctx.hasPendingMessages()) return false;
268
- const usage = ctx.getContextUsage();
269
- if (!usage || typeof usage.tokens !== "number" || !usage.contextWindow) return false;
270
- 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
+ }
271
897
  const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
272
898
  this.compacting = true;
273
899
  try {
@@ -278,9 +904,11 @@ export class LoopController {
278
904
  // objective the summary must preserve.
279
905
  goal?.status === "active" ? goal : undefined,
280
906
  this.settings.compaction.instructions,
907
+ this.ledger,
281
908
  ),
282
- onComplete: () => {
909
+ onComplete: (result) => {
283
910
  this.compacting = false;
911
+ this.requestReAnchor(result);
284
912
  this.nudgeHeldWake();
285
913
  },
286
914
  onError: (error) => {
@@ -301,21 +929,41 @@ export class LoopController {
301
929
  }
302
930
 
303
931
  /**
304
- * A wake held during compaction delivers at the next settled boundary;
305
- * 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.
306
950
  */
307
951
  private nudgeHeldWake(): void {
308
952
  const ctx = this.sessionCtx;
309
- if (ctx && this.wakePending) this.onAgentSettled(ctx);
953
+ if (ctx && (this.wakePending || this.continuationIntent)) this.onAgentSettled(ctx);
310
954
  }
311
955
 
312
956
  // --- state transitions & presentation ---
313
957
 
314
- private transition(status: "paused" | "stopped", why: string): void {
958
+ private transition(status: "paused" | "stopped", why: string, cause?: string): void {
315
959
  if (!this.state) return;
316
- this.state = { ...this.state, status };
960
+ const { waiting: _waiting, pauseCause: _pauseCause, ...rest } = this.state;
961
+ this.state = { ...rest, status, ...(cause ? { pauseCause: cause } : {}) };
317
962
  this.clearTimer();
963
+ this.waitTimer.clear();
318
964
  this.wakePending = false;
965
+ this.continuationIntent = undefined;
966
+ this.runOrigin = undefined;
319
967
  this.persist();
320
968
  this.sessionCtx?.ui.notify(`Loop ${status}: ${why}.`, "info");
321
969
  this.updateWidget();
@@ -339,7 +987,19 @@ export class LoopController {
339
987
  return;
340
988
  }
341
989
  if (loop.status === "paused") {
342
- 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
+ );
343
1003
  return;
344
1004
  }
345
1005
  const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
@@ -358,9 +1018,22 @@ export class LoopController {
358
1018
  const loop = this.state;
359
1019
  if (!loop) return ["No loop in this session. Start one with /loop <interval> [prompt]."];
360
1020
  const lines = [
361
- `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
+ : []),
362
1034
  `Interval: every ${formatDuration(loop.intervalMs)}`,
363
- `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}`}`,
364
1037
  `Started: ${new Date(loop.startedAt).toLocaleString()}`,
365
1038
  `Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
366
1039
  `Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
@@ -368,6 +1041,24 @@ export class LoopController {
368
1041
  if (loop.objective) {
369
1042
  lines.push("Mode: standalone (this loop owns its completion criteria)");
370
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
+ }
371
1062
  } else {
372
1063
  lines.push("Mode: goal-bound (pi-goal owns completion)");
373
1064
  }
@@ -377,7 +1068,16 @@ export class LoopController {
377
1068
  : readGoalSnapshot(ctx.sessionManager.getBranch());
378
1069
  if (goal) lines.push(`Goal (pi-goal): ${goal.status} — ${goal.text}`);
379
1070
  if (this.nextWakeAt && loop.status === "active") {
380
- 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.");
381
1081
  }
382
1082
  if (this.wakePending) lines.push("A wake is pending delivery at the next idle boundary.");
383
1083
  if (this.lastDecision) {
@@ -411,7 +1111,18 @@ export class LoopController {
411
1111
  );
412
1112
  return;
413
1113
  }
414
- 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;
415
1126
  const compactAt =
416
1127
  start.compactAt !== undefined
417
1128
  ? start.compactAt
@@ -428,24 +1139,85 @@ export class LoopController {
428
1139
  intervalMs: start.intervalMs,
429
1140
  maxIterations:
430
1141
  start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
1142
+ maxAutomaticTurns: this.settings.automaticTurns,
431
1143
  compactAt,
432
1144
  iteration: 0,
1145
+ automaticTurns: 0,
433
1146
  startedAt: now,
434
1147
  expiresAt: now + expiryMs,
435
1148
  };
436
1149
  this.wakePending = false;
1150
+ this.continuationIntent = undefined;
1151
+ this.noOpStreak = 0;
1152
+ this.ledgerWarned = false;
1153
+ this.openLedger(this.state);
437
1154
  this.persist();
438
1155
  this.scheduleTick(start.intervalMs);
439
1156
  this.updateWidget();
440
1157
  const clampNote = start.clamped
441
1158
  ? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
442
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
+ }
443
1166
  ctx.ui.notify(
444
1167
  goalBound
445
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.`
446
- : `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).`,
447
1170
  "info",
448
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
+ }
449
1221
  }
450
1222
 
451
1223
  pauseLoop(ctx: ExtensionContext): void {
@@ -482,14 +1254,25 @@ export class LoopController {
482
1254
  );
483
1255
  return;
484
1256
  }
485
- 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;
486
1262
  this.persist();
487
1263
  this.scheduleTick(loop.intervalMs);
488
1264
  this.updateWidget();
489
1265
  ctx.ui.notify(
490
- `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)}.`,
491
1269
  "info",
492
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
+ }
493
1276
  }
494
1277
 
495
1278
  /** Re-arm the timer after an interval edit while active. */
@@ -519,3 +1302,22 @@ export class LoopController {
519
1302
  function formatError(error: unknown): string {
520
1303
  return error instanceof Error ? error.message : String(error);
521
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
+ }