@hank-warren/pi-loop 0.5.0 → 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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Inline `/loop` invocation, tool-mediated.
3
+ *
4
+ * Pi only dispatches `/loop` when it starts the message. When the user writes
5
+ * `quick check /loop 10m get CI green` or a `loop:` prefixed line mid-prompt,
6
+ * this module appends a one-turn reminder to the system prompt so the model
7
+ * reliably calls the `loop_start` tool with the objective. The user's message
8
+ * itself is never touched — no cutting, splitting, re-sending, or visible
9
+ * annotation — so there are no delivery races, no message loss, and no
10
+ * transcript noise: guidance is injected at agent-start time, not by
11
+ * rewriting input.
12
+ *
13
+ * Two hooks cooperate because neither alone is safe:
14
+ * - `input` carries a `source` and so can tell user-typed text from
15
+ * extension-sent prompts, but any transform it returns rewrites the stored,
16
+ * visible user message. It only records the armed text here.
17
+ * - `before_agent_start` can extend the system prompt, but fires for
18
+ * extension-sent prompts too, and pi-loop's own kickoff and continuation
19
+ * prompts contain phrases like "the active /loop objective" that the
20
+ * detector would match. It injects only when the starting prompt *is* the
21
+ * armed user message, and disarms on every start, matched or not.
22
+ *
23
+ * Two deliberate limits keep the armed window one turn wide:
24
+ * - Streaming-typed input (`streamingBehavior` set, i.e. steered or queued)
25
+ * never arms. Pi returns from `prompt()` before `before_agent_start` for
26
+ * those, so an armed flag would survive to a later, unrelated turn — the
27
+ * window through which pi-loop's own continuation prompts could be matched.
28
+ * - The hint is a per-turn `systemPrompt` append, not a stored message, so
29
+ * "call loop_start now" cannot linger in the conversation and fire on a
30
+ * later turn.
31
+ *
32
+ * The armed flag is also the `loop_start` tool's gate: unlike pi-goal, which
33
+ * relied on prompt guidelines alone, a loop is self-continuing, so this
34
+ * extension *enforces* that the tool only runs on a turn the user explicitly
35
+ * invoked. See `InlineInvocationState.invokedThisTurn`.
36
+ */
37
+
38
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
39
+ import { detectsInlineInvocation } from "./inline-command.js";
40
+ import type { LoopController } from "./loop.js";
41
+
42
+ export const INLINE_LOOP_COMMAND = "loop";
43
+
44
+ export const INLINE_LOOP_HINT =
45
+ "<system-reminder>The user's message this turn inline-invoked a loop (/loop or loop:). Call the loop_start tool now with the objective text that follows the token in that message, then begin working toward it. Do not answer the objective as prose without starting the loop. If the message is discussing, quoting, or documenting the /loop command rather than invoking it, do not call loop_start. This reminder applies only to the user's message this turn.</system-reminder>";
46
+
47
+ /**
48
+ * The armed state, shared between the hooks and the `loop_start` tool.
49
+ *
50
+ * `invokedThisTurn` is the hard gate: set when `before_agent_start` matched
51
+ * the armed user message, cleared at `agent_end` and at every session
52
+ * boundary. `loop_start` refuses whenever it is false, so no amount of prompt
53
+ * drift, transcript replay, or model initiative can start a self-continuing
54
+ * loop the user did not ask for.
55
+ */
56
+ export class InlineInvocationState {
57
+ invokedThisTurn = false;
58
+ }
59
+
60
+ export function registerInlineInvocation(
61
+ pi: ExtensionAPI,
62
+ controller: LoopController,
63
+ state: InlineInvocationState,
64
+ ) {
65
+ let armedText: string | undefined;
66
+
67
+ pi.on("input", (event) => {
68
+ // Extension-sourced messages (loop kickoffs, continuations, pokes, other
69
+ // extensions' injections) never arm the hint.
70
+ if (event.source === "extension") return;
71
+ // Steered or queued input returns from prompt() without ever reaching
72
+ // before_agent_start, so arming it would leave stale text armed.
73
+ if (event.streamingBehavior !== undefined) return;
74
+ if (!controller.settings.inlineInvocation) return;
75
+ if (!detectsInlineInvocation(event.text, INLINE_LOOP_COMMAND)) return;
76
+ armedText = event.text;
77
+ });
78
+
79
+ pi.on("before_agent_start", (event) => {
80
+ const armed = armedText;
81
+ armedText = undefined;
82
+ // Every start closes the previous turn's window, so a turn that ends
83
+ // without an agent_end still cannot leave the tool unlocked.
84
+ state.invokedThisTurn = false;
85
+ if (armed === undefined) return;
86
+ if (!controller.settings.inlineInvocation) return;
87
+ if (!promptCarriesArmedMessage(event.prompt, armed)) return;
88
+ state.invokedThisTurn = true;
89
+ return { systemPrompt: `${event.systemPrompt}\n\n${INLINE_LOOP_HINT}` };
90
+ });
91
+
92
+ const disarm = () => {
93
+ armedText = undefined;
94
+ state.invokedThisTurn = false;
95
+ };
96
+ pi.on("agent_end", disarm);
97
+ pi.on("session_start", disarm);
98
+ pi.on("session_shutdown", disarm);
99
+ }
100
+
101
+ /**
102
+ * True when the starting prompt is the armed user message. Pi may wrap the
103
+ * text with expanded prefixes or suffixes, so the armed text has to be the
104
+ * whole prompt or one of its ends — mirroring how upstream recognises its own
105
+ * owned prompts at a terminal boundary.
106
+ */
107
+ function promptCarriesArmedMessage(prompt: string, armed: string) {
108
+ return prompt === armed || prompt.startsWith(armed) || prompt.endsWith(armed);
109
+ }
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,
@@ -54,7 +46,6 @@ import {
54
46
  buildCompactionInstructions,
55
47
  buildContinuation,
56
48
  buildExpiryWake,
57
- buildGoalPoke,
58
49
  buildKickoffAnchor,
59
50
  buildObjectivePoke,
60
51
  type ContinuationKind,
@@ -67,10 +58,8 @@ import {
67
58
  readLoopSettings,
68
59
  } from "./settings.js";
69
60
  import {
70
- isStandaloneLoop,
71
61
  LOOP_STATE_ENTRY_TYPE,
72
62
  type LoopState,
73
- readGoalSnapshot,
74
63
  readPlanModeEnabled,
75
64
  restoreLoopState,
76
65
  } from "./state.js";
@@ -106,6 +95,17 @@ export const MAX_DEAD_DELIVERIES = 3;
106
95
  /** Why the loop caused the run that is currently in flight. */
107
96
  type RunOrigin = "continuation" | "fallback";
108
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
109
  interface ContinuationIntent {
110
110
  loopId: string;
111
111
  kind: ContinuationKind;
@@ -189,7 +189,7 @@ export class LoopController {
189
189
  this.transition("stopped", "loop expired while the session was away");
190
190
  return;
191
191
  }
192
- if (!isStandaloneLoop(this.state) && this.migrateGoalBoundLoop(ctx)) return;
192
+ if (this.state.objective === undefined && this.adoptLegacyObjective(ctx)) return;
193
193
  // A restored loop keeps its ledger: createLedger only ever creates
194
194
  // PROGRESS.md, so days of agent-written state survive a restart.
195
195
  this.openLedger(this.state);
@@ -211,11 +211,11 @@ export class LoopController {
211
211
  }
212
212
 
213
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.
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
219
  */
220
220
  /** A run started, so the delivery that caused it was not a dead one. */
221
221
  onAgentStart(ctx: ExtensionContext): void {
@@ -230,7 +230,7 @@ export class LoopController {
230
230
  const origin = this.runOrigin;
231
231
  this.runOrigin = undefined;
232
232
  const loop = this.state;
233
- if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return;
233
+ if (!loop || loop.status !== "active") return;
234
234
  if (this.enforceToolAvailability(ctx)) return;
235
235
  // The expiry's final turn is the last one: never queue a continuation
236
236
  // behind it. The settle that follows stops the loop.
@@ -382,69 +382,52 @@ export class LoopController {
382
382
  return;
383
383
  }
384
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();
385
+ // interval of genuine idleness.
386
+ this.armFallback();
388
387
  }
389
388
 
390
389
  /**
391
390
  * 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.
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.
396
395
  */
397
396
  private settleTerminalState(ctx: ExtensionContext): boolean {
398
397
  const loop = this.state;
399
398
  if (!loop) return false;
400
399
  const env = this.gatherEnvironment(ctx);
401
400
  const decision = decideTick(loop, env);
402
- if (decision.action !== "expire" && decision.action !== "stop" && decision.action !== "pause") {
403
- return false;
404
- }
401
+ if (decision.action !== "expire" && decision.action !== "stop") return false;
405
402
  this.lastDecision = { ...decision, at: env.now };
406
403
  this.applyTerminalDecision(loop, decision);
407
404
  return true;
408
405
  }
409
406
 
410
407
  /**
411
- * Migration for goal-bound loops, which are deprecated.
408
+ * The restore shim for a loop persisted before 0.6.0.
412
409
  *
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.
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.
417
415
  *
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.
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.
422
419
  *
423
- * Returns true when the loop was stopped or paused and needs no timer.
420
+ * Returns true when the loop was paused and needs no timer.
424
421
  */
425
- private migrateGoalBoundLoop(ctx: ExtensionContext): boolean {
422
+ private adoptLegacyObjective(ctx: ExtensionContext): boolean {
426
423
  const loop = this.state;
427
424
  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;
425
+ const objective = loop.prompt;
443
426
  if (!objective) {
444
427
  this.transition(
445
428
  "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",
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",
448
431
  );
449
432
  return true;
450
433
  }
@@ -452,15 +435,15 @@ export class LoopController {
452
435
  this.state = { ...rest, objective };
453
436
  this.persist();
454
437
  ctx.ui.notify(
455
- `Goal-bound loops are deprecated; this one now owns its objective directly: ${objective}`,
438
+ `This loop predates pi-loop owning its own objective; it now works its focus text directly: ${objective}`,
456
439
  "info",
457
440
  );
458
441
  return false;
459
442
  }
460
443
 
461
444
  /**
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.
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.
464
447
  * That happens whenever the tool set is restricted (`--tools`, `--no-tools`,
465
448
  * a policy that drops extension tools), and it is invisible from inside the
466
449
  * loop — so check the live tool set and pause instead of spinning.
@@ -469,7 +452,7 @@ export class LoopController {
469
452
  */
470
453
  private enforceToolAvailability(ctx: ExtensionContext): boolean {
471
454
  const loop = this.state;
472
- if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return false;
455
+ if (!loop || loop.status !== "active") return false;
473
456
  if (this.completeToolAvailable()) return false;
474
457
  this.transition(
475
458
  "paused",
@@ -523,7 +506,7 @@ export class LoopController {
523
506
  */
524
507
  enterWait(reason: string, resumeAfterMs: number | undefined): ResolvedWaitDelay | undefined {
525
508
  const loop = this.state;
526
- if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return undefined;
509
+ if (!loop || loop.status !== "active") return undefined;
527
510
  const resolved = resolveWaitDelay(resumeAfterMs);
528
511
  const waiting = createLoopWait(reason, resumeAfterMs, this.now());
529
512
  // The wait replaces any continuation already recorded for this turn.
@@ -584,9 +567,9 @@ export class LoopController {
584
567
  // --- ledger ---
585
568
 
586
569
  /**
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.
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.
590
573
  */
591
574
  private openLedger(loop: LoopState): void {
592
575
  if (loop.objective === undefined) {
@@ -729,7 +712,6 @@ export class LoopController {
729
712
  busy: !ctx.isIdle() || ctx.hasPendingMessages(),
730
713
  compacting: this.compacting,
731
714
  planModeEnabled: readPlanModeEnabled(branch),
732
- goal: readGoalSnapshot(branch),
733
715
  };
734
716
  }
735
717
 
@@ -757,7 +739,7 @@ export class LoopController {
757
739
  this.updateWidget();
758
740
  return;
759
741
  case "poke":
760
- this.deliverPoke(env, decision.reason);
742
+ this.deliverPoke(env.now, decision.reason);
761
743
  return;
762
744
  default:
763
745
  this.applyTerminalDecision(loop, decision);
@@ -767,7 +749,7 @@ export class LoopController {
767
749
 
768
750
  private applyTerminalDecision(
769
751
  loop: LoopState,
770
- decision: Extract<TickDecision, { action: "expire" | "stop" | "pause" }>,
752
+ decision: Extract<TickDecision, { action: "expire" | "stop" }>,
771
753
  ): void {
772
754
  switch (decision.action) {
773
755
  case "expire":
@@ -777,19 +759,9 @@ export class LoopController {
777
759
  case "stop":
778
760
  this.transition(
779
761
  "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`,
762
+ decision.reason === "max-automatic-turns"
763
+ ? `the ${loop.maxAutomaticTurns}-automatic-turn cap was reached`
764
+ : `the ${loop.maxIterations}-iteration cap was reached`,
793
765
  );
794
766
  return;
795
767
  }
@@ -841,20 +813,11 @@ export class LoopController {
841
813
  * maxIterations cap on a poke that never arrived; on a throw the loop re-arms
842
814
  * on the same cadence and retries at the next wake.
843
815
  */
844
- private deliverPoke(
845
- env: TickEnvironment,
846
- reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed",
847
- ): void {
816
+ private deliverPoke(now: number, reason: "objective-stalled" | "wait-elapsed"): void {
848
817
  const loop = this.state;
849
818
  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
819
  try {
855
- this.pi.sendUserMessage(
856
- standalone ? buildObjectivePoke(loop, reason) : buildGoalPoke(loop, reason),
857
- );
820
+ this.pi.sendUserMessage(buildObjectivePoke(loop, reason));
858
821
  } catch (error) {
859
822
  this.sessionCtx?.ui.notify(
860
823
  `pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
@@ -872,7 +835,7 @@ export class LoopController {
872
835
  ...this.consumeWait(loop),
873
836
  iteration: loop.iteration + 1,
874
837
  automaticTurns: loop.automaticTurns + 1,
875
- lastWakeAt: env.now,
838
+ lastWakeAt: now,
876
839
  };
877
840
  this.persist();
878
841
  this.armFallback();
@@ -894,15 +857,11 @@ export class LoopController {
894
857
  if (!usage || typeof usage.tokens !== "number" || !usage.contextWindow) return false;
895
858
  if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
896
859
  }
897
- const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
898
860
  this.compacting = true;
899
861
  try {
900
862
  ctx.compact({
901
863
  customInstructions: buildCompactionInstructions(
902
864
  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
865
  this.settings.compaction.instructions,
907
866
  this.ledger,
908
867
  ),
@@ -937,7 +896,7 @@ export class LoopController {
937
896
  */
938
897
  private requestReAnchor(result: unknown): void {
939
898
  const loop = this.state;
940
- if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return;
899
+ if (!loop || loop.status !== "active") return;
941
900
  const summary =
942
901
  isRecord(result) && typeof result.summary === "string" ? result.summary : undefined;
943
902
  this.requestContinuation(loop, "reanchor", summary ? extractNextActions(summary) : undefined);
@@ -1039,7 +998,6 @@ export class LoopController {
1039
998
  `Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
1040
999
  ];
1041
1000
  if (loop.objective) {
1042
- lines.push("Mode: standalone (this loop owns its completion criteria)");
1043
1001
  lines.push(`Objective: ${loop.objective}`);
1044
1002
  if (this.ledger) {
1045
1003
  const criteria = this.criteria();
@@ -1059,17 +1017,11 @@ export class LoopController {
1059
1017
  } else {
1060
1018
  lines.push("Ledger: unavailable (the loop runs without one)");
1061
1019
  }
1062
- } else {
1063
- lines.push("Mode: goal-bound (pi-goal owns completion)");
1064
1020
  }
1065
1021
  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
1022
  if (this.nextWakeAt && loop.status === "active") {
1071
1023
  lines.push(
1072
- `${isStandaloneLoop(loop) ? "Next fallback wake" : "Next wake"}: ${formatClock(this.nextWakeAt)}${
1024
+ `Next fallback wake: ${formatClock(this.nextWakeAt)}${
1073
1025
  this.noOpStreak > 0
1074
1026
  ? ` (backed off ×${Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak)} after ${this.noOpStreak} no-op wake${this.noOpStreak === 1 ? "" : "s"})`
1075
1027
  : ""
@@ -1090,36 +1042,29 @@ export class LoopController {
1090
1042
  // --- command actions ---
1091
1043
 
1092
1044
  /**
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.
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.
1100
1048
  */
1101
- startLoop(ctx: ExtensionCommandContext, start: LoopStartArguments): void {
1049
+ startLoop(ctx: ExtensionContext, start: LoopStartArguments): LoopStartResult {
1102
1050
  this.sessionCtx = ctx;
1103
1051
  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;
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
+ };
1113
1059
  }
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;
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
+ };
1123
1068
  }
1124
1069
  const expiryMs =
1125
1070
  start.expiresInMs ?? parseDuration(this.settings.maxLoopDuration) ?? 604_800_000;
@@ -1129,13 +1074,10 @@ export class LoopController {
1129
1074
  : this.settings.compaction.enabled
1130
1075
  ? this.settings.compaction.threshold
1131
1076
  : null;
1132
- this.state = {
1077
+ const started: LoopState = {
1133
1078
  id: randomUUID().slice(0, 8),
1134
1079
  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 } : {}),
1080
+ objective,
1139
1081
  intervalMs: start.intervalMs,
1140
1082
  maxIterations:
1141
1083
  start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
@@ -1146,6 +1088,7 @@ export class LoopController {
1146
1088
  startedAt: now,
1147
1089
  expiresAt: now + expiryMs,
1148
1090
  };
1091
+ this.state = started;
1149
1092
  this.wakePending = false;
1150
1093
  this.continuationIntent = undefined;
1151
1094
  this.noOpStreak = 0;
@@ -1157,16 +1100,8 @@ export class LoopController {
1157
1100
  const clampNote = start.clamped
1158
1101
  ? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
1159
1102
  : "";
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
1103
  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).`,
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).`,
1170
1105
  "info",
1171
1106
  );
1172
1107
  if (this.ledger) {
@@ -1183,13 +1118,12 @@ export class LoopController {
1183
1118
  // The kickoff anchor: one stored message per loop holding the objective
1184
1119
  // data, because the system append exists only while the loop is active.
1185
1120
  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
- }
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 };
1193
1127
  }
1194
1128
 
1195
1129
  /**
@@ -1202,7 +1136,7 @@ export class LoopController {
1202
1136
  */
1203
1137
  private sendKickoffAnchor(ctx: ExtensionContext): void {
1204
1138
  const loop = this.state;
1205
- if (!loop || !isStandaloneLoop(loop) || !this.ledger) return;
1139
+ if (!loop || loop.objective === undefined || !this.ledger) return;
1206
1140
  const idle = ctx.isIdle() && !ctx.hasPendingMessages();
1207
1141
  try {
1208
1142
  this.pi.sendMessage(
@@ -1241,19 +1175,6 @@ export class LoopController {
1241
1175
  this.transition("stopped", "loop expired (maxLoopDuration reached)");
1242
1176
  return;
1243
1177
  }
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
1178
  // Resuming starts a fresh safety epoch: the user has seen why it paused
1258
1179
  // and chosen to continue, so the breaker must not trip on stale counters.
1259
1180
  const { pauseCause: _cause, lastFingerprint: _fingerprint, ...rest } = loop;
@@ -1263,13 +1184,11 @@ export class LoopController {
1263
1184
  this.scheduleTick(loop.intervalMs);
1264
1185
  this.updateWidget();
1265
1186
  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)}.`,
1187
+ `Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`,
1269
1188
  "info",
1270
1189
  );
1271
- // Resuming a standalone loop resumes the work, not just the heartbeat.
1272
- if (this.state && isStandaloneLoop(this.state)) {
1190
+ // Resuming resumes the work, not just the heartbeat.
1191
+ if (this.state) {
1273
1192
  this.requestContinuation(this.state);
1274
1193
  this.dispatchContinuationIfSettled(ctx);
1275
1194
  }