@hank-warren/pi-loop 0.7.0 → 0.8.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,119 @@
1
+ /**
2
+ * `loop_propose`: put a drafted loop up for the user's approval.
3
+ *
4
+ * It starts nothing. That separation is the point — the model drafts, the user
5
+ * approves, and the approval is what arms the start. `loop_start`'s gate
6
+ * exists because a loop is self-continuing and must never begin on model
7
+ * initiative; an explicit approval on a card showing the objective, the
8
+ * criteria, the cadence and the caps is stronger evidence of intent than a
9
+ * typed token, not weaker, so it arms the same gate rather than bypassing it.
10
+ *
11
+ * Registered unconditionally, like the other loop tools: the tool set is part
12
+ * of the cached request prefix, so it never changes with loop state.
13
+ */
14
+
15
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { Type } from "typebox";
17
+ import { MAX_INTERVAL_MS, parseDuration } from "./interval.js";
18
+ import type { LoopController } from "./loop.js";
19
+ import { renderProposalCard } from "./planning.js";
20
+
21
+ export const LOOP_PROPOSE_TOOL = "loop_propose";
22
+
23
+ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopController) {
24
+ pi.registerTool(
25
+ defineTool({
26
+ name: LOOP_PROPOSE_TOOL,
27
+ label: "Loop Propose",
28
+ description:
29
+ "Put a drafted loop objective up for the user's approval during loop planning. Renders an approval card showing the exact completion criteria the objective will produce. Starts nothing: the user approves, edits, or cancels.",
30
+ promptSnippet: "Propose a drafted loop objective for approval",
31
+ promptGuidelines: [
32
+ "Call loop_propose only while loop planning is open, and only once the objective reads as an acceptance test: one requirement per bullet, each naming the check that proves it.",
33
+ "Pass the objective you and the user agreed on, not a tidier version of it. The criteria are derived from this text and frozen when the loop starts.",
34
+ "Do not call loop_start for a planned loop; approving the card is what starts it.",
35
+ "loop_propose starts nothing, so the rule that a loop needs an inline /loop token in the user's message does not apply to it. While planning is open, a conversational request for a loop is the signal to draft one and propose it — never to refuse and ask the user to type /loop instead.",
36
+ ],
37
+ parameters: Type.Object({
38
+ objective: Type.String({
39
+ minLength: 1,
40
+ maxLength: 100_000,
41
+ description:
42
+ "The drafted objective, one requirement per bullet, each naming how it is verified.",
43
+ }),
44
+ interval: Type.Optional(
45
+ Type.String({
46
+ description:
47
+ "Fallback heartbeat as <number><unit> (s, m, h, d), e.g. 30m. Omit to use the configured default.",
48
+ }),
49
+ ),
50
+ max_turns: Type.Optional(
51
+ Type.Number({
52
+ description:
53
+ "Cap on loop-caused turns. Omit for the configured default; the user can change it on the card.",
54
+ }),
55
+ ),
56
+ expires: Type.Optional(
57
+ Type.String({
58
+ description: "Loop lifetime as <number><unit>, e.g. 3d. Omit for the default.",
59
+ }),
60
+ ),
61
+ }),
62
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
63
+ if (!controller.planning.active) {
64
+ return failure(
65
+ "Loop planning is not open, so there is nothing to propose. The user opens it by running /loop with no loop running.",
66
+ );
67
+ }
68
+ if (controller.state && controller.state.status !== "stopped") {
69
+ return failure(
70
+ "A loop is already running in this session. Stop it with /loop stop before planning another.",
71
+ );
72
+ }
73
+ const objective = params.objective.trim();
74
+ if (!objective) return failure("A proposal needs an objective.");
75
+
76
+ const overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } =
77
+ {};
78
+ if (params.interval !== undefined) {
79
+ const parsed = parseDuration(params.interval);
80
+ if (parsed === undefined) {
81
+ return failure(
82
+ `Invalid interval: ${params.interval}. Use <number><unit> with unit s, m, h, or d, e.g. 30m.`,
83
+ );
84
+ }
85
+ overrides.intervalMs = Math.min(parsed, MAX_INTERVAL_MS);
86
+ }
87
+ if (params.expires !== undefined) {
88
+ const parsed = parseDuration(params.expires);
89
+ if (parsed === undefined) {
90
+ return failure(
91
+ `Invalid expires: ${params.expires}. Use <number><unit> with unit s, m, h, or d, e.g. 3d.`,
92
+ );
93
+ }
94
+ overrides.expiresInMs = parsed;
95
+ }
96
+ if (params.max_turns !== undefined) {
97
+ if (!Number.isSafeInteger(params.max_turns) || params.max_turns <= 0) {
98
+ return failure("max_turns must be a positive whole number.");
99
+ }
100
+ overrides.maxTurns = params.max_turns;
101
+ }
102
+
103
+ const proposal = controller.propose(objective, overrides);
104
+ return {
105
+ content: [{ type: "text" as const, text: renderProposalCard(proposal).join("\n") }],
106
+ details: {
107
+ criteria: proposal.criteria.length,
108
+ intervalMs: proposal.intervalMs,
109
+ maxTurns: proposal.maxTurns,
110
+ },
111
+ };
112
+ },
113
+ }),
114
+ );
115
+ }
116
+
117
+ function failure(text: string) {
118
+ return { content: [{ type: "text" as const, text }], details: {}, isError: true };
119
+ }
package/src/widget.ts CHANGED
@@ -1,15 +1,33 @@
1
1
  /**
2
- * The loop widget: a compact themed line above the editor mirroring the
3
- * footer status (interval · loop turns/cap · next wake), with the loop focus
2
+ * The loop widget: one themed line above the editor, with the loop's focus
4
3
  * dimmed below it when set.
5
4
  *
5
+ * Two rules shape what goes on that line.
6
+ *
7
+ * **Show progress, not consumption.** The line used to lead with the interval
8
+ * and then report turns against the turn cap. The interval is a fallback
9
+ * heartbeat — a settle-paced loop can run its whole life without delivering
10
+ * one — and turns-against-cap is budget burn, which says nothing about how
11
+ * much of the objective is done. Criteria met over criteria total is the
12
+ * progress number, and it leads.
13
+ *
14
+ * **One surface, one story.** The widget and the footer status render the same
15
+ * state, so they render it from the same function. They disagreed before:
16
+ * `setStatus` handled `loop.waiting` and the widget did not, so a loop blocked
17
+ * on CI showed an ordinary "next 17:53" above the editor while the footer said
18
+ * it was waiting.
19
+ *
20
+ * States are ordered by how much they want a human, because the top of that
21
+ * order is the whole reason to glance at the line: paused and blocked and
22
+ * expiring come before the ordinary running line.
23
+ *
6
24
  * Presentation only: every entry point tolerates a host without setWidget
7
25
  * (test fixtures, print mode) and swallows render-side failures, because a
8
26
  * widget must never interrupt loop state transitions.
9
27
  */
