@hank-warren/pi-loop 0.8.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.
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 {
@@ -14,134 +20,144 @@ import {
14
20
  saveLoopSettings,
15
21
  } from "./settings.js";
16
22
  import { parseDuration } from "./interval.js";
17
- import { renderProposalCard } from "./planning.js";
23
+ import type { LoopStartArguments } from "./command.js";
24
+ import { startLoopInFreshSession } from "./fresh-launch.js";
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";
28
+ import type { LoopProposal } from "./planning.js";
29
+ import { loopWidgetLine } from "./widget.js";
18
30
 
19
- export async function showLoopManager(
31
+ /** The launch menu: no loop, no draft, nothing planning. */
32
+ export async function showLoopLaunch(
20
33
  controller: LoopController,
21
34
  ctx: ExtensionCommandContext,
35
+ startPlanning: () => void,
22
36
  ): Promise<void> {
23
37
  if (ctx.mode !== "tui") {
24
- 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
+ );
25
42
  return;
26
43
  }
27
- for (;;) {
28
- const loop = controller.state;
29
- const options: string[] = ["Status"];
30
- if (loop?.status === "active") options.push("Pause", "Edit focus", "Edit interval", "Stop");
31
- else if (loop?.status === "paused") options.push("Resume", "Edit focus", "Edit interval", "Stop");
32
- else options.push("Start a loop…");
33
- options.push("Settings");
34
- const choice = await ctx.ui.select(`Pi Loop${loop ? ` · ${loop.status}` : ""}`, options);
35
- if (choice === undefined) return;
36
- switch (choice) {
37
- case "Status":
38
- notifyStatus(controller, ctx);
39
- break;
40
- case "Pause":
41
- controller.pauseLoop(ctx);
42
- break;
43
- case "Resume":
44
- controller.resumeLoop(ctx);
45
- break;
46
- case "Stop":
47
- controller.stopLoop(ctx);
48
- break;
49
- case "Start a loop…":
50
- await startFromMenu(controller, ctx);
51
- break;
52
- case "Edit focus":
53
- await editPrompt(controller, ctx);
54
- break;
55
- case "Edit interval":
56
- await editInterval(controller, ctx);
57
- break;
58
- case "Settings":
59
- await showLoopSettings(controller, ctx);
60
- break;
61
- default:
62
- return;
63
- }
64
- }
65
- }
66
-
67
- function notifyStatus(controller: LoopController, ctx: ExtensionCommandContext): void {
68
- 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
+ });
69
50
  }
70
51
 
71
- async function startFromMenu(
52
+ /** The planning menu: a drafting conversation is open with no draft yet. */
53
+ export async function showLoopPlanning(
72
54
  controller: LoopController,
73
55
  ctx: ExtensionCommandContext,
56
+ options: { requestProposal: () => void },
74
57
  ): Promise<void> {
75
- const intervalText = await ctx.ui.input("Wake interval (e.g. 5m, 2h)", "5m");
76
- if (intervalText === undefined) return;
77
- const interval = parseInterval(intervalText.trim() || "5m");
78
- if (!interval) {
79
- 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
+ );
80
63
  return;
81
64
  }
82
- // The loop owns its objective, so the text is required — asking for it here
83
- // is what replaces the old dead-end refusal.
84
- const promptText = await ctx.ui.input(
85
- "Objective, including how the loop knows it is done",
86
- "e.g. get CI green on main, verified by a passing run",
87
- );
88
- if (promptText === undefined) return;
89
- const prompt = promptText.trim();
90
- if (!prompt) {
91
- 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);
92
83
  return;
93
84
  }
94
- const result = controller.startLoop(ctx, {
95
- kind: "start",
96
- requestedMs: interval.requestedMs,
97
- intervalMs: interval.effectiveMs,
98
- clamped: interval.clamped,
99
- ...(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),
100
95
  });
101
- if (!result.ok) ctx.ui.notify(result.message, "error");
102
96
  }
103
97
 
104
- 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 {
105
103
  const loop = controller.state;
106
- if (!loop || loop.status === "stopped") return;
107
- const next = await ctx.ui.input(
108
- "Loop focus (optional, restated on every loop message)",
109
- loop.prompt ?? "",
110
- );
111
- if (next === undefined) return;
112
- 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();
113
128
  if (prompt) controller.state = { ...loop, prompt };
114
129
  else {
115
130
  const { prompt: _dropped, ...rest } = loop;
116
131
  controller.state = rest;
117
132
  }
118
133
  controller.persist();
119
- 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;
120
137
  }
121
138
 
