@hank-warren/pi-loop 0.4.1 → 0.6.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,33 +3,54 @@
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: an agent_end records a
7
+ * continuation *intent*, and the next fully settled boundary dispatches it.
8
+ * The interval is a fallback heartbeat, re-armed from the last settle, that
9
+ * fires only when the session has been idle a whole interval with the
10
+ * objective unfinished (a lost continuation, or an external wait).
11
+ * - Timers are armed in session_start, a settle, or a command handler, never
12
+ * the factory, and cleared in an idempotent session_shutdown.
8
13
  * - Pokes deliver only at a fully idle boundary; a tick that lands while the
9
14
  * agent is busy coalesces into a single pending wake delivered at the next
10
- * agent_settled. Missed ticks never stack.
11
- * - Loops require an active pi-goal goal to operate: pi-goal owns "whether
12
- * the work is done". Its safety states pause the loop, its completion stops
13
- * it, and a missing goal pauses the loop. Coupling is read-only session
14
- * entries.
15
- * - Terminal decisions (expiry, completion, safety) also land at a settled
16
- * boundary, so the loop settles as soon as the goal does; only the timer
17
- * ever pokes.
15
+ * agent_settled. Missed ticks never stack, and a continuation supersedes a
16
+ * coalesced wake rather than delivering both.
17
+ * - A loop owns "whether the work is done" itself: it ends through
18
+ * `loop_complete`, a cap, its expiry, or the user, and reads no other
19
+ * extension's state to decide that.
20
+ * - Terminal decisions (expiry, caps) also land at a settled boundary, so the
21
+ * loop settles as soon as the work does; only the timer ever pokes.
18
22
  * - The loop's proactive compaction is the normal compaction path; Pi's
19
- * reserve-token auto-compaction is the fault handler. pi-goal owns the
20
- * post-compaction re-prompt: the loop sends none of its own.
23
+ * reserve-token auto-compaction is the fault handler. The loop owns the
24
+ * post-compaction re-anchor.
21
25
  */
22
26
 
23
27
  import { randomUUID } from "node:crypto";
24
- import type {
25
- ExtensionAPI,
26
- ExtensionCommandContext,
27
- ExtensionContext,
28
- } from "@earendil-works/pi-coding-agent";
28
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
29
29
  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";
