@hank-warren/pi-loop 0.5.0 → 0.7.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,37 +3,29 @@
3
3
  * loop-aware compaction, wired to Pi's extension events.
4
4
  *
5
5
  * Design invariants (approved plan):
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.
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).
13
11
  * - Timers are armed in session_start, a settle, or a command handler, never
14
12
  * the factory, and cleared in an idempotent session_shutdown.
15
13
  * - Pokes deliver only at a fully idle boundary; a tick that lands while the
16
14
  * agent is busy coalesces into a single pending wake delivered at the next
17
15
  * agent_settled. Missed ticks never stack, and a continuation supersedes a
18
16
  * coalesced wake rather than delivering both.
19
- * - Loops require an active pi-goal goal to operate: pi-goal owns "whether
20
- * the work is done". Its safety states pause the loop, its completion stops
21
- * it, and a missing goal pauses the loop. Coupling is read-only session
22
- * entries.
23
- * - Terminal decisions (expiry, completion, safety) also land at a settled
24
- * boundary, so the loop settles as soon as the goal does; only the timer
25
- * ever pokes.
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.
26
22
  * - The loop's proactive compaction is the normal compaction path; Pi's
27
- * reserve-token auto-compaction is the fault handler. pi-goal owns the
28
- * 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.
29
25
  */
30
26
 
31
27
  import { randomUUID } from "node:crypto";
32
- import type {
33
- ExtensionAPI,
34
- ExtensionCommandContext,
35
- ExtensionContext,
36
- } from "@earendil-works/pi-coding-agent";
28
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
37
29
  import type { LoopStartArguments } from "./command.js";