122
- async function editInterval(
139
+ function setCadence(
123
140
  controller: LoopController,
124
141
  ctx: ExtensionCommandContext,
125
- ): Promise<void> {
142
+ value: string,
143
+ ): boolean {
126
144
  const loop = controller.state;
127
- if (!loop || loop.status === "stopped") return;
128
- const next = await ctx.ui.input("Wake interval (e.g. 5m, 2h)", formatDuration(loop.intervalMs));
129
- if (next === undefined) return;
130
- const interval = parseInterval(next.trim());
145
+ if (!loop || loop.status === "stopped") return false;
146
+ const interval = parseInterval(value.trim());
131
147
  if (!interval) {
132
- ctx.ui.notify(`Invalid interval: ${next}. Use <number><unit>, e.g. 5m.`, "error");
133
- return;
148
+ ctx.ui.notify(`Invalid interval: ${value}. Use <number><unit>, e.g. 5m.`, "error");
149
+ return false;
134
150
  }
135
151
  controller.state = { ...loop, intervalMs: interval.effectiveMs };
136
152
  controller.persist();
137
- if (loop.status === "active") {
138
- // Re-arm on the new cadence from now.
139
- controller.resumeAfterEdit();
140
- }
153
+ // Re-arm on the new cadence from now.
154
+ if (loop.status === "active") controller.resumeAfterEdit();
155
+ else controller.updateWidget();
141
156
  ctx.ui.notify(
142
157
  `Loop interval set to ${formatDuration(interval.effectiveMs)}${interval.clamped ? " (clamped to the minimum)" : ""}.`,
143
158
  "info",
144
159
  );
160
+ return true;
145
161
  }
146
162
 
147
163
  export async function showLoopSettings(
@@ -166,7 +182,8 @@ export async function showLoopSettings(
166
182
  const next = structuredClone(s);
167
183
  if (index === 0) {
168
184
  // Unlimited is a first-class choice, not a magic word typed into a free
169
- // 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.
170
187
  const cap = await editCap(ctx, "Max loop turns", "no turn cap", s.maxTurns);
171
188
  if (cap === undefined) continue;
172
189
  next.maxTurns = cap === "unlimited" ? null : cap;
@@ -214,8 +231,7 @@ export async function showLoopSettings(
214
231
  /**
215
232
  * One cap editor for every cap. Unlimited is a first-class choice, not a
216
233
  * magic word typed into a free text box: it is only reachable by discovery
217
- * otherwise. The typed word still works, so the /loop --max vocabulary and
218
- * muscle memory keep working.
234
+ * otherwise. The typed word still works too.
219
235
  */
220
236
  async function editCap(
221
237
  ctx: ExtensionCommandContext,
@@ -272,11 +288,11 @@ function applySettings(
272
288
  * The approval card and its actions.
273
289
  *
274
290
  * This is where a planned loop starts, and the approval is what authorises it.
275
- * `loop_start`'s gate exists because a loop is self-continuing and must never
276
- * begin on model initiative; an explicit choice here, on a card showing the
277
- * objective, the derived criteria, the cadence and the caps, is stronger
278
- * evidence of intent than a typed token, so it starts the loop directly rather
279
- * 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.
280
296
  */
281
297
  export async function showLoopApproval(
282
298
  controller: LoopController,
@@ -284,59 +300,109 @@ export async function showLoopApproval(
284
300
  ): Promise<void> {
285
301
  const proposal = controller.planning.proposal;
286
302
  if (!proposal) return;
287
- ctx.ui.notify(renderProposalCard(proposal).join("\n"), "info");
303
+ // The card is an artifact, emitted once per draft; the menu below is the
304
+ // dialog over it. A draft proposed by loop_propose already has its card, so
305
+ // this only renders one when the user reached the approval some other way.
306
+ controller.showProposalCard(ctx);
288
307
  if (ctx.mode !== "tui") {
289
- ctx.ui.notify(
290
- "Approve it from a TUI session, or start it directly with /loop <interval> <objective>.",
291
- "info",
292
- );
308
+ ctx.ui.notify("Approve it from a TUI session; a loop cannot be started headless.", "info");
293
309
  return;
294
310
  }
295
- const choice = await ctx.ui.select("Start this loop?", [
296
- "Start loop",
297
- "Change cadence",
298
- "Keep editing",
299
- "Cancel",
300
- ]);
301
- switch (choice) {
302
- case "Start loop": {
303
- const result = controller.startLoop(ctx, {
304
- kind: "start",
305
- requestedMs: proposal.intervalMs,
306
- intervalMs: proposal.intervalMs,
307
- clamped: false,
308
- maxTurns: proposal.maxTurns,
309
- expiresInMs: proposal.expiresInMs,
310
- prompt: proposal.objective,
311
- });
311
+ await showLoopApprovalMenu(ctx, {
312
+ proposal,
313
+ startHere: () => {
314
+ const result = controller.startLoop(ctx, startArgumentsFor(proposal));
312
315
  if (result.ok) controller.endPlanning();
313
316
  else ctx.ui.notify(result.message, "error");
314
- return;
315
- }
316
- case "Change cadence": {
317
- const text = await ctx.ui.input(
318
- "Fallback heartbeat (e.g. 30m). The loop advances whenever the session settles.",
319
- formatDuration(proposal.intervalMs),
320
- );
321
- if (text === undefined) return;
322
- const interval = parseInterval(text.trim());
323
- if (!interval) {
324
- ctx.ui.notify(`Invalid interval: ${text}. Use <number><unit>, e.g. 30m.`, "error");
325
- return;
326
- }
327
- controller.propose(proposal.objective, {
328
- intervalMs: interval.effectiveMs,
329
- maxTurns: proposal.maxTurns,
330
- expiresInMs: proposal.expiresInMs,
331
- });
332
- await showLoopApproval(controller, ctx);
333
- return;
334
- }
335
- case "Keep editing":
317
+ },
318
+ startFresh: async () => {
319
+ await startApprovedLoopFresh(controller, ctx, proposal);
320
+ },
321
+ changeCadence: async () => {
322
+ await changeCadence(controller, ctx, proposal);
323
+ },
324
+ keepEditing: () => {
336
325
  ctx.ui.notify("Still planning. Tell the agent what to change.", "info");
337
- return;
338
- default:
326
+ },
327
+ cancel: () => {
339
328
  controller.endPlanning();
340
329
  ctx.ui.notify("Loop planning cancelled. Nothing was started.", "info");
330
+ },
331
+ });
332
+ }
333
+
334
+ /** The approved draft, as the arguments both start paths take. */
335
+ function startArgumentsFor(proposal: LoopProposal): LoopStartArguments {
336
+ return {
337
+ kind: "start",
338
+ requestedMs: proposal.intervalMs,
339
+ intervalMs: proposal.intervalMs,
340
+ clamped: false,
341
+ maxTurns: proposal.maxTurns,
342
+ expiresInMs: proposal.expiresInMs,
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 } : {}),
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Build the loop here, install it over there. The build/install split is what
352
+ * makes this possible at all: the state has to exist before `newSession` so
353
+ * its `setup` can append it, and it must not be installed in this session or
354
+ * the planning session would start working the objective it is handing away.
355
+ */
356
+ async function startApprovedLoopFresh(
357
+ controller: LoopController,
358
+ ctx: ExtensionCommandContext,
359
+ proposal: LoopProposal,
360
+ ): Promise<void> {
361
+ const built = controller.buildLoop(startArgumentsFor(proposal));
362
+ if (!built.ok) {
363
+ ctx.ui.notify(built.message, "error");
364
+ return;
365
+ }
366
+ const result = await startLoopInFreshSession(ctx, {
367
+ built: built.built,
368
+ prepareLedger: () => controller.prepareLedgerFor(built.built),
369
+ });
370
+ switch (result.kind) {
371
+ case "started":
372
+ case "partial":
373
+ // The draft has been handed off either way: the planning session must
374
+ // not keep offering to start it a second time.
375
+ controller.endPlanning();
376
+ return;
377
+ case "cancelled":
378
+ return;
379
+ default:
380
+ ctx.ui.notify(result.detail, "error");
341
381
  }
342
382
  }
383
+
384
+ async function changeCadence(
385
+ controller: LoopController,
386
+ ctx: ExtensionCommandContext,
387
+ proposal: LoopProposal,
388
+ ): Promise<void> {
389
+ const text = await ctx.ui.input(
390
+ "Fallback heartbeat (e.g. 30m). The loop advances whenever the session settles.",
391
+ formatDuration(proposal.intervalMs),
392
+ );
393
+ if (text === undefined) return;
394
+ const interval = parseInterval(text.trim());
395
+ if (!interval) {
396
+ ctx.ui.notify(`Invalid interval: ${text}. Use <number><unit>, e.g. 30m.`, "error");
397
+ return;
398
+ }
399
+ // A new draft, so it gets a new card: the cadence on the old one is no
400
+ // longer what would start.
401
+ controller.propose(proposal.objective, {
402
+ intervalMs: interval.effectiveMs,
403
+ maxTurns: proposal.maxTurns,
404
+ expiresInMs: proposal.expiresInMs,
405
+ ...(proposal.groundRules ? { groundRules: proposal.groundRules } : {}),
406
+ });
407
+ await showLoopApproval(controller, ctx);
408
+ }
package/src/messages.ts CHANGED
@@ -20,7 +20,7 @@ export type ContinuationKind = "kickoff" | "continue" | "reanchor";
20
20
  * counter against the delivered-wake cap; that cap is gone, collapsed into
21
21
  * the single loop-turn cap, and pairing a wake number with a turn cap would
22
22
  * have been a number that reads as a budget and is not one. The cap is shown
23
- * to the *user*, in the widget and `/loop status`, which is who it is for.
23
+ * to the *user*, in the widget and the /loop status screen, which is who it is for.
24
24
  */
25
25
  function formatWakeOrdinal(loop: LoopState): string {
26
26
  return `${loop.iteration + 1}`;
package/src/objective.ts CHANGED
@@ -32,6 +32,7 @@ export function buildLoopObjectivePrompt(
32
32
  "<loop_objective>",
33
33
  escapeXmlText(loop.objective),
34
34
  "</loop_objective>",
35
+ ...groundRuleLines(loop),
35
36
  `<loop_id>\n${escapeXmlText(loop.id)}\n</loop_id>`,
36
37
  "This loop_id is only the loop_complete tool's stale-loop guard, not part of the objective.",
37
38
  "",
@@ -52,7 +53,19 @@ export function buildLoopObjectivePrompt(
52
53
  "- You are running unattended. A prompt that blocks on a human — a permission approval, a clarifying question, any tool that waits for an answer — does not pause this loop, it deadlocks it: the session stays busy, so no continuation fires, no wake lands, and no cap trips. Nothing ends the loop until it expires. Plan to work without prompting.",
53
54
  "- Decide rather than ask. Take the reversible option, record the decision and the reasoning behind it in the ledger, and keep going. A decision written down is worth more than a question nobody is there to answer.",
54
55
  "- When you genuinely need a human, call loop_wait: it is the only way to ask that does not deadlock the session. Put the options in the ledger first, so the answer can be one word.",
55
- "- Never reshape a command to get past a permission prompt. A blocked command means stop and ask through loop_wait, never find another way around it.",
56
+ // The old wording forbade reshaping a blocked command outright, and a
57
+ // permission guardian that blocks with a stated concern depends on exactly
58
+ // that: its block is an instruction to fix the named problem. Both cannot
59
+ // stand, and "never reshape" is the one that was wrong — it made every
60
+ // block terminal, including the ones that named a one-word fix. The line
61
+ // that matters is not whether the command changes but what the change is
62
+ // aimed at: satisfying the concern, or getting around the gate that raised
63
+ // it. So the prohibition is stated against the aim, and the number of
64
+ // attempts is bounded so that "revise to address it" cannot decay into
65
+ // "retry until it passes".
66
+ "- Never reshape a command to get around a permission gate. Splitting it up, obfuscating it, routing it through another tool, or retrying variations until one is allowed are all the same move, and it is forbidden however the loop is going.",
67
+ "- A block that states a concern is different: it names something to fix, and fixing exactly that is legitimate. Revise only to satisfy the stated concern, and only while the block says rounds remain against it. When they run out, the block will say so — stop revising and call loop_wait.",
68
+ "- A block that states no concern, or one you cannot address without widening what the command does, is already final. Do not spend the rounds; call loop_wait.",
56
69
  "- Prefer the undoable. Nobody is watching to catch a bad call, so when two paths are close, take the one that is cheap to reverse.",
57
70
  ...(ledger ? ledgerRules(ledger) : []),
58
71
  `${focus}`,
@@ -61,6 +74,30 @@ export function buildLoopObjectivePrompt(
61
74
  .trimEnd();
62
75
  }
63
76
 
77
+ /**
78
+ * The approved ground rules, as a block the model cannot mistake for the
79
+ * objective.
80
+ *
81
+ * They sit next to the objective rather than inside it because they are a
82
+ * different kind of thing: the objective is what the loop is trying to reach
83
+ * and what `loop_complete` answers for, while these bound how it may get
84
+ * there. Folding them into the objective would make them criteria, and a
85
+ * constraint that has to be "met" is a constraint nobody can satisfy.
86
+ *
87
+ * Approved by the user on the card, so unlike the objective they are not
88
+ * merely task data to consider — they outrank the loop's own judgement about
89
+ * what is expedient at 3am on turn 200.
90
+ */
91
+ function groundRuleLines(loop: LoopState): string[] {
92
+ if (!loop.groundRules || loop.groundRules.length === 0) return [];
93
+ return [
94
+ "",
95
+ "Ground rules (hard constraints, never violate):",
96
+ ...loop.groundRules.map((rule) => `- ${escapeXmlText(rule)}`),
97
+ "The user approved these with the objective. They bound how the work may be done, they are never satisfied or completed, and no amount of progress justifies breaking one. If the only way forward violates a ground rule, stop and call loop_wait.",
98
+ ];
99
+ }
100
+
64
101
  /**
65
102
  * The ledger contract. Stable per loop (the path is derived from the loop
66
103
  * id), so it keeps the append byte-identical across turns.