30
+ import {
31
+ type ContinuationDecision,
32
+ decideContinuation,
33
+ decideTick,
34
+ type TickDecision,
35
+ type TickEnvironment,
36
+ } from "./decide.js";
37
+ import { formatClock, formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
38
+ import {
39
+ createLedger,
40
+ deriveCriteria,
41
+ type LedgerPaths,
42
+ ledgerPaths,
43
+ readCriteria,
44
+ } from "./ledger.js";
45
+ import {
46
+ buildCompactionInstructions,
47
+ buildContinuation,
48
+ buildExpiryWake,
49
+ buildKickoffAnchor,
50
+ buildObjectivePoke,
51
+ type ContinuationKind,
52
+ extractNextActions,
53
+ } from "./messages.js";
33
54
  import {
34
55
  DEFAULT_LOOP_SETTINGS,
35
56
  type LoopSettings,
@@ -37,20 +58,66 @@ import {
37
58
  readLoopSettings,
38
59
  } from "./settings.js";
39
60
  import {
40
- isStandaloneLoop,
41
61
  LOOP_STATE_ENTRY_TYPE,
42
62
  type LoopState,
43
- readGoalSnapshot,
44
63
  readPlanModeEnabled,
45
64
  restoreLoopState,
46
65
  } from "./state.js";
66
+ import { isLoopOkAck } from "./ack.js";
67
+ import { calledTool, hasAssistantToolCall, nextNoProgressState } from "./safety.js";
68
+ import { classifyInterruption } from "./errors.js";
69
+ import { LOOP_WAIT_TOOL } from "./wait-tool.js";
70
+ import {
71
+ createLoopWait,
72
+ type LoopWait,
73
+ LoopWaitTimer,
74
+ resolveWaitDelay,
75
+ type ResolvedWaitDelay,
76
+ } from "./wait.js";
77
+ import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
47
78
  import { clearLoopWidget, updateLoopWidget } from "./widget.js";
48
79
 
49
80
  export const LOOP_STATUS_KEY = "loop";
50
81
 
82
+ /** Custom message type of the kickoff anchor. */
83
+ export const LOOP_ANCHOR_MESSAGE_TYPE = "loop-objective";
84
+
85
+ /**
86
+ * A fallback wake that produces a no-op turn doubles the next fallback delay,
87
+ * capped here. Deterministic and small: the point is to stop re-waking a loop
88
+ * that has nothing to do, not to invent an adaptive scheduler.
89
+ */
90
+ export const MAX_FALLBACK_BACKOFF = 4;
91
+
92
+ /** Consecutive loop deliveries that produce no run before the loop pauses. */
93
+ export const MAX_DEAD_DELIVERIES = 3;
94
+
95
+ /** Why the loop caused the run that is currently in flight. */
96
+ type RunOrigin = "continuation" | "fallback";
97
+
98
+ /**
99
+ * The outcome of a start attempt.
100
+ *
101
+ * `startLoop` used to report its refusals by calling `ctx.ui.notify` itself,
102
+ * which tied the only start path to a UI. Two callers now share that path —
103
+ * the `/loop` command and the `loop_start` tool — and the tool has to turn
104
+ * the same refusal into tool content rather than a toast, so the decision is
105
+ * returned and each caller renders it.
106
+ */
107
+ export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
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,57 +181,505 @@ 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 (this.state.objective === undefined && this.adoptLegacyObjective(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 loop is the signal that paces it:
215
+ * record the *intent* to continue here and let the settled boundary decide
216
+ * whether it may be delivered. Recording at agent_end (not at settle) is
217
+ * what makes the intent survive Pi's own retries and auto-compaction, which
218
+ * 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") 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.
386
+ this.armFallback();
119
387
  }
120
388
 
121
389
  /**
122
390
  * A settled boundary with no wake pending still evaluates the terminal
123
- * decisions — expiry, a completed or missing goal, a pi-goal safety state
124
- * so the loop settles the moment the goal does instead of up to one interval
125
- * later. Poke and skip decisions are deliberately ignored here: only the
126
- * timer pokes, and settling is not a schedule.
391
+ * decisions — expiry and the caps so the loop settles the moment the work
392
+ * does instead of up to one interval later. Poke and skip decisions are
393
+ * deliberately ignored here: only the timer pokes, and settling is not a
394
+ * schedule.
127
395
  */
128
- private settleTerminalState(ctx: ExtensionContext): void {
396
+ private settleTerminalState(ctx: ExtensionContext): boolean {
129
397
  const loop = this.state;
130
- if (!loop) return;
398
+ if (!loop) return false;
131
399
  const env = this.gatherEnvironment(ctx);
132
400
  const decision = decideTick(loop, env);
133
- if (decision.action !== "expire" && decision.action !== "stop" && decision.action !== "pause") {
134
- return;
135
- }
401
+ if (decision.action !== "expire" && decision.action !== "stop") return false;
136
402
  this.lastDecision = { ...decision, at: env.now };
137
403
  this.applyTerminalDecision(loop, decision);
404
+ return true;
405
+ }
406
+
407
+ /**
408
+ * The restore shim for a loop persisted before 0.6.0.
409
+ *
410
+ * Such a loop may carry no objective of its own: it delegated "is the work
411
+ * done" to a goal in another extension that no longer exists here. The only
412
+ * case that can still reach this code is a session persisted before 0.6.0
413
+ * and resumed after it, having never been restored under 0.5.0 — where it
414
+ * would already have been converted.
415
+ *
416
+ * Its focus text, when it has one, is the closest thing to an objective it
417
+ * has, so adopt that. With nothing to adopt there is no honest way to run
418
+ * it, so it pauses and says so.
419
+ *
420
+ * Returns true when the loop was paused and needs no timer.
421
+ */
422
+ private adoptLegacyObjective(ctx: ExtensionContext): boolean {
423
+ const loop = this.state;
424
+ if (!loop) return true;
425
+ const objective = loop.prompt;
426
+ if (!objective) {
427
+ this.transition(
428
+ "paused",
429
+ "it was bound to a goal that is gone and has no objective of its own; start a new loop with /loop <interval> <objective>",
430
+ "loop with no objective",
431
+ );
432
+ return true;
433
+ }
434
+ const { prompt: _prompt, ...rest } = loop;
435
+ this.state = { ...rest, objective };
436
+ this.persist();
437
+ ctx.ui.notify(
438
+ `This loop predates pi-loop owning its own objective; it now works its focus text directly: ${objective}`,
439
+ "info",
440
+ );
441
+ return false;
442
+ }
443
+
444
+ /**
445
+ * A loop that cannot call `loop_complete` cannot end itself: it will work,
446
+ * finish, and then be told to keep working until it hits a cap.
447
+ * That happens whenever the tool set is restricted (`--tools`, `--no-tools`,
448
+ * a policy that drops extension tools), and it is invisible from inside the
449
+ * loop — so check the live tool set and pause instead of spinning.
450
+ *
451
+ * Returns true when the loop was paused.
452
+ */
453
+ private enforceToolAvailability(ctx: ExtensionContext): boolean {
454
+ const loop = this.state;
455
+ if (!loop || loop.status !== "active") return false;
456
+ if (this.completeToolAvailable()) return false;
457
+ this.transition(
458
+ "paused",
459
+ `the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then /loop resume`,
460
+ "loop_complete unavailable",
461
+ );
462
+ void ctx;
463
+ return true;
464
+ }
465
+
466
+ private completeToolAvailable(): boolean {
467
+ const getActiveTools = (this.pi as { getActiveTools?: () => string[] }).getActiveTools;
468
+ if (typeof getActiveTools !== "function") return true;
469
+ try {
470
+ // Fail open: a host that cannot report its tools is not evidence that
471
+ // the tool is missing.
472
+ return getActiveTools.call(this.pi).includes(LOOP_COMPLETE_TOOL);
473
+ } catch {
474
+ return true;
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Count a delivery that never became a run, and pause once the loop is
480
+ * plainly shouting into a void. Without this a session whose provider
481
+ * refuses every request (no API key, a revoked token) keeps waking on the
482
+ * heartbeat until it exhausts its wake cap, because a run that fails before
483
+ * the first token never reaches agent_end and so is never classified.
484
+ *
485
+ * Returns true when the loop was paused.
486
+ */
487
+ private noteDelivery(): boolean {
488
+ if (this.awaitingRun) this.deadDeliveries += 1;
489
+ this.awaitingRun = true;
490
+ if (this.deadDeliveries < MAX_DEAD_DELIVERIES) return false;
491
+ this.transition(
492
+ "paused",
493
+ `${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then /loop resume`,
494
+ "deliveries produce no turns",
495
+ );
496
+ return true;
497
+ }
498
+
499
+ // --- loop_wait ---
500
+
501
+ /**
502
+ * Enter an external wait. The pacemaker is deliberately left armed: a wait
503
+ * supersedes the *next* fallback wake, it does not disable the heartbeat,
504
+ * so a wait whose event never arrives still ends in a wake rather than in
505
+ * silence.
506
+ */
507
+ enterWait(reason: string, resumeAfterMs: number | undefined): ResolvedWaitDelay | undefined {
508
+ const loop = this.state;
509
+ if (!loop || loop.status !== "active") return undefined;
510
+ const resolved = resolveWaitDelay(resumeAfterMs);
511
+ const waiting = createLoopWait(reason, resumeAfterMs, this.now());
512
+ // The wait replaces any continuation already recorded for this turn.
513
+ this.continuationIntent = undefined;
514
+ const { cancelledWaitReason: _cancelled, ...rest } = loop;
515
+ this.state = { ...rest, waiting };
516
+ this.persist();
517
+ this.restoreWaitTimer();
518
+ this.updateWidget();
519
+ return resolved;
520
+ }
521
+
522
+ /**
523
+ * Drop a wait that something other than its own deadline ended, keeping its
524
+ * reason as a one-shot hint for the next loop message. There is no cancel
525
+ * tool: the events that legitimately cancel a wait are not the model's to
526
+ * report.
527
+ */
528
+ private cancelWait(_why: string): void {
529
+ const loop = this.state;
530
+ if (!loop?.waiting) return;
531
+ const { waiting, ...rest } = loop;
532
+ this.waitTimer.clear();
533
+ this.state = { ...rest, cancelledWaitReason: waiting.reason };
534
+ this.persist();
535
+ this.updateWidget();
536
+ }
537
+
538
+ /** Re-arm the wait deadline, including after a restore or a compaction. */
539
+ private restoreWaitTimer(): void {
540
+ this.waitTimer.clear();
541
+ const loop = this.state;
542
+ const resumeAt = loop?.status === "active" ? loop.waiting?.resumeAt : undefined;
543
+ if (resumeAt === undefined) return;
544
+ const loopId = loop?.id;
545
+ this.waitTimer.schedule(
546
+ resumeAt,
547
+ () => {
548
+ const ctx = this.sessionCtx;
549
+ if (!ctx || this.state?.id !== loopId) return;
550
+ try {
551
+ this.runTick(ctx);
552
+ } catch (error) {
553
+ ctx.ui.notify(`pi-loop wait deadline failed: ${formatError(error)}`, "warning");
554
+ }
555
+ },
556
+ this.now(),
557
+ );
558
+ }
559
+
560
+ /** The wait a delivered wake consumed, cleared as the wake goes out. */
561
+ private consumeWait(loop: LoopState): LoopState {
562
+ const { waiting: _waiting, cancelledWaitReason: _cancelled, ...rest } = loop;
563
+ this.waitTimer.clear();
564
+ return rest;
565
+ }
566
+
567
+ // --- ledger ---
568
+
569
+ /**
570
+ * Create (or adopt) the loop's ledger. Best-effort by design: a loop with
571
+ * no writable ledger still runs, it just loses the durable record, so the
572
+ * failure is warned once and never repeated.
573
+ */
574
+ private openLedger(loop: LoopState): void {
575
+ if (loop.objective === undefined) {
576
+ this.ledger = undefined;
577
+ return;
578
+ }
579
+ const paths = ledgerPaths(loop.id, this.agentDir);
580
+ const failure = createLedger(paths, loop.objective, deriveCriteria(loop.objective));
581
+ if (failure) {
582
+ this.ledger = undefined;
583
+ if (!this.ledgerWarned) {
584
+ this.ledgerWarned = true;
585
+ this.sessionCtx?.ui.notify(
586
+ `pi-loop could not write its ledger (${failure}). The loop runs without one.`,
587
+ "warning",
588
+ );
589
+ }
590
+ return;
591
+ }
592
+ this.ledger = paths;
593
+ }
594
+
595
+ /** The loop's criteria as last written to disk, fail-open. */
596
+ criteria() {
597
+ return this.ledger ? readCriteria(this.ledger) : undefined;
598
+ }
599
+
600
+ // --- settle-driven continuation ---
601
+
602
+ /** Record the intent to continue; the settled boundary decides delivery. */
603
+ private requestContinuation(
604
+ loop: LoopState,
605
+ kind: ContinuationKind = "continue",
606
+ nextActions?: string,
607
+ ): void {
608
+ // A re-anchor outranks an ordinary continuation already queued: after a
609
+ // compaction, "re-read the ledger" is strictly the better instruction.
610
+ if (this.continuationIntent?.loopId === loop.id && kind !== "reanchor") return;
611
+ this.continuationIntent = { loopId: loop.id, kind, ...(nextActions ? { nextActions } : {}) };
612
+ }
613
+
614
+ /**
615
+ * Deliver a recorded continuation, but only at a boundary where Pi will
616
+ * actually accept it. A skip leaves the intent in place for the next
617
+ * settle; a terminal decision consumes it.
618
+ */
619
+ dispatchContinuationIfSettled(ctx: ExtensionContext): boolean {
620
+ const intent = this.continuationIntent;
621
+ if (!intent) return false;
622
+ const loop = this.state;
623
+ if (!loop || loop.id !== intent.loopId) {
624
+ this.continuationIntent = undefined;
625
+ return false;
626
+ }
627
+ const env = this.gatherEnvironment(ctx);
628
+ const decision = decideContinuation(loop, env);
629
+ this.lastContinuation = { ...decision, at: env.now };
630
+ if (decision.action === "none") {
631
+ this.continuationIntent = undefined;
632
+ return false;
633
+ }
634
+ if (decision.action === "skip") return false;
635
+ if (decision.action !== "continue") {
636
+ this.continuationIntent = undefined;
637
+ this.lastDecision = { ...decision, at: env.now };
638
+ this.applyTerminalDecision(loop, decision);
639
+ return true;
640
+ }
641
+ try {
642
+ this.pi.sendUserMessage(buildContinuation(loop, intent.kind, intent.nextActions));
643
+ } catch (error) {
644
+ // Keep the intent: the next settle retries it, and the fallback
645
+ // heartbeat covers the case where no further settle arrives.
646
+ this.sessionCtx?.ui.notify(
647
+ `pi-loop could not continue the loop: ${formatError(error)}. Retrying at the next idle boundary.`,
648
+ "warning",
649
+ );
650
+ this.armFallback();
651
+ this.updateWidget();
652
+ return false;
653
+ }
654
+ this.continuationIntent = undefined;
655
+ this.runOrigin = "continuation";
656
+ if (this.noteDelivery()) return true;
657
+ // A continuation is the work the coalesced wake would have asked for.
658
+ this.wakePending = false;
659
+ this.state = { ...loop, automaticTurns: loop.automaticTurns + 1 };
660
+ this.persist();
661
+ this.armFallback();
662
+ this.updateWidget();
663
+ return true;
138
664
  }
139
665
 
140
666
  // --- tick machinery ---
141
667
 
668
+ /**
669
+ * Arm the fallback heartbeat, backing off while consecutive fallback wakes
670
+ * keep producing no-op turns.
671
+ */
672
+ private armFallback(): void {
673
+ const loop = this.state;
674
+ if (!loop || loop.status !== "active") return;
675
+ this.scheduleTick(this.fallbackDelayMs(loop));
676
+ }
677
+
678
+ fallbackDelayMs(loop: LoopState): number {
679
+ const multiplier = Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak);
680
+ return Math.min(MAX_INTERVAL_MS, loop.intervalMs * multiplier);
681
+ }
682
+
142
683
  private scheduleTick(delayMs: number): void {
143
684
  this.clearTimer();
144
685
  this.nextWakeAt = this.now() + delayMs;
@@ -171,7 +712,6 @@ export class LoopController {
171
712
  busy: !ctx.isIdle() || ctx.hasPendingMessages(),
172
713
  compacting: this.compacting,
173
714
  planModeEnabled: readPlanModeEnabled(branch),
174
- goal: readGoalSnapshot(branch),
175
715
  };
176
716
  }
177
717
 
@@ -185,10 +725,12 @@ export class LoopController {
185
725
  case "none":
186
726
  return;
187
727
  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);
728
+ if (decision.reason === "plan-mode-active" || decision.reason === "loop-waiting") {
729
+ // Plan mode may end without an agent_settled we can use, and a
730
+ // wait supersedes only this one fallback wake — in both cases
731
+ // keep the heartbeat armed rather than coalescing a wake that
732
+ // would fire the moment the hold ends.
733
+ this.armFallback();
192
734
  } else {
193
735
  // Busy or compacting: coalesce into one pending wake that
194
736
  // the next agent_settled (or compaction onComplete) delivers.
@@ -197,7 +739,7 @@ export class LoopController {
197
739
  this.updateWidget();
198
740
  return;
199
741
  case "poke":
200
- this.deliverPoke(env, decision.reason);
742
+ this.deliverPoke(env.now, decision.reason);
201
743
  return;
202
744
  default:
203
745
  this.applyTerminalDecision(loop, decision);
@@ -207,62 +749,96 @@ export class LoopController {
207
749
 
208
750
  private applyTerminalDecision(
209
751
  loop: LoopState,
210
- decision: Extract<TickDecision, { action: "expire" | "stop" | "pause" }>,
752
+ decision: Extract<TickDecision, { action: "expire" | "stop" }>,
211
753
  ): void {
212
754
  switch (decision.action) {
213
755
  case "expire":
214
- this.transition("stopped", "loop expired (maxLoopDuration reached)");
756
+ if (decision.reason === "expiry-final-wake" && this.deliverExpiryWake(loop)) return;
757
+ this.transition("stopped", "loop expired (the expiry was reached)");
215
758
  return;
216
759
  case "stop":
217
760
  this.transition(
218
761
  "stopped",
219
- decision.reason === "goal-complete"
220
- ? "the goal completed"
762
+ decision.reason === "max-automatic-turns"
763
+ ? `the ${loop.maxAutomaticTurns}-automatic-turn cap was reached`
221
764
  : `the ${loop.maxIterations}-iteration cap was reached`,
222
765
  );
223
766
  return;
224
- case "pause":
225
- this.transition(
226
- "paused",
227
- decision.reason === "goal-missing"
228
- ? "loops require an active goal; start one with /goal <objective>, then /loop resume"
229
- : `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
230
- );
231
- return;
232
767
  }
233
768
  }
234
769
 
770
+ /**
771
+ * One last turn at expiry, so the loop's most recent state lands in the
772
+ * ledger instead of only in a conversation that is about to be closed. The
773
+ * loop stays active for exactly that turn — the objective append has to be
774
+ * present while it writes — and `expiring` makes the next settle stop it.
775
+ *
776
+ * Returns false when the wake could not be delivered, in which case the
777
+ * caller stops the loop immediately rather than leaving it alive past its
778
+ * expiry waiting for a turn that will not happen.
779
+ */
780
+ private deliverExpiryWake(loop: LoopState): boolean {
781
+ if (loop.expiring) return false;
782
+ try {
783
+ this.pi.sendUserMessage(buildExpiryWake(loop, this.ledger));
784
+ } catch (error) {
785
+ this.sessionCtx?.ui.notify(
786
+ `pi-loop could not deliver the expiry wake: ${formatError(error)}. Stopping the loop.`,
787
+ "warning",
788
+ );
789
+ return false;
790
+ }
791
+ this.runOrigin = "fallback";
792
+ this.continuationIntent = undefined;
793
+ this.state = {
794
+ ...this.consumeWait(loop),
795
+ iteration: loop.iteration + 1,
796
+ automaticTurns: loop.automaticTurns + 1,
797
+ lastWakeAt: this.now(),
798
+ expiring: true,
799
+ };
800
+ this.clearTimer();
801
+ this.persist();
802
+ this.updateWidget();
803
+ this.sessionCtx?.ui.notify(
804
+ "Loop expired: one final turn to write the current state down, then it stops.",
805
+ "info",
806
+ );
807
+ return true;
808
+ }
809
+
235
810
  /**
236
811
  * Send first, then account. Pi can refuse the delivery (a busy or compacting
237
812
  * session), and an iteration persisted before the send would burn the
238
813
  * maxIterations cap on a poke that never arrived; on a throw the loop re-arms
239
814
  * on the same cadence and retries at the next wake.
240
815
  */
241
- private deliverPoke(
242
- env: TickEnvironment,
243
- reason: "goal-stalled" | "goal-waiting" | "objective-stalled",
244
- ): void {
816
+ private deliverPoke(now: number, reason: "objective-stalled" | "wait-elapsed"): void {
245
817
  const loop = this.state;
246
818
  if (!loop) return;
247
- // A goal-bound poke restates nothing, so it is only meaningful while the
248
- // goal it points at is readable; a standalone poke needs no goal at all.
249
- if (reason !== "objective-stalled" && !env.goal) return;
250
819
  try {
251
- this.pi.sendUserMessage(
252
- reason === "objective-stalled" ? buildObjectivePoke(loop) : buildGoalPoke(loop, reason),
253
- );
820
+ this.pi.sendUserMessage(buildObjectivePoke(loop, reason));
254
821
  } catch (error) {
255
822
  this.sessionCtx?.ui.notify(
256
823
  `pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
257
824
  "warning",
258
825
  );
259
- this.scheduleTick(loop.intervalMs);
826
+ this.armFallback();
260
827
  this.updateWidget();
261
828
  return;
262
829
  }
263
- this.state = { ...loop, iteration: loop.iteration + 1, lastWakeAt: env.now };
830
+ this.runOrigin = "fallback";
831
+ if (this.noteDelivery()) return;
832
+ // The wake consumed the wait it was arranged for, and any one-shot
833
+ // cancelled-wait hint it just carried.
834
+ this.state = {
835
+ ...this.consumeWait(loop),
836
+ iteration: loop.iteration + 1,
837
+ automaticTurns: loop.automaticTurns + 1,
838
+ lastWakeAt: now,
839
+ };
264
840
  this.persist();
265
- this.scheduleTick(this.state.intervalMs);
841
+ this.armFallback();
266
842
  this.updateWidget();
267
843
  }
268
844
 
@@ -271,22 +847,27 @@ export class LoopController {
271
847
  if (!loop || loop.status !== "active" || loop.compactAt === null) return false;
272
848
  if (!this.settings.compaction.enabled || this.compacting) return false;
273
849
  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;
277
- const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
850
+ // A turn that overflowed the context window compacts regardless of the
851
+ // threshold: the usage reading that would gate it is exactly the reading
852
+ // the failed request just disproved.
853
+ const requested = this.compactionRequested;
854
+ this.compactionRequested = false;
855
+ if (!requested) {
856
+ const usage = ctx.getContextUsage();
857
+ if (!usage || typeof usage.tokens !== "number" || !usage.contextWindow) return false;
858
+ if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
859
+ }
278
860
  this.compacting = true;
279
861
  try {
280
862
  ctx.compact({
281
863
  customInstructions: buildCompactionInstructions(
282
864
  loop,
283
- // A completed or otherwise finished goal is no longer the
284
- // objective the summary must preserve.
285
- goal?.status === "active" ? goal : undefined,
286
865
  this.settings.compaction.instructions,
866
+ this.ledger,
287
867
  ),
288
- onComplete: () => {
868
+ onComplete: (result) => {
289
869
  this.compacting = false;
870
+ this.requestReAnchor(result);
290
871
  this.nudgeHeldWake();
291
872
  },
292
873
  onError: (error) => {
@@ -307,21 +888,41 @@ export class LoopController {
307
888
  }
308
889
 
309
890
  /**
310
- * A wake held during compaction delivers at the next settled boundary;
311
- * nudge in case that boundary already passed while compaction ran.
891
+ * Own the post-compaction re-anchor instead of leaving the loop silent
892
+ * until the next fallback wake. One pointer-sized continuation at the next
893
+ * settle: the objective is in the system append, the record is in the
894
+ * ledger, and the next actions ride out of the summary that just replaced
895
+ * the conversation.
896
+ */
897
+ private requestReAnchor(result: unknown): void {
898
+ const loop = this.state;
899
+ if (!loop || loop.status !== "active") return;
900
+ const summary =
901
+ isRecord(result) && typeof result.summary === "string" ? result.summary : undefined;
902
+ this.requestContinuation(loop, "reanchor", summary ? extractNextActions(summary) : undefined);
903
+ }
904
+
905
+ /**
906
+ * A wake or continuation held during compaction delivers at the next
907
+ * settled boundary; nudge in case that boundary already passed while
908
+ * compaction ran.
312
909
  */
313
910
  private nudgeHeldWake(): void {
314
911
  const ctx = this.sessionCtx;
315
- if (ctx && this.wakePending) this.onAgentSettled(ctx);
912
+ if (ctx && (this.wakePending || this.continuationIntent)) this.onAgentSettled(ctx);
316
913
  }
317
914
 
318
915
  // --- state transitions & presentation ---
319
916
 
320
- private transition(status: "paused" | "stopped", why: string): void {
917
+ private transition(status: "paused" | "stopped", why: string, cause?: string): void {
321
918
  if (!this.state) return;
322
- this.state = { ...this.state, status };
919
+ const { waiting: _waiting, pauseCause: _pauseCause, ...rest } = this.state;
920
+ this.state = { ...rest, status, ...(cause ? { pauseCause: cause } : {}) };
323
921
  this.clearTimer();
922
+ this.waitTimer.clear();
324
923
  this.wakePending = false;
924
+ this.continuationIntent = undefined;
925
+ this.runOrigin = undefined;
325
926
  this.persist();
326
927
  this.sessionCtx?.ui.notify(`Loop ${status}: ${why}.`, "info");
327
928
  this.updateWidget();
@@ -345,7 +946,19 @@ export class LoopController {
345
946
  return;
346
947
  }
347
948
  if (loop.status === "paused") {
348
- ui.setStatus(LOOP_STATUS_KEY, "loop paused");
949
+ ui.setStatus(
950
+ LOOP_STATUS_KEY,
951
+ loop.pauseCause ? `loop paused · ${loop.pauseCause}` : "loop paused",
952
+ );
953
+ return;
954
+ }
955
+ if (loop.waiting) {
956
+ ui.setStatus(
957
+ LOOP_STATUS_KEY,
958
+ `loop waiting · ${loop.waiting.reason}${
959
+ loop.waiting.resumeAt ? ` · until ${formatClock(loop.waiting.resumeAt)}` : ""
960
+ }`,
961
+ );
349
962
  return;
350
963
  }
351
964
  const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
@@ -364,26 +977,59 @@ export class LoopController {
364
977
  const loop = this.state;
365
978
  if (!loop) return ["No loop in this session. Start one with /loop <interval> [prompt]."];
366
979
  const lines = [
367
- `Status: ${loop.status}`,
980
+ `Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
981
+ ...(loop.waiting
982
+ ? [
983
+ `Waiting: ${loop.waiting.reason}${
984
+ loop.waiting.resumeAt
985
+ ? ` (wakes ${formatClock(loop.waiting.resumeAt)})`
986
+ : " (no deadline)"
987
+ }`,
988
+ ]
989
+ : []),
990
+ ...(loop.cancelledWaitReason
991
+ ? [`Cancelled wait (reported on the next wake): ${loop.cancelledWaitReason}`]
992
+ : []),
368
993
  `Interval: every ${formatDuration(loop.intervalMs)}`,
369
- `Iterations: ${loop.iteration}${loop.maxIterations === null ? " (unlimited)" : ` of ${loop.maxIterations}`}`,
994
+ `Wakes: ${loop.iteration}${loop.maxIterations === null ? " (unlimited)" : ` of ${loop.maxIterations}`}`,
995
+ `Automatic turns: ${loop.automaticTurns}${loop.maxAutomaticTurns === null ? " (unlimited)" : ` of ${loop.maxAutomaticTurns}`}`,
370
996
  `Started: ${new Date(loop.startedAt).toLocaleString()}`,
371
997
  `Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
372
998
  `Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
373
999
  ];
374
1000
  if (loop.objective) {
375
- lines.push("Mode: standalone (this loop owns its completion criteria)");
376
1001
  lines.push(`Objective: ${loop.objective}`);
377
- } else {
378
- lines.push("Mode: goal-bound (pi-goal owns completion)");
1002
+ if (this.ledger) {
1003
+ const criteria = this.criteria();
1004
+ lines.push(`Ledger: ${this.ledger.dir}`);
1005
+ if (criteria) {
1006
+ const met = criteria.filter((criterion) => criterion.passes).length;
1007
+ lines.push(`Criteria: ${met}/${criteria.length} marked passing`);
1008
+ lines.push(
1009
+ ...criteria.map(
1010
+ (criterion) =>
1011
+ ` [${criterion.passes ? "x" : " "}] ${criterion.id}. ${criterion.description}`,
1012
+ ),
1013
+ );
1014
+ } else {
1015
+ lines.push("Criteria: unreadable (the loop runs without them)");
1016
+ }
1017
+ } else {
1018
+ lines.push("Ledger: unavailable (the loop runs without one)");
1019
+ }
379
1020
  }
380
1021
  if (loop.prompt) lines.push(`Focus: ${loop.prompt}`);
381
- const goal = isStandaloneLoop(loop)
382
- ? undefined
383
- : readGoalSnapshot(ctx.sessionManager.getBranch());
384
- if (goal) lines.push(`Goal (pi-goal): ${goal.status} — ${goal.text}`);
385
1022
  if (this.nextWakeAt && loop.status === "active") {
386
- lines.push(`Next wake: ${formatClock(this.nextWakeAt)}`);
1023
+ lines.push(
1024
+ `Next fallback wake: ${formatClock(this.nextWakeAt)}${
1025
+ this.noOpStreak > 0
1026
+ ? ` (backed off ×${Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak)} after ${this.noOpStreak} no-op wake${this.noOpStreak === 1 ? "" : "s"})`
1027
+ : ""
1028
+ }`,
1029
+ );
1030
+ }
1031
+ if (this.continuationIntent) {
1032
+ lines.push("A continuation is queued for the next idle boundary.");
387
1033
  }
388
1034
  if (this.wakePending) lines.push("A wake is pending delivery at the next idle boundary.");
389
1035
  if (this.lastDecision) {
@@ -396,50 +1042,58 @@ export class LoopController {
396
1042
  // --- command actions ---
397
1043
 
398
1044
  /**
399
- * Mode selection, and the only place it happens.
400
- *
401
- * An active pi-goal goal wins: a bare or focused `/loop` alongside a goal
402
- * behaves exactly as it always has, and the trailing text stays a per-wake
403
- * focus. With no active goal the trailing text becomes this loop's own
404
- * objective and the loop is standalone. With neither, there is nothing to
405
- * work on, and the caller is told what to supply.
1045
+ * Start a loop on its own objective, the only mode there is: the trailing
1046
+ * text *is* what the loop works on and what `loop_complete` answers for.
1047
+ * With no text there is nothing to work on, and the caller is told so.
406
1048
  */
407
- startLoop(ctx: ExtensionCommandContext, start: LoopStartArguments): void {
1049
+ startLoop(ctx: ExtensionContext, start: LoopStartArguments): LoopStartResult {
408
1050
  this.sessionCtx = ctx;
409
1051
  const now = this.now();
410
- const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
411
- const goalBound = goal?.status === "active";
412
- const objective = goalBound ? undefined : start.prompt?.trim();
413
- if (!goalBound && !objective) {
414
- ctx.ui.notify(
415
- "A loop needs something to work on. Either give it an objective /loop <interval> <objective with completion criteria> — or start a goal first with /goal <objective> and run /loop <interval> to bind to it.",
416
- "error",
417
- );
418
- return;
1052
+ const objective = start.prompt?.trim();
1053
+ if (!objective) {
1054
+ return {
1055
+ ok: false,
1056
+ message:
1057
+ "A loop needs something to work on. Give it an objective: /loop <interval> <objective with completion criteria>.",
1058
+ };
419
1059
  }
420
- const expiryMs = parseDuration(this.settings.maxLoopDuration) ?? 604_800_000;
1060
+ // A loop with no way to call loop_complete would work, finish, and then be
1061
+ // told to keep working until it hit a cap. Refuse at the door rather than
1062
+ // after the first turn.
1063
+ if (!this.completeToolAvailable()) {
1064
+ return {
1065
+ ok: false,
1066
+ message: `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.`,
1067
+ };
1068
+ }
1069
+ const expiryMs =
1070
+ start.expiresInMs ?? parseDuration(this.settings.maxLoopDuration) ?? 604_800_000;
421
1071
  const compactAt =
422
1072
  start.compactAt !== undefined
423
1073
  ? start.compactAt
424
1074
  : this.settings.compaction.enabled
425
1075
  ? this.settings.compaction.threshold
426
1076
  : null;
427
- this.state = {
1077
+ const started: LoopState = {
428
1078
  id: randomUUID().slice(0, 8),
429
1079
  status: "active",
430
- // The same trailing text is a per-wake focus for a goal-bound loop and
431
- // the authoritative objective for a standalone one; never both.
432
- ...(goalBound && start.prompt ? { prompt: start.prompt } : {}),
433
- ...(objective ? { objective } : {}),
1080
+ objective,
434
1081
  intervalMs: start.intervalMs,
435
1082
  maxIterations:
436
1083
  start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
1084
+ maxAutomaticTurns: this.settings.automaticTurns,
437
1085
  compactAt,
438
1086
  iteration: 0,
1087
+ automaticTurns: 0,
439
1088
  startedAt: now,
440
1089
  expiresAt: now + expiryMs,
441
1090
  };
1091
+ this.state = started;
442
1092
  this.wakePending = false;
1093
+ this.continuationIntent = undefined;
1094
+ this.noOpStreak = 0;
1095
+ this.ledgerWarned = false;
1096
+ this.openLedger(this.state);
443
1097
  this.persist();
444
1098
  this.scheduleTick(start.intervalMs);
445
1099
  this.updateWidget();
@@ -447,11 +1101,57 @@ export class LoopController {
447
1101
  ? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
448
1102
  : "";
449
1103
  ctx.ui.notify(
450
- goalBound
451
- ? `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.`,
1104
+ `Loop started: working its objective from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you run /loop stop. Fallback wake every ${formatDuration(start.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(expiryMs)} (one final turn to write its state down, then it stops).`,
453
1105
  "info",
454
1106
  );
1107
+ if (this.ledger) {
1108
+ const criteria = this.criteria() ?? [];
1109
+ ctx.ui.notify(
1110
+ [
1111
+ `Loop ledger: ${this.ledger.dir}`,
1112
+ `Completion criteria (${criteria.length}) — loop_complete answers for these:`,
1113
+ ...criteria.map((criterion) => ` ${criterion.id}. ${criterion.description}`),
1114
+ ].join("\n"),
1115
+ "info",
1116
+ );
1117
+ }
1118
+ // The kickoff anchor: one stored message per loop holding the objective
1119
+ // data, because the system append exists only while the loop is active.
1120
+ this.sendKickoffAnchor(ctx);
1121
+ // Immediate kickoff: the loop starts working now instead of burning its
1122
+ // first interval idle. A busy session keeps the intent and delivers it at
1123
+ // the settle.
1124
+ this.requestContinuation(started, "kickoff");
1125
+ this.dispatchContinuationIfSettled(ctx);
1126
+ return { ok: true, loop: started };
1127
+ }
1128
+
1129
+ /**
1130
+ * Store the objective as an ordinary message so it outlives the loop.
1131
+ *
1132
+ * At an idle boundary `sendMessage` appends the message with no turn, which
1133
+ * is exactly an anchor. While the agent streams the same call would *steer*
1134
+ * the running turn, so a busy session gets `nextTurn` instead: queued as
1135
+ * context alongside the next prompt, interrupting nothing.
1136
+ */
1137
+ private sendKickoffAnchor(ctx: ExtensionContext): void {
1138
+ const loop = this.state;
1139
+ if (!loop || loop.objective === undefined || !this.ledger) return;
1140
+ const idle = ctx.isIdle() && !ctx.hasPendingMessages();
1141
+ try {
1142
+ this.pi.sendMessage(
1143
+ {
1144
+ customType: LOOP_ANCHOR_MESSAGE_TYPE,
1145
+ content: buildKickoffAnchor(loop, this.ledger),
1146
+ display: true,
1147
+ details: { loopId: loop.id },
1148
+ },
1149
+ idle ? {} : { deliverAs: "nextTurn" },
1150
+ );
1151
+ } catch (error) {
1152
+ // The anchor is a durability nicety; the loop runs without it.
1153
+ ctx.ui.notify(`pi-loop could not anchor the objective: ${formatError(error)}.`, "warning");
1154
+ }
455
1155
  }
456
1156
 
457
1157
  pauseLoop(ctx: ExtensionContext): void {
@@ -475,27 +1175,23 @@ export class LoopController {
475
1175
  this.transition("stopped", "loop expired (maxLoopDuration reached)");
476
1176
  return;
477
1177
  }
478
- // Same guard as startLoop, and for the same reason: resuming a goal-bound
479
- // loop into a finished or missing goal would only stop or pause again at
480
- // the first tick. A standalone loop owns its objective and needs no goal.
481
- if (
482
- !isStandaloneLoop(loop) &&
483
- readGoalSnapshot(ctx.sessionManager.getBranch())?.status !== "active"
484
- ) {
485
- ctx.ui.notify(
486
- "This loop is bound to a goal, which is no longer active. Start one with /goal <objective>, then /loop resume.",
487
- "error",
488
- );
489
- return;
490
- }
491
- this.state = { ...loop, status: "active" };
1178
+ // Resuming starts a fresh safety epoch: the user has seen why it paused
1179
+ // and chosen to continue, so the breaker must not trip on stale counters.
1180
+ const { pauseCause: _cause, lastFingerprint: _fingerprint, ...rest } = loop;
1181
+ this.state = { ...rest, status: "active", toolFreeRepeatCount: 0 };
1182
+ this.noOpStreak = 0;
492
1183
  this.persist();
493
1184
  this.scheduleTick(loop.intervalMs);
494
1185
  this.updateWidget();
495
1186
  ctx.ui.notify(
496
- `Loop resumed: next wake at ${formatClock(this.now() + loop.intervalMs)}.`,
1187
+ `Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`,
497
1188
  "info",
498
1189
  );
1190
+ // Resuming resumes the work, not just the heartbeat.
1191
+ if (this.state) {
1192
+ this.requestContinuation(this.state);
1193
+ this.dispatchContinuationIfSettled(ctx);
1194
+ }
499
1195
  }
500
1196
 
501
1197
  /** Re-arm the timer after an interval edit while active. */
@@ -525,3 +1221,22 @@ export class LoopController {
525
1221
  function formatError(error: unknown): string {
526
1222
  return error instanceof Error ? error.message : String(error);
527
1223
  }
1224
+
1225
+ /**
1226
+ * A run that called no tool did nothing to the world. It is the signal the
1227
+ * fallback backoff needs today; Stage 6's `LOOP_OK` acknowledgement refines
1228
+ * the same counter rather than replacing it.
1229
+ */
1230
+ function isNoOpRun(messages: readonly unknown[]): boolean {
1231
+ for (const message of messages) {
1232
+ if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
1233
+ continue;
1234
+ }
1235
+ if (message.content.some((block) => isRecord(block) && block.type === "toolCall")) return false;
1236
+ }
1237
+ return true;
1238
+ }
1239
+
1240
+ function isRecord(value: unknown): value is Record<string, unknown> {
1241
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1242
+ }