38
30
  import {
39
31
  type ContinuationDecision,
@@ -45,16 +37,17 @@ import {
45
37
  import { formatClock, formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
46
38
  import {
47
39
  createLedger,
40
+ criteriaFromDescriptions,
48
41
  deriveCriteria,
49
42
  type LedgerPaths,
50
43
  ledgerPaths,
44
+ type LoopCriterion,
51
45
  readCriteria,
52
46
  } from "./ledger.js";
53
47
  import {
54
48
  buildCompactionInstructions,
55
49
  buildContinuation,
56
50
  buildExpiryWake,
57
- buildGoalPoke,
58
51
  buildKickoffAnchor,
59
52
  buildObjectivePoke,
60
53
  type ContinuationKind,
@@ -67,10 +60,8 @@ import {
67
60
  readLoopSettings,
68
61
  } from "./settings.js";
69
62
  import {
70
- isStandaloneLoop,
71
63
  LOOP_STATE_ENTRY_TYPE,
72
64
  type LoopState,
73
- readGoalSnapshot,
74
65
  readPlanModeEnabled,
75
66
  restoreLoopState,
76
67
  } from "./state.js";
@@ -106,6 +97,17 @@ export const MAX_DEAD_DELIVERIES = 3;
106
97
  /** Why the loop caused the run that is currently in flight. */
107
98
  type RunOrigin = "continuation" | "fallback";
108
99
 
100
+ /**
101
+ * The outcome of a start attempt.
102
+ *
103
+ * `startLoop` used to report its refusals by calling `ctx.ui.notify` itself,
104
+ * which tied the only start path to a UI. Two callers now share that path —
105
+ * the `/loop` command and the `loop_start` tool — and the tool has to turn
106
+ * the same refusal into tool content rather than a toast, so the decision is
107
+ * returned and each caller renders it.
108
+ */
109
+ export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
110
+
109
111
  interface ContinuationIntent {
110
112
  loopId: string;
111
113
  kind: ContinuationKind;
@@ -189,7 +191,7 @@ export class LoopController {
189
191
  this.transition("stopped", "loop expired while the session was away");
190
192
  return;
191
193
  }
192
- if (!isStandaloneLoop(this.state) && this.migrateGoalBoundLoop(ctx)) return;
194
+ if (this.state.objective === undefined && this.adoptLegacyObjective(ctx)) return;
193
195
  // A restored loop keeps its ledger: createLedger only ever creates
194
196
  // PROGRESS.md, so days of agent-written state survive a restart.
195
197
  this.openLedger(this.state);
@@ -211,11 +213,11 @@ export class LoopController {
211
213
  }
212
214
 
213
215
  /**
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.
216
+ * A finished agent run with an active loop is the signal that paces it:
217
+ * record the *intent* to continue here and let the settled boundary decide
218
+ * whether it may be delivered. Recording at agent_end (not at settle) is
219
+ * what makes the intent survive Pi's own retries and auto-compaction, which
220
+ * run between the two events.
219
221
  */
220
222
  /** A run started, so the delivery that caused it was not a dead one. */
221
223
  onAgentStart(ctx: ExtensionContext): void {
@@ -230,7 +232,7 @@ export class LoopController {
230
232
  const origin = this.runOrigin;
231
233
  this.runOrigin = undefined;
232
234
  const loop = this.state;
233
- if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return;
235
+ if (!loop || loop.status !== "active") return;
234
236
  if (this.enforceToolAvailability(ctx)) return;
235
237
  // The expiry's final turn is the last one: never queue a continuation
236
238
  // behind it. The settle that follows stops the loop.
@@ -382,69 +384,52 @@ export class LoopController {
382
384
  return;
383
385
  }
384
386
  // 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();
387
+ // interval of genuine idleness.
388
+ this.armFallback();
388
389
  }
389
390
 
390
391
  /**
391
392
  * A settled boundary with no wake pending still evaluates the terminal
392
- * decisions — expiry, a completed or missing goal, a pi-goal safety state
393
- * so the loop settles the moment the goal does instead of up to one interval
394
- * later. Poke and skip decisions are deliberately ignored here: only the
395
- * timer pokes, and settling is not a schedule.
393
+ * decisions — expiry and the caps so the loop settles the moment the work
394
+ * does instead of up to one interval later. Poke and skip decisions are
395
+ * deliberately ignored here: only the timer pokes, and settling is not a
396
+ * schedule.
396
397
  */
397
398
  private settleTerminalState(ctx: ExtensionContext): boolean {
398
399
  const loop = this.state;
399
400
  if (!loop) return false;
400
401
  const env = this.gatherEnvironment(ctx);
401
402
  const decision = decideTick(loop, env);
402
- if (decision.action !== "expire" && decision.action !== "stop" && decision.action !== "pause") {
403
- return false;
404
- }
403
+ if (decision.action !== "expire" && decision.action !== "stop") return false;
405
404
  this.lastDecision = { ...decision, at: env.now };
406
405
  this.applyTerminalDecision(loop, decision);
407
406
  return true;
408
407
  }
409
408
 
410
409
  /**
411
- * Migration for goal-bound loops, which are deprecated.
410
+ * The restore shim for a loop persisted before 0.6.0.
412
411
  *
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.
412
+ * Such a loop may carry no objective of its own: it delegated "is the work
413
+ * done" to a goal in another extension that no longer exists here. The only
414
+ * case that can still reach this code is a session persisted before 0.6.0
415
+ * and resumed after it, having never been restored under 0.5.0 where it
416
+ * would already have been converted.
417
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.
418
+ * Its focus text, when it has one, is the closest thing to an objective it
419
+ * has, so adopt that. With nothing to adopt there is no honest way to run
420
+ * it, so it pauses and says so.
422
421
  *
423
- * Returns true when the loop was stopped or paused and needs no timer.
422
+ * Returns true when the loop was paused and needs no timer.
424
423
  */
425
- private migrateGoalBoundLoop(ctx: ExtensionContext): boolean {
424
+ private adoptLegacyObjective(ctx: ExtensionContext): boolean {
426
425
  const loop = this.state;
427
426
  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;
427
+ const objective = loop.prompt;
443
428
  if (!objective) {
444
429
  this.transition(
445
430
  "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",
431
+ "it was bound to a goal that is gone and has no objective of its own; start a new loop with /loop <interval> <objective>",
432
+ "loop with no objective",
448
433
  );
449
434
  return true;
450
435
  }
@@ -452,15 +437,15 @@ export class LoopController {
452
437
  this.state = { ...rest, objective };
453
438
  this.persist();
454
439
  ctx.ui.notify(
455
- `Goal-bound loops are deprecated; this one now owns its objective directly: ${objective}`,
440
+ `This loop predates pi-loop owning its own objective; it now works its focus text directly: ${objective}`,
456
441
  "info",
457
442
  );
458
443
  return false;
459
444
  }
460
445
 
461
446
  /**
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.
447
+ * A loop that cannot call `loop_complete` cannot end itself: it will work,
448
+ * finish, and then be told to keep working until it hits a cap.
464
449
  * That happens whenever the tool set is restricted (`--tools`, `--no-tools`,
465
450
  * a policy that drops extension tools), and it is invisible from inside the
466
451
  * loop — so check the live tool set and pause instead of spinning.
@@ -469,7 +454,7 @@ export class LoopController {
469
454
  */
470
455
  private enforceToolAvailability(ctx: ExtensionContext): boolean {
471
456
  const loop = this.state;
472
- if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return false;
457
+ if (!loop || loop.status !== "active") return false;
473
458
  if (this.completeToolAvailable()) return false;
474
459
  this.transition(
475
460
  "paused",
@@ -523,7 +508,7 @@ export class LoopController {
523
508
  */
524
509
  enterWait(reason: string, resumeAfterMs: number | undefined): ResolvedWaitDelay | undefined {
525
510
  const loop = this.state;
526
- if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return undefined;
511
+ if (!loop || loop.status !== "active") return undefined;
527
512
  const resolved = resolveWaitDelay(resumeAfterMs);
528
513
  const waiting = createLoopWait(reason, resumeAfterMs, this.now());
529
514
  // The wait replaces any continuation already recorded for this turn.
@@ -584,17 +569,24 @@ export class LoopController {
584
569
  // --- ledger ---
585
570
 
586
571
  /**
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.
572
+ * Create (or adopt) the loop's ledger. Best-effort by design: a loop with
573
+ * no writable ledger still runs, it just loses the durable record, so the
574
+ * failure is warned once and never repeated.
575
+ *
576
+ * `criteria` is passed at start: the criteria proposed at `loop_start`, or
577
+ * the deterministic split of the objective. On restore it is omitted, and
578
+ * the criteria already on disk are authoritative — they are the ones the
579
+ * user saw echoed, and re-deriving them would both discard a proposed set
580
+ * and reset whatever `passes` flips the loop has earned.
590
581
  */
591
- private openLedger(loop: LoopState): void {
582
+ private openLedger(loop: LoopState, criteria?: LoopCriterion[]): void {
592
583
  if (loop.objective === undefined) {
593
584
  this.ledger = undefined;
594
585
  return;
595
586
  }
596
587
  const paths = ledgerPaths(loop.id, this.agentDir);
597
- const failure = createLedger(paths, loop.objective, deriveCriteria(loop.objective));
588
+ const contents = criteria ?? readCriteria(paths) ?? deriveCriteria(loop.objective);
589
+ const failure = createLedger(paths, loop.objective, contents);
598
590
  if (failure) {
599
591
  this.ledger = undefined;
600
592
  if (!this.ledgerWarned) {
@@ -729,7 +721,6 @@ export class LoopController {
729
721
  busy: !ctx.isIdle() || ctx.hasPendingMessages(),
730
722
  compacting: this.compacting,
731
723
  planModeEnabled: readPlanModeEnabled(branch),
732
- goal: readGoalSnapshot(branch),
733
724
  };
734
725
  }
735
726
 
@@ -757,7 +748,7 @@ export class LoopController {
757
748
  this.updateWidget();
758
749
  return;
759
750
  case "poke":
760
- this.deliverPoke(env, decision.reason);
751
+ this.deliverPoke(env.now, decision.reason);
761
752
  return;
762
753
  default:
763
754
  this.applyTerminalDecision(loop, decision);
@@ -767,7 +758,7 @@ export class LoopController {
767
758
 
768
759
  private applyTerminalDecision(
769
760
  loop: LoopState,
770
- decision: Extract<TickDecision, { action: "expire" | "stop" | "pause" }>,
761
+ decision: Extract<TickDecision, { action: "expire" | "stop" }>,
771
762
  ): void {
772
763
  switch (decision.action) {
773
764
  case "expire":
@@ -775,22 +766,7 @@ export class LoopController {
775
766
  this.transition("stopped", "loop expired (the expiry was reached)");
776
767
  return;
777
768
  case "stop":
778
- this.transition(
779
- "stopped",
780
- decision.reason === "goal-complete"
781
- ? "the goal completed"
782
- : decision.reason === "max-automatic-turns"
783
- ? `the ${loop.maxAutomaticTurns}-automatic-turn cap was reached`
784
- : `the ${loop.maxIterations}-iteration cap was reached`,
785
- );
786
- return;
787
- case "pause":
788
- this.transition(
789
- "paused",
790
- decision.reason === "goal-missing"
791
- ? "loops require an active goal; start one with /goal <objective>, then /loop resume"
792
- : `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
793
- );
769
+ this.transition("stopped", `the ${loop.maxTurns}-turn cap was reached`);
794
770
  return;
795
771
  }
796
772
  }
@@ -837,24 +813,15 @@ export class LoopController {
837
813
 
838
814
  /**
839
815
  * Send first, then account. Pi can refuse the delivery (a busy or compacting
840
- * session), and an iteration persisted before the send would burn the
841
- * maxIterations cap on a poke that never arrived; on a throw the loop re-arms
842
- * on the same cadence and retries at the next wake.
816
+ * session), and a turn persisted before the send would burn the cap on a
817
+ * poke that never arrived; on a throw the loop re-arms on the same cadence
818
+ * and retries at the next wake.
843
819
  */
844
- private deliverPoke(
845
- env: TickEnvironment,
846
- reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed",
847
- ): void {
820
+ private deliverPoke(now: number, reason: "objective-stalled" | "wait-elapsed"): void {
848
821
  const loop = this.state;
849
822
  if (!loop) return;
850
- const standalone = reason === "objective-stalled" || reason === "wait-elapsed";
851
- // A goal-bound poke restates nothing, so it is only meaningful while the
852
- // goal it points at is readable; a standalone poke needs no goal at all.
853
- if (!standalone && !env.goal) return;
854
823
  try {
855
- this.pi.sendUserMessage(
856
- standalone ? buildObjectivePoke(loop, reason) : buildGoalPoke(loop, reason),
857
- );
824
+ this.pi.sendUserMessage(buildObjectivePoke(loop, reason));
858
825
  } catch (error) {
859
826
  this.sessionCtx?.ui.notify(
860
827
  `pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
@@ -872,7 +839,7 @@ export class LoopController {
872
839
  ...this.consumeWait(loop),
873
840
  iteration: loop.iteration + 1,
874
841
  automaticTurns: loop.automaticTurns + 1,
875
- lastWakeAt: env.now,
842
+ lastWakeAt: now,
876
843
  };
877
844
  this.persist();
878
845
  this.armFallback();
@@ -894,15 +861,11 @@ export class LoopController {
894
861
  if (!usage || typeof usage.tokens !== "number" || !usage.contextWindow) return false;
895
862
  if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
896
863
  }
897
- const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
898
864
  this.compacting = true;
899
865
  try {
900
866
  ctx.compact({
901
867
  customInstructions: buildCompactionInstructions(
902
868
  loop,
903
- // A completed or otherwise finished goal is no longer the
904
- // objective the summary must preserve.
905
- goal?.status === "active" ? goal : undefined,
906
869
  this.settings.compaction.instructions,
907
870
  this.ledger,
908
871
  ),
@@ -937,7 +900,7 @@ export class LoopController {
937
900
  */
938
901
  private requestReAnchor(result: unknown): void {
939
902
  const loop = this.state;
940
- if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return;
903
+ if (!loop || loop.status !== "active") return;
941
904
  const summary =
942
905
  isRecord(result) && typeof result.summary === "string" ? result.summary : undefined;
943
906
  this.requestContinuation(loop, "reanchor", summary ? extractNextActions(summary) : undefined);
@@ -1002,7 +965,7 @@ export class LoopController {
1002
965
  );
1003
966
  return;
1004
967
  }
1005
- const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
968
+ const cap = loop.maxTurns === null ? "∞" : `${loop.maxTurns}`;
1006
969
  const next = this.wakePending
1007
970
  ? "next on idle"
1008
971
  : this.nextWakeAt
@@ -1010,7 +973,7 @@ export class LoopController {
1010
973
  : "next unscheduled";
1011
974
  ui.setStatus(
1012
975
  LOOP_STATUS_KEY,
1013
- `loop ${formatDuration(loop.intervalMs)} · ${loop.iteration}/${cap} · ${next}`,
976
+ `loop ${formatDuration(loop.intervalMs)} · ${loop.automaticTurns}/${cap} · ${next}`,
1014
977
  );
1015
978
  }
1016
979
 
@@ -1032,14 +995,13 @@ export class LoopController {
1032
995
  ? [`Cancelled wait (reported on the next wake): ${loop.cancelledWaitReason}`]
1033
996
  : []),
1034
997
  `Interval: every ${formatDuration(loop.intervalMs)}`,
1035
- `Wakes: ${loop.iteration}${loop.maxIterations === null ? " (unlimited)" : ` of ${loop.maxIterations}`}`,
1036
- `Automatic turns: ${loop.automaticTurns}${loop.maxAutomaticTurns === null ? " (unlimited)" : ` of ${loop.maxAutomaticTurns}`}`,
998
+ `Loop turns: ${loop.automaticTurns}${loop.maxTurns === null ? " (unlimited)" : ` of ${loop.maxTurns}`}`,
999
+ `Fallback wakes delivered: ${loop.iteration}`,
1037
1000
  `Started: ${new Date(loop.startedAt).toLocaleString()}`,
1038
1001
  `Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
1039
1002
  `Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
1040
1003
  ];
1041
1004
  if (loop.objective) {
1042
- lines.push("Mode: standalone (this loop owns its completion criteria)");
1043
1005
  lines.push(`Objective: ${loop.objective}`);
1044
1006
  if (this.ledger) {
1045
1007
  const criteria = this.criteria();
@@ -1059,17 +1021,11 @@ export class LoopController {
1059
1021
  } else {
1060
1022
  lines.push("Ledger: unavailable (the loop runs without one)");
1061
1023
  }
1062
- } else {
1063
- lines.push("Mode: goal-bound (pi-goal owns completion)");
1064
1024
  }
1065
1025
  if (loop.prompt) lines.push(`Focus: ${loop.prompt}`);
1066
- const goal = isStandaloneLoop(loop)
1067
- ? undefined
1068
- : readGoalSnapshot(ctx.sessionManager.getBranch());
1069
- if (goal) lines.push(`Goal (pi-goal): ${goal.status} — ${goal.text}`);
1070
1026
  if (this.nextWakeAt && loop.status === "active") {
1071
1027
  lines.push(
1072
- `${isStandaloneLoop(loop) ? "Next fallback wake" : "Next wake"}: ${formatClock(this.nextWakeAt)}${
1028
+ `Next fallback wake: ${formatClock(this.nextWakeAt)}${
1073
1029
  this.noOpStreak > 0
1074
1030
  ? ` (backed off ×${Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak)} after ${this.noOpStreak} no-op wake${this.noOpStreak === 1 ? "" : "s"})`
1075
1031
  : ""
@@ -1090,36 +1046,29 @@ export class LoopController {
1090
1046
  // --- command actions ---
1091
1047
 
1092
1048
  /**
1093
- * Mode selection, and the only place it happens.
1094
- *
1095
- * An active pi-goal goal wins: a bare or focused `/loop` alongside a goal
1096
- * behaves exactly as it always has, and the trailing text stays a per-wake
1097
- * focus. With no active goal the trailing text becomes this loop's own
1098
- * objective and the loop is standalone. With neither, there is nothing to
1099
- * work on, and the caller is told what to supply.
1049
+ * Start a loop on its own objective, the only mode there is: the trailing
1050
+ * text *is* what the loop works on and what `loop_complete` answers for.
1051
+ * With no text there is nothing to work on, and the caller is told so.
1100
1052
  */
1101
- startLoop(ctx: ExtensionCommandContext, start: LoopStartArguments): void {
1053
+ startLoop(ctx: ExtensionContext, start: LoopStartArguments): LoopStartResult {
1102
1054
  this.sessionCtx = ctx;
1103
1055
  const now = this.now();
1104
- const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
1105
- const goalBound = goal?.status === "active";
1106
- const objective = goalBound ? undefined : start.prompt?.trim();
1107
- if (!goalBound && !objective) {
1108
- ctx.ui.notify(
1109
- "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.",
1110
- "error",
1111
- );
1112
- return;
1056
+ const objective = start.prompt?.trim();
1057
+ if (!objective) {
1058
+ return {
1059
+ ok: false,
1060
+ message:
1061
+ "A loop needs something to work on. Give it an objective: /loop <interval> <objective with completion criteria>.",
1062
+ };
1113
1063
  }
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;
1064
+ // A loop with no way to call loop_complete would work, finish, and then be
1065
+ // told to keep working until it hit a cap. Refuse at the door rather than
1066
+ // after the first turn.
1067
+ if (!this.completeToolAvailable()) {
1068
+ return {
1069
+ ok: false,
1070
+ 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.`,
1071
+ };
1123
1072
  }
1124
1073
  const expiryMs =
1125
1074
  start.expiresInMs ?? parseDuration(this.settings.maxLoopDuration) ?? 604_800_000;
@@ -1129,44 +1078,35 @@ export class LoopController {
1129
1078
  : this.settings.compaction.enabled
1130
1079
  ? this.settings.compaction.threshold
1131
1080
  : null;
1132
- this.state = {
1081
+ const started: LoopState = {
1133
1082
  id: randomUUID().slice(0, 8),
1134
1083
  status: "active",
1135
- // The same trailing text is a per-wake focus for a goal-bound loop and
1136
- // the authoritative objective for a standalone one; never both.
1137
- ...(goalBound && start.prompt ? { prompt: start.prompt } : {}),
1138
- ...(objective ? { objective } : {}),
1084
+ objective,
1139
1085
  intervalMs: start.intervalMs,
1140
- maxIterations:
1141
- start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
1142
- maxAutomaticTurns: this.settings.automaticTurns,
1086
+ maxTurns: start.maxTurns !== undefined ? start.maxTurns : this.settings.maxTurns,
1143
1087
  compactAt,
1144
1088
  iteration: 0,
1145
1089
  automaticTurns: 0,
1146
1090
  startedAt: now,
1147
1091
  expiresAt: now + expiryMs,
1148
1092
  };
1093
+ this.state = started;
1149
1094
  this.wakePending = false;
1150
1095
  this.continuationIntent = undefined;
1151
1096
  this.noOpStreak = 0;
1152
1097
  this.ledgerWarned = false;
1153
- this.openLedger(this.state);
1098
+ this.openLedger(
1099
+ this.state,
1100
+ start.criteria ? criteriaFromDescriptions(start.criteria) : deriveCriteria(objective),
1101
+ );
1154
1102
  this.persist();
1155
1103
  this.scheduleTick(start.intervalMs);
1156
1104
  this.updateWidget();
1157
1105
  const clampNote = start.clamped
1158
1106
  ? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
1159
1107
  : "";
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
- }
1166
1108
  ctx.ui.notify(
1167
- goalBound
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.`
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).`,
1109
+ `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).`,
1170
1110
  "info",
1171
1111
  );
1172
1112
  if (this.ledger) {
@@ -1183,13 +1123,12 @@ export class LoopController {
1183
1123
  // The kickoff anchor: one stored message per loop holding the objective
1184
1124
  // data, because the system append exists only while the loop is active.
1185
1125
  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
- }
1126
+ // Immediate kickoff: the loop starts working now instead of burning its
1127
+ // first interval idle. A busy session keeps the intent and delivers it at
1128
+ // the settle.
1129
+ this.requestContinuation(started, "kickoff");
1130
+ this.dispatchContinuationIfSettled(ctx);
1131
+ return { ok: true, loop: started };
1193
1132
  }
1194
1133
 
1195
1134
  /**
@@ -1202,7 +1141,7 @@ export class LoopController {
1202
1141
  */
1203
1142
  private sendKickoffAnchor(ctx: ExtensionContext): void {
1204
1143
  const loop = this.state;
1205
- if (!loop || !isStandaloneLoop(loop) || !this.ledger) return;
1144
+ if (!loop || loop.objective === undefined || !this.ledger) return;
1206
1145
  const idle = ctx.isIdle() && !ctx.hasPendingMessages();
1207
1146
  try {
1208
1147
  this.pi.sendMessage(
@@ -1241,19 +1180,6 @@ export class LoopController {
1241
1180
  this.transition("stopped", "loop expired (maxLoopDuration reached)");
1242
1181
  return;
1243
1182
  }
1244
- // Same guard as startLoop, and for the same reason: resuming a goal-bound
1245
- // loop into a finished or missing goal would only stop or pause again at
1246
- // the first tick. A standalone loop owns its objective and needs no goal.
1247
- if (
1248
- !isStandaloneLoop(loop) &&
1249
- readGoalSnapshot(ctx.sessionManager.getBranch())?.status !== "active"
1250
- ) {
1251
- ctx.ui.notify(
1252
- "This loop is bound to a goal, which is no longer active. Start one with /goal <objective>, then /loop resume.",
1253
- "error",
1254
- );
1255
- return;
1256
- }
1257
1183
  // Resuming starts a fresh safety epoch: the user has seen why it paused
1258
1184
  // and chosen to continue, so the breaker must not trip on stale counters.
1259
1185
  const { pauseCause: _cause, lastFingerprint: _fingerprint, ...rest } = loop;
@@ -1263,13 +1189,11 @@ export class LoopController {
1263
1189
  this.scheduleTick(loop.intervalMs);
1264
1190
  this.updateWidget();
1265
1191
  ctx.ui.notify(
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)}.`,
1192
+ `Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`,
1269
1193
  "info",
1270
1194
  );
1271
- // Resuming a standalone loop resumes the work, not just the heartbeat.
1272
- if (this.state && isStandaloneLoop(this.state)) {
1195
+ // Resuming resumes the work, not just the heartbeat.
1196
+ if (this.state) {
1273
1197
  this.requestContinuation(this.state);
1274
1198
  this.dispatchContinuationIfSettled(ctx);
1275
1199
  }