@hank-warren/pi-loop 0.9.0 → 1.0.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,191 @@
1
+ /**
2
+ * The manager: every lifecycle control a running loop has.
3
+ *
4
+ * Pause, resume, stop, status, focus and cadence used to be typed
5
+ * subcommands (`/loop pause`, `/loop status`, …). They are here instead,
6
+ * because a lifecycle control nobody can discover is a control nobody uses,
7
+ * and because the loop is the kind of thing you reach for when you want to
8
+ * *look* at it — at which point a menu that shows the state and offers the
9
+ * actions beats remembering six words.
10
+ *
11
+ * Pause and Resume are mutually exclusive by construction: the screen is
12
+ * built from the loop's status, so a paused loop never offers Pause and an
13
+ * active one never offers Resume. Pinned in the tests, because an item that
14
+ * silently does nothing is the failure mode a menu invites.
15
+ */
16
+
17
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import type { ActionsScreen, DetailScreen, InputScreen } from "@narumitw/pi-tui-kit";
19
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
20
+
21
+ export type LoopManagerScreen = "main" | "status" | "focus" | "cadence";
22
+ export type LoopManagerAction =
23
+ | "pause"
24
+ | "resume"
25
+ | "stop"
26
+ | "settings"
27
+ | "set-focus"
28
+ | "set-cadence";
29
+
30
+ /** What the manager renders: the loop as the menu needs to see it. */
31
+ export interface LoopManagerView {
32
+ /** `stopped` never reaches here; the menu only exists for a live loop. */
33
+ status: "active" | "paused";
34
+ /** The widget line, so the manager and the footer tell the same story. */
35
+ headline: string;
36
+ /** Full status detail, one line each. */
37
+ statusLines: readonly string[];
38
+ /** The loop's recurring focus, when it has one. */
39
+ focus?: string;
40
+ /** The current fallback heartbeat, formatted. */
41
+ interval: string;
42
+ }
43
+
44
+ export function loopManagerScreen(
45
+ view: LoopManagerView,
46
+ ): ActionsScreen<LoopManagerScreen, LoopManagerAction> {
47
+ return {
48
+ kind: "actions",
49
+ title: `Loop · ${view.status}`,
50
+ lines: [view.headline],
51
+ items: [
52
+ { id: "status", label: "Status", description: "The loop's full state.", to: "status" },
53
+ ...(view.status === "active"
54
+ ? ([
55
+ {
56
+ id: "pause",
57
+ label: "Pause",
58
+ description: "Stop continuing and waking. The loop keeps its state.",
59
+ action: "pause" as const,
60
+ },
61
+ ] as const)
62
+ : ([
63
+ {
64
+ id: "resume",
65
+ label: "Resume",
66
+ description: "Continue the objective now, and re-arm the heartbeat.",
67
+ action: "resume" as const,
68
+ },
69
+ ] as const)),
70
+ {
71
+ id: "focus",
72
+ label: "Edit focus…",
73
+ description: "A recurring note restated on every loop message.",
74
+ to: "focus",
75
+ },
76
+ {
77
+ id: "cadence",
78
+ label: "Edit cadence…",
79
+ description: `Fallback heartbeat, currently every ${view.interval}.`,
80
+ to: "cadence",
81
+ },
82
+ {
83
+ id: "stop",
84
+ label: "Stop",
85
+ description: "End the loop. The ledger stays on disk.",
86
+ action: "stop",
87
+ },
88
+ { id: "settings", label: "Settings", action: "settings" },
89
+ ],
90
+ hint: "close",
91
+ };
92
+ }
93
+
94
+ export function loopStatusScreen(view: LoopManagerView): DetailScreen {
95
+ return {
96
+ kind: "detail",
97
+ title: "Loop status",
98
+ lines: [...view.statusLines],
99
+ hint: "back",
100
+ };
101
+ }
102
+
103
+ export function loopFocusScreen(view: LoopManagerView): InputScreen<LoopManagerAction> {
104
+ return {
105
+ kind: "input",
106
+ title: "Loop focus",
107
+ lines: [
108
+ view.focus ? `Currently: ${view.focus}` : "No focus set.",
109
+ "Restated on every loop message. Leave empty to clear it.",
110
+ ],
111
+ placeholder: view.focus ?? "e.g. keep the diff small and reversible",
112
+ action: "set-focus",
113
+ hint: "back",
114
+ };
115
+ }
116
+
117
+ export function loopCadenceScreen(view: LoopManagerView): InputScreen<LoopManagerAction> {
118
+ return {
119
+ kind: "input",
120
+ title: "Fallback heartbeat",
121
+ lines: [
122
+ `Currently every ${view.interval}.`,
123
+ "The loop advances whenever the session settles; this only fires when it has gone quiet.",
124
+ ],
125
+ placeholder: view.interval,
126
+ action: "set-cadence",
127
+ hint: "back",
128
+ };
129
+ }
130
+
131
+ export interface LoopManagerMenuOptions {
132
+ getView(): LoopManagerView | undefined;
133
+ signal?: AbortSignal;
134
+ isCurrent?(): boolean;
135
+ pause(): void;
136
+ resume(): void;
137
+ stop(): void;
138
+ settings(signal: AbortSignal): Promise<void>;
139
+ /** Returns false when the value was rejected, so the input screen stays open. */
140
+ setFocus(value: string): boolean;
141
+ setCadence(value: string): boolean;
142
+ }
143
+
144
+ export async function showLoopManagerMenu(ctx: ExtensionContext, options: LoopManagerMenuOptions) {
145
+ const first = options.getView();
146
+ if (!first) return;
147
+ // The view is re-read on every render: a pause taken from this menu has to
148
+ // turn the Pause item into Resume without closing and reopening it.
149
+ const view = () => options.getView() ?? first;
150
+ const menu = defineMenu<
151
+ LoopManagerView,
152
+ LoopManagerScreen,
153
+ LoopManagerAction,
154
+ ExtensionContext
155
+ >({
156
+ start: "main",
157
+ screens: {
158
+ main: ({ state }) => loopManagerScreen(state),
159
+ status: ({ state }) => loopStatusScreen(state),
160
+ focus: ({ state }) => loopFocusScreen(state),
161
+ cadence: ({ state }) => loopCadenceScreen(state),
162
+ },
163
+ actions: {
164
+ pause: async () => {
165
+ options.pause();
166
+ return { kind: "stay" };
167
+ },
168
+ resume: async () => {
169
+ options.resume();
170
+ return { kind: "stay" };
171
+ },
172
+ stop: async () => {
173
+ options.stop();
174
+ return { kind: "close" };
175
+ },
176
+ settings: async ({ signal }) => {
177
+ await options.settings(signal);
178
+ return { kind: "stay" };
179
+ },
180
+ "set-focus": async ({ value }) =>
181
+ options.setFocus(value ?? "") ? { kind: "to", screen: "main" } : { kind: "rejected" },
182
+ "set-cadence": async ({ value }) =>
183
+ options.setCadence(value ?? "") ? { kind: "to", screen: "main" } : { kind: "rejected" },
184
+ },
185
+ });
186
+ return runMenu(ctx, menu, {
187
+ getState: view,
188
+ ...(options.signal ? { signal: options.signal } : {}),
189
+ ...(options.isCurrent ? { isCurrent: options.isCurrent } : {}),
190
+ });
191
+ }
package/src/loop.ts CHANGED
@@ -89,6 +89,8 @@ import {
89
89
  buildProposal,
90
90
  type LoopPlanningState,
91
91
  type LoopProposal,
92
+ type LoopProposalOverrides,
93
+ normalizeGroundRules,
92
94
  } from "./planning.js";