10
28
 
11
29
  import { Text } from "@earendil-works/pi-tui";
12
- import { formatClock, formatDuration } from "./interval.js";
30
+ import { formatClock, formatElapsed } from "./interval.js";
13
31
  import type { LoopState } from "./state.js";
14
32
 
15
33
  export const LOOP_WIDGET_KEY = "loop";
@@ -21,27 +39,61 @@ interface WidgetTheme {
21
39
 
22
40
  type WidgetHost = { setWidget?: unknown };
23
41
 
24
- export interface LoopWidgetView {
42
+ /** Criteria progress, absent when the loop has no readable ledger. */
43
+ export interface CriteriaProgress {
44
+ met: number;
45
+ total: number;
46
+ }
47
+
48
+ /** The loop is being drafted with the user and has not started. */
49
+ export interface LoopPlanningView {
50
+ kind: "planning";
51
+ /** Criteria in the proposed draft, once one has been put up for approval. */
52
+ proposedCriteria?: number;
53
+ }
54
+
55
+ export interface LoopRunningView {
56
+ kind: "loop";
25
57
  loop: LoopState;
26
58
  /** A wake is held for the next idle boundary. */
27
59
  wakePending: boolean;
28
60
  /** Epoch ms of the next scheduled tick, when armed. */
29
61
  nextWakeAt: number | undefined;
62
+ criteria?: CriteriaProgress;
63
+ /**
64
+ * How long the session has been busy with no completed turn. Set only past
65
+ * the stall threshold, where a blocking prompt is the likely explanation.
66
+ */
67
+ blockedForMs?: number;
68
+ /** Injected so the elapsed span is deterministic in tests. */
69
+ now?: number;
30
70
  }
31
71
 
72
+ export type LoopWidgetView = LoopPlanningView | LoopRunningView;
73
+
74
+ /** How urgently a line wants a human; picks the colour. */
75
+ type Tone = "normal" | "attention" | "planning";
76
+
32
77
  export function updateLoopWidget(ui: WidgetHost, view: LoopWidgetView | undefined) {
33
78
  const setWidget = resolveSetWidget(ui);
34
79
  if (!setWidget) return;
35
80
  try {
36
- if (!view || view.loop.status === "stopped") {
81
+ if (!view || (view.kind === "loop" && view.loop.status === "stopped")) {
37
82
  setWidget(LOOP_WIDGET_KEY, undefined);
38
83
  return;
39
84
  }
40
85
  setWidget(LOOP_WIDGET_KEY, (_tui: unknown, theme: WidgetTheme) => {
41
86
  const bold = theme.bold ?? identity;
87
+ const paint = (tone: Tone, text: string) => {
88
+ if (tone === "normal") return text;
89
+ return theme.fg?.(tone === "attention" ? "warning" : "accent", text) ?? text;
90
+ };
42
91
  const dim = (text: string) => theme.fg?.("dim", text) ?? text;
43
- const focus = view.loop.prompt ? `\n${dim(` focus: ${view.loop.prompt}`)}` : "";
44
- return new Text(`${bold(loopWidgetLine(view))}${focus}`);
92
+ const focus =
93
+ view.kind === "loop" && view.loop.prompt
94
+ ? `\n${dim(` focus: ${view.loop.prompt}`)}`
95
+ : "";
96
+ return new Text(`${paint(widgetTone(view), bold(loopWidgetLine(view)))}${focus}`);
45
97
  });
46
98
  } catch {
47
99
  // Presentation only; a widget failure must never break a loop transition.
@@ -58,18 +110,56 @@ export function clearLoopWidget(ui: WidgetHost) {
58
110
  }
59
111
  }
60
112
 
61
- export function loopWidgetLine(view: LoopWidgetView) {
113
+ /** Exported for tests: the tone the line renders in. */
114
+ export function widgetTone(view: LoopWidgetView): Tone {
115
+ if (view.kind === "planning") return "planning";
62
116
  const loop = view.loop;
63
- if (loop.status === "paused") return "⏸ loop paused";
117
+ if (loop.status === "paused" || loop.expiring || view.blockedForMs !== undefined) {
118
+ return "attention";
119
+ }
120
+ return "normal";
121
+ }
122
+
123
+ export function loopWidgetLine(view: LoopWidgetView): string {
124
+ if (view.kind === "planning") {
125
+ return view.proposedCriteria === undefined
126
+ ? "◆ loop planning · drafting an objective"
127
+ : `◆ loop planning · ${view.proposedCriteria} criteria proposed · approve to start`;
128
+ }
129
+ const loop = view.loop;
130
+
131
+ // Ordered by how much the state wants a human. A paused or blocked loop is
132
+ // not making progress, so reporting progress numbers first would bury the
133
+ // only fact that matters.
134
+ if (loop.status === "paused") {
135
+ return `⏸ loop paused${loop.pauseCause ? ` · ${loop.pauseCause}` : ""}`;
136
+ }
137
+ if (loop.expiring) return "⚠ loop expiring · write your state into the ledger";
138
+ if (view.blockedForMs !== undefined) {
139
+ // The engine cannot see the prompt itself: it only knows the session has
140
+ // been busy without completing a turn, which a blocking prompt explains
141
+ // and ordinary long work also explains. Say which one is being reported.
142
+ return `⚠ loop blocked · no turn for ${formatElapsed(view.blockedForMs)} · a prompt may be waiting`;
143
+ }
144
+ if (loop.waiting) {
145
+ const until =
146
+ loop.waiting.resumeAt === undefined
147
+ ? "no deadline"
148
+ : `until ${formatClock(loop.waiting.resumeAt)}`;
149
+ return `⏳ loop waiting · ${loop.waiting.reason} · ${until}`;
150
+ }
151
+
64
152
  const cap = loop.maxTurns === null ? "∞" : `${loop.maxTurns}`;
65
153
  const next = view.wakePending
66
154
  ? "next on idle"
67
155
  : view.nextWakeAt !== undefined
68
156
  ? `next ${formatClock(view.nextWakeAt)}`
69
157
  : "next unscheduled";
70
- // The turn counter, not the wake counter: the cap counts turns, so a
71
- // progress line against that cap has to count the same thing.
72
- return `⟳ loop every ${formatDuration(loop.intervalMs)} · ${loop.automaticTurns}/${cap} · ${next}`;
158
+ const elapsed = formatElapsed((view.now ?? Date.now()) - loop.startedAt);
159
+ // Progress leads when there is progress to report. Turns are still shown,
160
+ // but as the budget they are, not as the headline.
161
+ const progress = view.criteria ? `${view.criteria.met}/${view.criteria.total} done · ` : "";
162
+ return `⟳ loop ${progress}turn ${loop.automaticTurns}/${cap} · ${elapsed} · ${next}`;
73
163
  }
74
164
 
75
165
  function identity(text: string) {
@@ -1,255 +0,0 @@
1
- /**
2
- * Deterministic `/schedule` parsing. Grammar:
3
- *
4
- * /schedule manager TUI
5
- * /schedule list
6
- * /schedule every <dur> [flags] <prompt...>
7
- * /schedule at <ISO|+dur> [flags] <prompt...>
8
- * /schedule cron "<m h dom mon dow>" [flags] <prompt...>
9
- * /schedule pause|resume|delete|run|status <id>
10
- *
11
- * Flags: --run (headless instead of an in-session prompt), --cwd <path>,
12
- * --max <n|unlimited>, --wake always|failure|success|never, --name <text>.
13
- *
14
- * The extension owns this grammar, never the model — same reason `/loop`
15
- * does. A model that can schedule its own future turns can schedule its way
16
- * around every limit the loop imposes.
17
- */
18
-
19
- import { parseDuration } from "../interval.js";
20
- import { parseCron } from "./cron.js";
21
- import {
22
- MIN_INTERVAL_MS,
23
- type ScheduleSpec,
24
- type TaskSpec,
25
- WAKE_ON_VALUES,
26
- type WakeOn,
27
- } from "./model.js";
28
-
29
- export const SCHEDULE_SUBCOMMANDS = [
30
- "list",
31
- "pause",
32
- "resume",
33
- "delete",
34
- "run",
35
- "status",
36
- ] as const;
37
- export type ScheduleSubcommand = (typeof SCHEDULE_SUBCOMMANDS)[number];
38
-
39
- export interface ScheduleCreateCommand {
40
- kind: "create";
41
- name: string;
42
- schedule: ScheduleSpec;
43
- task: TaskSpec;
44
- maxRuns?: number | null;
45
- /** Set when a sub-minute interval was raised to the floor. */
46
- clampedFrom?: number;
47
- }
48
-
49
- export type ScheduleCommand =
50
- | { kind: "show" }
51
- | { kind: "list" }
52
- | { kind: ScheduleSubcommand; id: string }
53
- | ScheduleCreateCommand
54
- | { kind: "error"; message: string };
55
-
56
- export interface ParseScheduleOptions {
57
- /** Default working directory for a headless run. */
58
- cwd: string;
59
- now?: number;
60
- }
61
-
62
- export function parseScheduleCommand(
63
- args: string,
64
- options: ParseScheduleOptions,
65
- ): ScheduleCommand {
66
- const trimmed = args.trim();
67
- if (!trimmed) return { kind: "show" };
68
- const tokens = [...args.matchAll(/\S+/g)].map((match) => ({
69
- text: match[0],
70
- index: match.index,
71
- }));
72
- const head = tokens[0]?.text ?? "";
73
- if (head === "list") return { kind: "list" };
74
- if ((SCHEDULE_SUBCOMMANDS as readonly string[]).includes(head)) {
75
- const id = tokens[1]?.text;
76
- if (!id) return { kind: "error", message: `/schedule ${head} needs a task id.` };
77
- return { kind: head as ScheduleSubcommand, id };
78
- }
79
-
80
- const now = options.now ?? Date.now();
81
- let position = 1;
82
- let schedule: ScheduleSpec | undefined;
83
- let clampedFrom: number | undefined;
84
- if (head === "every") {
85
- const value = tokens[1]?.text;
86
- if (!value) return { kind: "error", message: "/schedule every needs a duration, e.g. 30m." };
87
- const everyMs = parseDuration(value);
88
- if (everyMs === undefined) {
89
- return {
90
- kind: "error",
91
- message: `Invalid interval: ${value}. Use <number><unit> with unit s, m, h, or d.`,
92
- };
93
- }
94
- if (everyMs < MIN_INTERVAL_MS) clampedFrom = everyMs;
95
- schedule = { kind: "interval", everyMs: Math.max(MIN_INTERVAL_MS, everyMs) };
96
- position = 2;
97
- } else if (head === "at") {
98
- const value = tokens[1]?.text;
99
- if (!value) {
100
- return {
101
- kind: "error",
102
- message: "/schedule at needs a time: an ISO timestamp, or +<duration> such as +90m.",
103
- };
104
- }
105
- const at = parseAt(value, now);
106
- if (at === undefined) {
107
- return {
108
- kind: "error",
109
- message: `Invalid time: ${value}. Use an ISO timestamp (2026-01-31T09:00) or +<duration> such as +90m.`,
110
- };
111
- }
112
- if (at <= now) return { kind: "error", message: `That time is in the past: ${value}.` };
113
- schedule = { kind: "once", at };
114
- position = 2;
115
- } else if (head === "cron") {
116
- const quoted = readQuoted(args, tokens[1]?.index ?? 0);
117
- const expression = quoted?.value ?? tokens.slice(1, 6).map((token) => token.text).join(" ");
118
- const parsed = parseCron(expression);
119
- if (!parsed.ok) return { kind: "error", message: `Invalid cron expression: ${parsed.error}.` };
120
- schedule = { kind: "cron", expression: parsed.spec.expression };
121
- position = quoted ? tokenIndexAfter(tokens, quoted.end) : 6;
122
- } else {
123
- return {
124
- kind: "error",
125
- message: `Unknown /schedule form: ${head}. Use every <dur>, at <time>, cron "<expr>", list, pause, resume, run, status, or delete.`,
126
- };
127
- }
128
-
129
- let headless = false;
130
- let cwd = options.cwd;
131
- let wakeOn: WakeOn = "failure";
132
- let maxRuns: number | null | undefined;
133
- let name: string | undefined;
134
- while (position < tokens.length) {
135
- const token = tokens[position];
136
- if (token === undefined || !token.text.startsWith("--")) break;
137
- const [flag, inline] = splitFlag(token.text);
138
- const value = inline ?? tokens[position + 1]?.text;
139
- if (flag === "--run") {
140
- headless = true;
141
- position += 1;
142
- continue;
143
- }
144
- if (value === undefined) return { kind: "error", message: `${flag} needs a value.` };
145
- if (flag === "--cwd") {
146
- cwd = value;
147
- } else if (flag === "--wake") {
148
- if (!WAKE_ON_VALUES.includes(value as WakeOn)) {
149
- return {
150
- kind: "error",
151
- message: `Invalid --wake value: ${value}. Use ${WAKE_ON_VALUES.join(", ")}.`,
152
- };
153
- }
154
- wakeOn = value as WakeOn;
155
- } else if (flag === "--max") {
156
- if (value === "unlimited" || value === "null") maxRuns = null;
157
- else {
158
- const parsed = Number(value);
159
- if (!Number.isSafeInteger(parsed) || parsed <= 0) {
160
- return {
161
- kind: "error",
162
- message: `Invalid --max value: ${value}. Use a positive whole number or unlimited.`,
163
- };
164
- }
165
- maxRuns = parsed;
166
- }
167
- } else if (flag === "--name") {
168
- name = value;
169
- } else {
170
- return {
171
- kind: "error",
172
- message: `Unknown flag: ${flag}. Known flags: --run, --cwd, --max, --wake, --name.`,
173
- };
174
- }
175
- position += inline === undefined ? 2 : 1;
176
- }
177
-
178
- const promptToken = tokens[position];
179
- const prompt = promptToken === undefined ? "" : args.slice(promptToken.index).trim();
180
- if (!prompt) {
181
- return { kind: "error", message: "A scheduled task needs a prompt to run." };
182
- }
183
- return {
184
- kind: "create",
185
- name: name ?? summarize(prompt),
186
- schedule,
187
- task: headless ? { kind: "run", prompt, cwd, wakeOn } : { kind: "prompt", prompt },
188
- ...(maxRuns === undefined ? {} : { maxRuns }),
189
- ...(clampedFrom === undefined ? {} : { clampedFrom }),
190
- };
191
- }
192
-
193
- function parseAt(value: string, now: number): number | undefined {
194
- if (value.startsWith("+")) {
195
- const delay = parseDuration(value.slice(1));
196
- return delay === undefined ? undefined : now + delay;
197
- }
198
- const parsed = Date.parse(value);
199
- return Number.isFinite(parsed) ? parsed : undefined;
200
- }
201
-
202
- /** Read a quoted string starting at `from`, or undefined when unquoted. */
203
- function readQuoted(args: string, from: number): { value: string; end: number } | undefined {
204
- const quote = args[from];
205
- if (quote !== '"' && quote !== "'") return undefined;
206
- const end = args.indexOf(quote, from + 1);
207
- if (end === -1) return undefined;
208
- return { value: args.slice(from + 1, end), end };
209
- }
210
-
211
- function tokenIndexAfter(
212
- tokens: ReadonlyArray<{ text: string; index: number }>,
213
- offset: number,
214
- ): number {
215
- for (let index = 0; index < tokens.length; index += 1) {
216
- const token = tokens[index];
217
- if (token && token.index > offset) return index;
218
- }
219
- return tokens.length;
220
- }
221
-
222
- function splitFlag(token: string): [string, string | undefined] {
223
- const equals = token.indexOf("=");
224
- if (equals === -1) return [token, undefined];
225
- return [token.slice(0, equals), token.slice(equals + 1)];
226
- }
227
-
228
- function summarize(prompt: string): string {
229
- const collapsed = prompt.replace(/\s+/gu, " ").trim();
230
- return collapsed.length <= 60 ? collapsed : `${collapsed.slice(0, 59)}…`;
231
- }
232
-
233
- export interface ScheduleArgumentCompletion {
234
- value: string;
235
- label: string;
236
- description?: string;
237
- }
238
-
239
- const COMPLETIONS: readonly ScheduleArgumentCompletion[] = [
240
- { value: "every", label: "every", description: "Repeat on an interval, e.g. every 30m" },
241
- { value: "at", label: "at", description: "Fire once, e.g. at +90m or an ISO timestamp" },
242
- { value: "cron", label: "cron", description: 'Fire on a cron expression, e.g. cron "0 9 * * 1"' },
243
- { value: "list", label: "list", description: "List scheduled tasks" },
244
- { value: "pause", label: "pause", description: "Pause a task" },
245
- { value: "resume", label: "resume", description: "Resume a paused task" },
246
- { value: "run", label: "run", description: "Run a task now" },
247
- { value: "status", label: "status", description: "Show one task in detail" },
248
- { value: "delete", label: "delete", description: "Delete a task" },
249
- ];
250
-
251
- export function completeScheduleArguments(prefix: string): ScheduleArgumentCompletion[] | null {
252
- const trimmed = prefix.trimStart();
253
- const matches = COMPLETIONS.filter((candidate) => candidate.value.startsWith(trimmed));
254
- return matches.length > 0 ? matches : null;
255
- }