93
95
  import {
94
96
  clearLoopWidget,
@@ -130,10 +132,9 @@ type RunOrigin = "continuation" | "fallback";
130
132
  * The outcome of a start attempt.
131
133
  *
132
134
  * `startLoop` used to report its refusals by calling `ctx.ui.notify` itself,
133
- * which tied the only start path to a UI. Two callers now share that path —
134
- * the `/loop` command and the `loop_start` tool and the tool has to turn
135
- * the same refusal into tool content rather than a toast, so the decision is
136
- * returned and each caller renders it.
135
+ * which tied the only start path to a UI. The approval card's two start
136
+ * actions share that path one installs the loop here, the other hands it to
137
+ * a fresh session so the decision is returned and each caller renders it.
137
138
  */
138
139
  export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
139
140
 
@@ -293,7 +294,7 @@ export class LoopController {
293
294
  this.state = rest;
294
295
  this.persist();
295
296
  ctx.ui.notify(
296
- "Loop started in this session: only the objective crossed over, not the planning conversation. It works from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you run /loop stop.",
297
+ "Loop started in this session: only the objective crossed over, not the planning conversation. It works from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you stop it from the /loop menu.",
297
298
  "info",
298
299
  );
299
300
  this.sendKickoffAnchor(ctx);
@@ -371,14 +372,14 @@ export class LoopController {
371
372
  case "usage-limited":
372
373
  this.transition(
373
374
  "paused",
374
- "the provider reports the usage limit is reached; resume with /loop resume once it resets",
375
+ "the provider reports the usage limit is reached; resume it from the /loop menu once it resets",
375
376
  "usage limit reached",
376
377
  );
377
378
  return true;
378
379
  case "fatal":
379
380
  this.transition(
380
381
  "paused",
381
- "the turn failed with an error a retry cannot fix; resolve it, then /loop resume",
382
+ "the turn failed with an error a retry cannot fix; resolve it, then resume it from the /loop menu",
382
383
  "unrecoverable provider error",
383
384
  );
384
385
  return true;
@@ -386,7 +387,7 @@ export class LoopController {
386
387
  // Esc, or another extension stopping the turn. A loop-caused run
387
388
  // that the user interrupted must not be immediately re-sent.
388
389
  if (origin === undefined) return false;
389
- this.transition("paused", "the turn was interrupted; resume with /loop resume", "interrupted");
390
+ this.transition("paused", "the turn was interrupted; resume it from the /loop menu", "interrupted");
390
391
  return true;
391
392
  case "context-overflow":
392
393
  // The request no longer fits: compact first, then continue. The
@@ -408,7 +409,7 @@ export class LoopController {
408
409
  * The no-progress breaker: consecutive tool-free loop turns with identical
409
410
  * visible output pause the loop instead of waking it again forever. It
410
411
  * pauses rather than stops, so the loop stays configured and one
411
- * `/loop resume` (or the next user prompt) puts it back to work.
412
+ * Resuming from the /loop menu (or the next user prompt) puts it back to work.
412
413
  */
413
414
  private enforceNoProgress(
414
415
  ctx: ExtensionContext,
@@ -451,7 +452,7 @@ export class LoopController {
451
452
  void ctx;
452
453
  this.transition(
453
454
  "paused",
454
- `${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`,
455
+ `${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so resuming from the /loop menu (or your next message) continues it`,
455
456
  "no progress",
456
457
  );
457
458
  return true;
@@ -534,7 +535,7 @@ export class LoopController {
534
535
  if (!objective) {
535
536
  this.transition(
536
537
  "paused",
537
- "it was bound to a goal that is gone and has no objective of its own; start a new loop with /loop <interval> <objective>",
538
+ "it was bound to a goal that is gone and has no objective of its own; run /loop to plan and approve a new one",
538
539
  "loop with no objective",
539
540
  );
540
541
  return true;
@@ -564,7 +565,7 @@ export class LoopController {
564
565
  if (this.completeToolAvailable()) return false;
565
566
  this.transition(
566
567
  "paused",
567
- `the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then /loop resume`,
568
+ `the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then resume the loop from the /loop menu`,
568
569
  "loop_complete unavailable",
569
570
  );
570
571
  void ctx;
@@ -598,7 +599,7 @@ export class LoopController {
598
599
  if (this.deadDeliveries < MAX_DEAD_DELIVERIES) return false;
599
600
  this.transition(
600
601
  "paused",
601
- `${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then /loop resume`,
602
+ `${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then resume the loop from the /loop menu`,
602
603
  "deliveries produce no turns",
603
604
  );
604
605
  return true;
@@ -679,7 +680,7 @@ export class LoopController {
679
680
  * no writable ledger still runs, it just loses the durable record, so the
680
681
  * failure is warned once and never repeated.
681
682
  *
682
- * `criteria` is passed at start: the criteria proposed at `loop_start`, or
683
+ * `criteria` is passed at start: the criteria approved with the draft, or
683
684
  * the deterministic split of the objective. On restore it is omitted, and
684
685
  * the criteria already on disk are authoritative — they are the ones the
685
686
  * user saw echoed, and re-deriving them would both discard a proposed set
@@ -817,7 +818,7 @@ export class LoopController {
817
818
  // Nothing is scheduled once the timer has fired. `runTick` re-arms it
818
819
  // through `scheduleTick` when it pokes, but a busy or compacting
819
820
  // session coalesces into `wakePending` instead — and leaving the old
820
- // deadline here made `/loop status` report a clock time that had
821
+ // deadline here made the /loop status screen report a clock time that had
821
822
  // already passed.
822
823
  this.nextWakeAt = undefined;
823
824
  const ctx = this.sessionCtx;
@@ -1128,7 +1129,7 @@ export class LoopController {
1128
1129
 
1129
1130
  statusLines(ctx: ExtensionContext): string[] {
1130
1131
  const loop = this.state;
1131
- if (!loop) return ["No loop in this session. Start one with /loop <interval> [prompt]."];
1132
+ if (!loop) return ["No loop in this session. Run /loop to plan one."];
1132
1133
  const lines = [
1133
1134
  `Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
1134
1135
  ...(loop.waiting
@@ -1208,10 +1209,7 @@ export class LoopController {
1208
1209
  }
1209
1210
 
1210
1211
  /** Record a drafted loop for approval, replacing any previous draft. */
1211
- propose(
1212
- objective: string,
1213
- overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
1214
- ): LoopProposal {
1212
+ propose(objective: string, overrides: LoopProposalOverrides = {}): LoopProposal {
1215
1213
  const proposal = buildProposal(
1216
1214
  objective,
1217
1215
  {
@@ -1287,7 +1285,7 @@ export class LoopController {
1287
1285
  return {
1288
1286
  ok: false,
1289
1287
  message:
1290
- "A loop needs something to work on. Give it an objective: /loop <interval> <objective with completion criteria>.",
1288
+ "A loop needs something to work on. Run /loop and draft an objective with completion criteria first.",
1291
1289
  };
1292
1290
  }
1293
1291
  // A loop with no way to call loop_complete would work, finish, and then be
@@ -1307,10 +1305,12 @@ export class LoopController {
1307
1305
  : this.settings.compaction.enabled
1308
1306
  ? this.settings.compaction.threshold
1309
1307
  : null;
1308
+ const groundRules = normalizeGroundRules(start.groundRules);
1310
1309
  const loop: LoopState = {
1311
1310
  id: randomUUID().slice(0, 8),
1312
1311
  status: "active",
1313
1312
  objective,
1313
+ ...(groundRules ? { groundRules } : {}),
1314
1314
  intervalMs: start.intervalMs,
1315
1315
  maxTurns: start.maxTurns !== undefined ? start.maxTurns : this.settings.maxTurns,
1316
1316
  compactAt,
@@ -1356,7 +1356,7 @@ export class LoopController {
1356
1356
  ? ` (requested ${formatDuration(built.requestedMs)}, clamped to the ${formatDuration(started.intervalMs)} minimum)`
1357
1357
  : "";
1358
1358
  ctx.ui.notify(
1359
- `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(started.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(built.expiryMs)} (one final turn to write its state down, then it stops).`,
1359
+ `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 stop it from the /loop menu. Fallback wake every ${formatDuration(started.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(built.expiryMs)} (one final turn to write its state down, then it stops).`,
1360
1360
  "info",
1361
1361
  );
1362
1362
  if (this.ledger) {
package/src/manager.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  /**
2
- * Bare-/loop manager and /loop settings, built on Pi's native dialog
3
- * primitives (ui.select / ui.input / ui.confirm). Non-TUI modes get status
4
- * notifications instead of menus.
2
+ * The `/loop` surfaces, wired to the controller: the manager for a live loop,
3
+ * the approval card's actions, and the settings editor.
4
+ *
5
+ * The top-level menus are tui-kit screens (see `loop-manager-menu.ts` and
6
+ * `loop-launch-menu.ts`), so the family renders the same way pi-plan-mode
7
+ * does. The settings editor keeps its `ui.select`/`ui.input` internals: it is
8
+ * a value editor, not a navigation surface, and rewriting it would change
9
+ * nothing a user can see. Non-TUI modes get status notifications instead of
10
+ * menus, exactly as before.
5
11
  */
6
12
 
7
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
14
  import { formatDuration, parseInterval } from "./interval.js";
9
15
  import type { LoopController } from "./loop.js";
10
16
  import {
@@ -17,134 +23,141 @@ import { parseDuration } from "./interval.js";
17
23
  import type { LoopStartArguments } from "./command.js";
18
24
  import { startLoopInFreshSession } from "./fresh-launch.js";
19
25
  import { showLoopApprovalMenu } from "./loop-action-menus.js";
26
+ import { type LoopManagerView, showLoopManagerMenu } from "./loop-manager-menu.js";
27
+ import { showLoopLaunchMenu, showLoopPlanningMenu } from "./loop-launch-menu.js";
20
28
  import type { LoopProposal } from "./planning.js";
29
+ import { loopWidgetLine } from "./widget.js";
21
30
 
22
- export async function showLoopManager(
31
+ /** The launch menu: no loop, no draft, nothing planning. */
32
+ export async function showLoopLaunch(
23
33
  controller: LoopController,
24
34
  ctx: ExtensionCommandContext,
35
+ startPlanning: () => void,
25
36
  ): Promise<void> {
26
37
  if (ctx.mode !== "tui") {
27
- notifyStatus(controller, ctx);
38
+ ctx.ui.notify(
39
+ "No loop in this session. The interactive /loop menu is unavailable in print and JSON modes.",
40
+ "info",
41
+ );
28
42
  return;
29
43
  }
30
- for (;;) {
31
- const loop = controller.state;
32
- const options: string[] = ["Status"];
33
- if (loop?.status === "active") options.push("Pause", "Edit focus", "Edit interval", "Stop");
34
- else if (loop?.status === "paused") options.push("Resume", "Edit focus", "Edit interval", "Stop");
35
- else options.push("Start a loop…");
36
- options.push("Settings");
37
- const choice = await ctx.ui.select(`Pi Loop${loop ? ` · ${loop.status}` : ""}`, options);
38
- if (choice === undefined) return;
39
- switch (choice) {
40
- case "Status":
41
- notifyStatus(controller, ctx);
42
- break;
43
- case "Pause":
44
- controller.pauseLoop(ctx);
45
- break;
46
- case "Resume":
47
- controller.resumeLoop(ctx);
48
- break;
49
- case "Stop":
50
- controller.stopLoop(ctx);
51
- break;
52
- case "Start a loop…":
53
- await startFromMenu(controller, ctx);
54
- break;
55
- case "Edit focus":
56
- await editPrompt(controller, ctx);
57
- break;
58
- case "Edit interval":
59
- await editInterval(controller, ctx);
60
- break;
61
- case "Settings":
62
- await showLoopSettings(controller, ctx);
63
- break;
64
- default:
65
- return;
66
- }
67
- }
68
- }
69
-
70
- function notifyStatus(controller: LoopController, ctx: ExtensionCommandContext): void {
71
- ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
44
+ await showLoopLaunchMenu(ctx, {
45
+ startPlanning,
46
+ settings: async () => {
47
+ await showLoopSettings(controller, ctx);
48
+ },
49
+ });
72
50
  }
73
51
 
74
- async function startFromMenu(
52
+ /** The planning menu: a drafting conversation is open with no draft yet. */
53
+ export async function showLoopPlanning(
75
54
  controller: LoopController,
76
55
  ctx: ExtensionCommandContext,
56
+ options: { requestProposal: () => void },
77
57
  ): Promise<void> {
78
- const intervalText = await ctx.ui.input("Wake interval (e.g. 5m, 2h)", "5m");
79
- if (intervalText === undefined) return;
80
- const interval = parseInterval(intervalText.trim() || "5m");
81
- if (!interval) {
82
- ctx.ui.notify(`Invalid interval: ${intervalText}. Use <number><unit>, e.g. 5m.`, "error");
58
+ if (ctx.mode !== "tui") {
59
+ ctx.ui.notify(
60
+ "Loop planning is open: describe the objective, and the agent will put a loop up for approval.",
61
+ "info",
62
+ );
83
63
  return;
84
64
  }
85
- // The loop owns its objective, so the text is required — asking for it here
86
- // is what replaces the old dead-end refusal.
87
- const promptText = await ctx.ui.input(
88
- "Objective, including how the loop knows it is done",
89
- "e.g. get CI green on main, verified by a passing run",
90
- );
91
- if (promptText === undefined) return;
92
- const prompt = promptText.trim();
93
- if (!prompt) {
94
- ctx.ui.notify("A loop needs an objective, so no loop was started.", "warning");
65
+ await showLoopPlanningMenu(ctx, {
66
+ requestProposal: options.requestProposal,
67
+ cancelPlanning: () => {
68
+ controller.endPlanning();
69
+ ctx.ui.notify("Loop planning cancelled. Nothing was started.", "info");
70
+ },
71
+ settings: async () => {
72
+ await showLoopSettings(controller, ctx);
73
+ },
74
+ });
75
+ }
76
+
77
+ export async function showLoopManager(
78
+ controller: LoopController,
79
+ ctx: ExtensionCommandContext,
80
+ ): Promise<void> {
81
+ if (ctx.mode !== "tui") {
82
+ notifyStatus(controller, ctx);
95
83
  return;
96
84
  }
97
- const result = controller.startLoop(ctx, {
98
- kind: "start",
99
- requestedMs: interval.requestedMs,
100
- intervalMs: interval.effectiveMs,
101
- clamped: interval.clamped,
102
- ...(prompt ? { prompt } : {}),
85
+ await showLoopManagerMenu(ctx, {
86
+ getView: () => managerView(controller, ctx),
87
+ pause: () => controller.pauseLoop(ctx),
88
+ resume: () => controller.resumeLoop(ctx),
89
+ stop: () => controller.stopLoop(ctx),
90
+ settings: async () => {
91
+ await showLoopSettings(controller, ctx);
92
+ },
93
+ setFocus: (value) => setFocus(controller, ctx, value),
94
+ setCadence: (value) => setCadence(controller, ctx, value),
103
95
  });
104
- if (!result.ok) ctx.ui.notify(result.message, "error");
105
96
  }
106
97
 
107
- async function editPrompt(controller: LoopController, ctx: ExtensionCommandContext): Promise<void> {
98
+ /** The live loop as the manager needs it, or undefined once it has stopped. */
99
+ function managerView(
100
+ controller: LoopController,
101
+ ctx: ExtensionContext,
102
+ ): LoopManagerView | undefined {
108
103
  const loop = controller.state;
109
- if (!loop || loop.status === "stopped") return;
110
- const next = await ctx.ui.input(
111
- "Loop focus (optional, restated on every loop message)",
112
- loop.prompt ?? "",
113
- );
114
- if (next === undefined) return;
115
- const prompt = next.trim();
104
+ if (!loop || loop.status === "stopped") return undefined;
105
+ const view = controller.widgetView();
106
+ return {
107
+ status: loop.status,
108
+ headline: view ? loopWidgetLine(view) : `loop ${loop.status}`,
109
+ statusLines: controller.statusLines(ctx),
110
+ ...(loop.prompt ? { focus: loop.prompt } : {}),
111
+ interval: formatDuration(loop.intervalMs),
112
+ };
113
+ }
114
+
115
+ function notifyStatus(controller: LoopController, ctx: ExtensionCommandContext): void {
116
+ ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
117
+ }
118
+
119
+ /** An empty value clears the focus; anything else replaces it. */
120
+ function setFocus(
121
+ controller: LoopController,
122
+ ctx: ExtensionCommandContext,
123
+ value: string,
124
+ ): boolean {
125
+ const loop = controller.state;
126
+ if (!loop || loop.status === "stopped") return false;
127
+ const prompt = value.trim();
116
128
  if (prompt) controller.state = { ...loop, prompt };
117
129
  else {
118
130
  const { prompt: _dropped, ...rest } = loop;
119
131
  controller.state = rest;
120
132
  }
121
133
  controller.persist();
122
- ctx.ui.notify("Loop focus updated.", "info");
134
+ controller.updateWidget();
135
+ ctx.ui.notify(prompt ? "Loop focus updated." : "Loop focus cleared.", "info");
136
+ return true;
123
137
  }
124
138
 
125
- async function editInterval(
139
+ function setCadence(
126
140
  controller: LoopController,
127
141
  ctx: ExtensionCommandContext,
128
- ): Promise<void> {
142
+ value: string,
143
+ ): boolean {
129
144
  const loop = controller.state;
130
- if (!loop || loop.status === "stopped") return;
131
- const next = await ctx.ui.input("Wake interval (e.g. 5m, 2h)", formatDuration(loop.intervalMs));
132
- if (next === undefined) return;
133
- const interval = parseInterval(next.trim());
145
+ if (!loop || loop.status === "stopped") return false;
146
+ const interval = parseInterval(value.trim());
134
147
  if (!interval) {
135
- ctx.ui.notify(`Invalid interval: ${next}. Use <number><unit>, e.g. 5m.`, "error");
136
- return;
148
+ ctx.ui.notify(`Invalid interval: ${value}. Use <number><unit>, e.g. 5m.`, "error");
149
+ return false;
137
150
  }
138
151
  controller.state = { ...loop, intervalMs: interval.effectiveMs };
139
152
  controller.persist();
140
- if (loop.status === "active") {
141
- // Re-arm on the new cadence from now.
142
- controller.resumeAfterEdit();
143
- }
153
+ // Re-arm on the new cadence from now.
154
+ if (loop.status === "active") controller.resumeAfterEdit();
155
+ else controller.updateWidget();
144
156
  ctx.ui.notify(
145
157
  `Loop interval set to ${formatDuration(interval.effectiveMs)}${interval.clamped ? " (clamped to the minimum)" : ""}.`,
146
158
  "info",
147
159
  );
160
+ return true;
148
161
  }
149
162
 
150
163
  export async function showLoopSettings(
@@ -169,7 +182,8 @@ export async function showLoopSettings(
169
182
  const next = structuredClone(s);
170
183
  if (index === 0) {
171
184
  // Unlimited is a first-class choice, not a magic word typed into a free
172
- // text box: it is only reachable by discovery otherwise.
185
+ // text box: it is only reachable by discovery otherwise. It is also the
186
+ // default, so this editor is where a user opts *into* a budget.
173
187
  const cap = await editCap(ctx, "Max loop turns", "no turn cap", s.maxTurns);
174
188
  if (cap === undefined) continue;
175
189
  next.maxTurns = cap === "unlimited" ? null : cap;
@@ -217,8 +231,7 @@ export async function showLoopSettings(
217
231
  /**
218
232
  * One cap editor for every cap. Unlimited is a first-class choice, not a
219
233
  * magic word typed into a free text box: it is only reachable by discovery
220
- * otherwise. The typed word still works, so the /loop --max vocabulary and
221
- * muscle memory keep working.
234
+ * otherwise. The typed word still works too.
222
235
  */
223
236
  async function editCap(
224
237
  ctx: ExtensionCommandContext,
@@ -275,11 +288,11 @@ function applySettings(
275
288
  * The approval card and its actions.
276
289
  *
277
290
  * This is where a planned loop starts, and the approval is what authorises it.
278
- * `loop_start`'s gate exists because a loop is self-continuing and must never
279
- * begin on model initiative; an explicit choice here, on a card showing the
280
- * objective, the derived criteria, the cadence and the caps, is stronger
281
- * evidence of intent than a typed token, so it starts the loop directly rather
282
- * than routing back through a tool the model could reach on its own.
291
+ * A loop is self-continuing and must never begin on model initiative; an
292
+ * explicit choice here, on a card showing the objective, the derived criteria,
293
+ * the ground rules, the cadence and the caps, is the strongest evidence of
294
+ * intent there is — stronger than any token the model could also emit so it
295
+ * starts the loop directly rather than routing back through a tool.
283
296
  */
284
297
  export async function showLoopApproval(
285
298
  controller: LoopController,
@@ -292,10 +305,7 @@ export async function showLoopApproval(
292
305
  // this only renders one when the user reached the approval some other way.
293
306
  controller.showProposalCard(ctx);
294
307
  if (ctx.mode !== "tui") {
295
- ctx.ui.notify(
296
- "Approve it from a TUI session, or start it directly with /loop <interval> <objective>.",
297
- "info",
298
- );
308
+ ctx.ui.notify("Approve it from a TUI session; a loop cannot be started headless.", "info");
299
309
  return;
300
310
  }
301
311
  await showLoopApprovalMenu(ctx, {
@@ -331,6 +341,9 @@ function startArgumentsFor(proposal: LoopProposal): LoopStartArguments {
331
341
  maxTurns: proposal.maxTurns,
332
342
  expiresInMs: proposal.expiresInMs,
333
343
  prompt: proposal.objective,
344
+ // The approved constraints cross with the objective; they are part of what
345
+ // the user said yes to.
346
+ ...(proposal.groundRules ? { groundRules: proposal.groundRules } : {}),
334
347
  };
335
348
  }
336
349
 
@@ -389,6 +402,7 @@ async function changeCadence(
389
402
  intervalMs: interval.effectiveMs,
390
403
  maxTurns: proposal.maxTurns,
391
404
  expiresInMs: proposal.expiresInMs,
405
+ ...(proposal.groundRules ? { groundRules: proposal.groundRules } : {}),
392
406
  });
393
407
  await showLoopApproval(controller, ctx);
394
408
  }