@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.
- package/CHANGELOG.md +12 -0
- package/README.md +35 -32
- package/package.json +1 -1
- package/skills/pi-loop/SKILL.md +64 -45
- package/src/command.ts +33 -187
- package/src/complete-tool.ts +1 -1
- package/src/index.ts +70 -79
- package/src/ledger.ts +2 -2
- package/src/loop-action-menus.ts +2 -1
- package/src/loop-launch-menu.ts +158 -0
- package/src/loop-manager-menu.ts +191 -0
- package/src/loop.ts +22 -22
- package/src/manager.ts +118 -104
- package/src/messages.ts +1 -1
- package/src/objective.ts +25 -0
- package/src/planning.ts +67 -23
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +29 -13
- package/src/settings.ts +27 -22
- package/src/state.ts +28 -0
- package/src/wait-tool.ts +1 -1
- package/src/widget.ts +7 -3
- package/src/inline-command.ts +0 -159
- package/src/inline-invocation.ts +0 -109
- package/src/start-tool.ts +0 -199
package/src/command.ts
CHANGED
|
@@ -1,23 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The whole `/loop` grammar, which is now two cases:
|
|
3
3
|
*
|
|
4
|
-
* /loop
|
|
5
|
-
* /loop
|
|
6
|
-
* /loop [flags] <interval> [flags] [prompt...]
|
|
4
|
+
* /loop -> the menu (context-sensitive: launch, planning, approval, manager)
|
|
5
|
+
* /loop <text> -> open planning and send <text> as the first drafting message
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* Everything else that used to live here — a typed start (`/loop 5m fix CI`),
|
|
8
|
+
* its flags, and the `status|pause|resume|stop|settings` subcommands — is
|
|
9
|
+
* gone. A loop's objective becomes its acceptance gate, and a command line is
|
|
10
|
+
* the worst place to author one: the flags were invisible, the interval was
|
|
11
|
+
* mandatory for no reason a user could see, and the criteria were frozen
|
|
12
|
+
* before anyone had seen them. Planning plus an approval card replaces all of
|
|
13
|
+
* it, and the menu carries the lifecycle actions the subcommands used to.
|
|
14
|
+
*
|
|
15
|
+
* Consequence worth stating plainly: `/loop status` is no longer a
|
|
16
|
+
* subcommand, so it seeds planning with the word "status". That is the price
|
|
17
|
+
* of having exactly one way in, and the menu is one keystroke away.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
export type LoopCommand =
|
|
21
|
+
/** Bare `/loop`: open whichever menu the current state calls for. */
|
|
22
|
+
| { kind: "menu" }
|
|
23
|
+
/** `/loop <text>`: enter planning and send the text as the first message. */
|
|
24
|
+
| { kind: "seed"; text: string };
|
|
17
25
|
|
|
18
|
-
export
|
|
19
|
-
|
|
26
|
+
export function parseLoopCommand(args: string): LoopCommand {
|
|
27
|
+
const trimmed = args.trim();
|
|
28
|
+
return trimmed ? { kind: "seed", text: trimmed } : { kind: "menu" };
|
|
29
|
+
}
|
|
20
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The arguments a loop is built from.
|
|
33
|
+
*
|
|
34
|
+
* Only two callers construct these now — the approval card's start actions —
|
|
35
|
+
* so the shape is the approved draft, not a parsed command line.
|
|
36
|
+
*/
|
|
21
37
|
export interface LoopStartArguments {
|
|
22
38
|
kind: "start";
|
|
23
39
|
requestedMs: number;
|
|
@@ -31,180 +47,10 @@ export interface LoopStartArguments {
|
|
|
31
47
|
expiresInMs?: number;
|
|
32
48
|
prompt?: string;
|
|
33
49
|
/**
|
|
34
|
-
* Completion criteria proposed
|
|
35
|
-
* split of the objective.
|
|
36
|
-
* splits, because there is no model in that path to propose anything.
|
|
50
|
+
* Completion criteria proposed with the draft, replacing the deterministic
|
|
51
|
+
* split of the objective.
|
|
37
52
|
*/
|
|
38
53
|
criteria?: string[];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
export type LoopCommand =
|
|
42
|
-
| { kind: "show" }
|
|
43
|
-
| { kind: LoopSubcommand }
|
|
44
|
-
| LoopStartArguments
|
|
45
|
-
| { kind: "error"; message: string };
|
|
46
|
-
|
|
47
|
-
export function parseLoopCommand(args: string): LoopCommand {
|
|
48
|
-
const trimmed = args.trim();
|
|
49
|
-
if (!trimmed) return { kind: "show" };
|
|
50
|
-
if ((LOOP_SUBCOMMANDS as readonly string[]).includes(trimmed)) {
|
|
51
|
-
return { kind: trimmed as LoopSubcommand };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const tokens = [...args.matchAll(/\S+/g)].map((match) => ({
|
|
55
|
-
text: match[0],
|
|
56
|
-
index: match.index,
|
|
57
|
-
}));
|
|
58
|
-
const flags: LoopFlags = {};
|
|
59
|
-
const beforeInterval = scanFlags(tokens, 0, flags);
|
|
60
|
-
if (typeof beforeInterval !== "number") return beforeInterval;
|
|
61
|
-
|
|
62
|
-
const intervalToken = tokens[beforeInterval];
|
|
63
|
-
if (intervalToken === undefined) {
|
|
64
|
-
return { kind: "error", message: "An interval is required to start a loop, e.g. /loop 5m <prompt>." };
|
|
65
|
-
}
|
|
66
|
-
const interval = parseInterval(intervalToken.text);
|
|
67
|
-
if (!interval) {
|
|
68
|
-
return {
|
|
69
|
-
kind: "error",
|
|
70
|
-
message: `Invalid interval: ${intervalToken.text}. Use <number><unit> with unit s, m, h, or d, e.g. 5m.`,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
// Flags are also accepted *after* the interval. They used to be positional,
|
|
74
|
-
// which meant `/loop 5m --max 3 fix the tests` silently made "--max 3 fix
|
|
75
|
-
// the tests" the objective: no error, a polluted objective, and a setting
|
|
76
|
-
// that quietly did not apply.
|
|
77
|
-
const afterInterval = scanFlags(tokens, beforeInterval + 1, flags);
|
|
78
|
-
if (typeof afterInterval !== "number") return afterInterval;
|
|
79
|
-
const { maxTurns, compactAt, expiresInMs } = flags;
|
|
80
|
-
|
|
81
|
-
const promptToken = tokens[afterInterval];
|
|
82
|
-
const prompt = promptToken === undefined ? undefined : args.slice(promptToken.index).trim();
|
|
83
|
-
return {
|
|
84
|
-
kind: "start",
|
|
85
|
-
requestedMs: interval.requestedMs,
|
|
86
|
-
intervalMs: interval.effectiveMs,
|
|
87
|
-
clamped: interval.clamped,
|
|
88
|
-
...(maxTurns === undefined ? {} : { maxTurns }),
|
|
89
|
-
...(compactAt === undefined ? {} : { compactAt }),
|
|
90
|
-
...(expiresInMs === undefined ? {} : { expiresInMs }),
|
|
91
|
-
...(prompt ? { prompt } : {}),
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
interface LoopFlags {
|
|
96
|
-
maxTurns?: number | null;
|
|
97
|
-
compactAt?: number | null;
|
|
98
|
-
expiresInMs?: number;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Consume leading `--flag` tokens starting at `position`, filling `flags`.
|
|
103
|
-
* Returns the index of the first non-flag token, or the parse error.
|
|
104
|
-
*/
|
|
105
|
-
function scanFlags(
|
|
106
|
-
tokens: ReadonlyArray<{ text: string; index: number }>,
|
|
107
|
-
position: number,
|
|
108
|
-
flags: LoopFlags,
|
|
109
|
-
): number | { kind: "error"; message: string } {
|
|
110
|
-
while (position < tokens.length) {
|
|
111
|
-
const token = tokens[position];
|
|
112
|
-
if (token === undefined || !token.text.startsWith("--")) break;
|
|
113
|
-
const [flag, inlineValue] = splitFlag(token.text);
|
|
114
|
-
const value = inlineValue ?? tokens[position + 1]?.text;
|
|
115
|
-
const consumed = inlineValue !== undefined ? 1 : 2;
|
|
116
|
-
if (flag === "--max") {
|
|
117
|
-
if (value === undefined) {
|
|
118
|
-
return {
|
|
119
|
-
kind: "error",
|
|
120
|
-
message: "--max needs a value (a positive number of loop turns, or unlimited).",
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
const parsed = parseMax(value);
|
|
124
|
-
if (parsed === undefined) {
|
|
125
|
-
return {
|
|
126
|
-
kind: "error",
|
|
127
|
-
message: `Invalid --max value: ${value}. Use a positive whole number or unlimited.`,
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
flags.maxTurns = parsed;
|
|
131
|
-
} else if (flag === "--compact-at") {
|
|
132
|
-
if (value === undefined) {
|
|
133
|
-
return { kind: "error", message: "--compact-at needs a value (e.g. 60% or off)." };
|
|
134
|
-
}
|
|
135
|
-
const parsed = parseCompactAt(value);
|
|
136
|
-
if (parsed === undefined) {
|
|
137
|
-
return {
|
|
138
|
-
kind: "error",
|
|
139
|
-
message: `Invalid --compact-at value: ${value}. Use a percentage between 1% and 99% (e.g. 60%), a fraction (0.6), or off.`,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
flags.compactAt = parsed;
|
|
143
|
-
} else if (flag === "--expires") {
|
|
144
|
-
if (value === undefined) {
|
|
145
|
-
return { kind: "error", message: "--expires needs a duration (e.g. 3d)." };
|
|
146
|
-
}
|
|
147
|
-
const parsed = parseDuration(value);
|
|
148
|
-
if (parsed === undefined) {
|
|
149
|
-
return {
|
|
150
|
-
kind: "error",
|
|
151
|
-
message: `Invalid --expires value: ${value}. Use <number><unit> with unit s, m, h, or d, e.g. 3d.`,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
flags.expiresInMs = parsed;
|
|
155
|
-
} else {
|
|
156
|
-
return {
|
|
157
|
-
kind: "error",
|
|
158
|
-
message: `Unknown flag: ${flag}. Known flags: --max, --compact-at, --expires.`,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
position += consumed;
|
|
162
|
-
}
|
|
163
|
-
return position;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function splitFlag(token: string): [string, string | undefined] {
|
|
167
|
-
const equals = token.indexOf("=");
|
|
168
|
-
if (equals === -1) return [token, undefined];
|
|
169
|
-
return [token.slice(0, equals), token.slice(equals + 1)];
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function parseMax(value: string): number | null | undefined {
|
|
173
|
-
if (value === "unlimited" || value === "null") return null;
|
|
174
|
-
const parsed = Number(value);
|
|
175
|
-
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function parseCompactAt(value: string): number | null | undefined {
|
|
179
|
-
if (value === "off" || value === "none") return null;
|
|
180
|
-
let fraction: number;
|
|
181
|
-
if (value.endsWith("%")) {
|
|
182
|
-
fraction = Number(value.slice(0, -1)) / 100;
|
|
183
|
-
} else {
|
|
184
|
-
fraction = Number(value);
|
|
185
|
-
}
|
|
186
|
-
if (!Number.isFinite(fraction) || fraction <= 0 || fraction >= 1) return undefined;
|
|
187
|
-
return fraction;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export interface LoopArgumentCompletion {
|
|
191
|
-
value: string;
|
|
192
|
-
label: string;
|
|
193
|
-
description?: string;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const LOOP_ARGUMENT_COMPLETIONS: readonly LoopArgumentCompletion[] = [
|
|
197
|
-
{ value: "status", label: "status", description: "Show the current loop" },
|
|
198
|
-
{ value: "pause", label: "pause", description: "Pause the loop" },
|
|
199
|
-
{ value: "resume", label: "resume", description: "Resume a paused loop" },
|
|
200
|
-
{ value: "stop", label: "stop", description: "Stop the loop" },
|
|
201
|
-
{ value: "settings", label: "settings", description: "Open pi-loop settings" },
|
|
202
|
-
];
|
|
203
|
-
|
|
204
|
-
export function completeLoopArguments(prefix: string): LoopArgumentCompletion[] | null {
|
|
205
|
-
const trimmed = prefix.trimStart();
|
|
206
|
-
const matches = LOOP_ARGUMENT_COMPLETIONS.filter((candidate) =>
|
|
207
|
-
candidate.value.startsWith(trimmed),
|
|
208
|
-
);
|
|
209
|
-
return matches.length > 0 ? matches : null;
|
|
54
|
+
/** Hard constraints carried into the loop's per-turn objective append. */
|
|
55
|
+
groundRules?: string[];
|
|
210
56
|
}
|
package/src/complete-tool.ts
CHANGED
|
@@ -120,7 +120,7 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
|
|
|
120
120
|
if (!loop || loop.objective === undefined) {
|
|
121
121
|
return {
|
|
122
122
|
content: toolContent(
|
|
123
|
-
"No /loop with an objective is active, so there is nothing to complete.
|
|
123
|
+
"No /loop with an objective is active, so there is nothing to complete. Run /loop to plan and approve one.",
|
|
124
124
|
),
|
|
125
125
|
details: { loopId: params.loop_id },
|
|
126
126
|
isError: true,
|
package/src/index.ts
CHANGED
|
@@ -8,19 +8,26 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import {
|
|
11
|
+
import { parseLoopCommand } from "./command.js";
|
|
12
12
|
import { registerLoopCompleteTool } from "./complete-tool.js";
|
|
13
|
-
import { InlineInvocationState, registerInlineInvocation } from "./inline-invocation.js";
|
|
14
13
|
import { registerLoopProgressTool } from "./progress-tool.js";
|
|
15
14
|
import { registerLoopProposeTool } from "./propose-tool.js";
|
|
16
15
|
import { LOOP_PLANNING_HINT } from "./planning.js";
|
|
17
|
-
import { registerLoopStartTool } from "./start-tool.js";
|
|
18
16
|
import { registerLoopWaitTool } from "./wait-tool.js";
|
|
19
17
|
import { LoopController, type LoopControllerOptions } from "./loop.js";
|
|
20
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
showLoopApproval,
|
|
20
|
+
showLoopLaunch,
|
|
21
|
+
showLoopManager,
|
|
22
|
+
showLoopPlanning,
|
|
23
|
+
} from "./manager.js";
|
|
21
24
|
import { buildLoopObjectivePrompt } from "./objective.js";
|
|
22
25
|
import { registerLoopMessageRendering } from "./render.js";
|
|
23
26
|
|
|
27
|
+
/** What the planning menu's "Request proposal now" asks for. */
|
|
28
|
+
export const REQUEST_PROPOSAL_MESSAGE =
|
|
29
|
+
"Put the loop we have been drafting up for approval now: call loop_propose with the objective as an acceptance test, one requirement per bullet naming the check that proves it, plus any ground rules we agreed. If something material is still undecided, ask me that one question instead.";
|
|
30
|
+
|
|
24
31
|
export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
25
32
|
const controller = new LoopController(pi, options);
|
|
26
33
|
// Registered unconditionally and never toggled with loop state: tools are
|
|
@@ -33,93 +40,72 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
33
40
|
registerLoopWaitTool(pi, controller);
|
|
34
41
|
registerLoopProgressTool(pi, controller);
|
|
35
42
|
registerLoopProposeTool(pi, controller);
|
|
36
|
-
// Inline invocation: an `input` handler arms a one-turn system-prompt hint
|
|
37
|
-
// for a mid-prompt `/loop` token, `before_agent_start` appends it, and
|
|
38
|
-
// loop_start is the model-invoked start it points at — refused on any turn
|
|
39
|
-
// the hint did not arm. The user's message is never transformed.
|
|
40
|
-
const invocation = new InlineInvocationState();
|
|
41
|
-
registerInlineInvocation(pi, controller, invocation);
|
|
42
|
-
registerLoopStartTool(pi, controller, invocation);
|
|
43
43
|
// Collapse loop pokes into one-line transcript chips (display-only; the
|
|
44
44
|
// stored message and model context are untouched).
|
|
45
45
|
registerLoopMessageRendering(pi);
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Send a user message on the user's behalf, exactly the way pi-plan-mode
|
|
49
|
+
* seeds a planning conversation: an idle session takes it now, a busy one
|
|
50
|
+
* takes it as a follow-up rather than steering the turn in flight.
|
|
51
|
+
*/
|
|
52
|
+
const sendPlanningMessage = (text: string, ctx: ExtensionCommandContext): void => {
|
|
53
|
+
try {
|
|
54
|
+
if (ctx.isIdle()) pi.sendUserMessage(text);
|
|
55
|
+
else pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
58
|
+
ctx.ui.notify(`Unable to send the loop planning message: ${detail}`, "error");
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const beginPlanning = (ctx: ExtensionCommandContext): void => {
|
|
63
|
+
if (controller.planning.active) return;
|
|
64
|
+
controller.beginPlanning();
|
|
65
|
+
ctx.ui.notify(
|
|
66
|
+
"Loop planning. Describe what you want the loop to achieve and how you will know it is done; the agent drafts it and puts it up for approval. Nothing starts until you approve it.",
|
|
67
|
+
"info",
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
47
71
|
pi.registerCommand("loop", {
|
|
48
|
-
description:
|
|
49
|
-
"Work an objective across many turns, waking the session if it goes quiet: /loop [--max N] [--compact-at 60%] [--expires 3d] <interval> [objective]",
|
|
50
|
-
getArgumentCompletions: (prefix: string) => completeLoopArguments(prefix),
|
|
72
|
+
description: "Plan, approve, and manage a long-running loop: /loop [what it should achieve]",
|
|
51
73
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
52
74
|
const command = parseLoopCommand(args);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const running = controller.state && controller.state.status !== "stopped";
|
|
60
|
-
if (running) {
|
|
61
|
-
await showLoopManager(controller, ctx);
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (controller.planning.proposal) {
|
|
65
|
-
await showLoopApproval(controller, ctx);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
if (controller.planning.active) {
|
|
69
|
-
ctx.ui.notify(
|
|
70
|
-
"Still planning: describe the objective, and the agent will put a loop up for approval.",
|
|
71
|
-
"info",
|
|
72
|
-
);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
controller.beginPlanning();
|
|
75
|
+
const running = controller.state !== undefined && controller.state.status !== "stopped";
|
|
76
|
+
// `/loop <text>` mirrors `/plan <prompt>`: it opens planning and says the
|
|
77
|
+
// first thing. A loop already running owns the session, so the text goes
|
|
78
|
+
// nowhere and the manager opens instead of a second draft.
|
|
79
|
+
if (command.kind === "seed") {
|
|
80
|
+
if (running) {
|
|
76
81
|
ctx.ui.notify(
|
|
77
|
-
"
|
|
78
|
-
"
|
|
82
|
+
"A loop is already running in this session. Stop it from this menu before planning another.",
|
|
83
|
+
"warning",
|
|
79
84
|
);
|
|
85
|
+
await showLoopManager(controller, ctx);
|
|
80
86
|
return;
|
|
81
87
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
case "pause":
|
|
86
|
-
controller.pauseLoop(ctx);
|
|
87
|
-
return;
|
|
88
|
-
case "resume":
|
|
89
|
-
controller.resumeLoop(ctx);
|
|
90
|
-
return;
|
|
91
|
-
case "stop":
|
|
92
|
-
controller.stopLoop(ctx);
|
|
93
|
-
return;
|
|
94
|
-
case "settings":
|
|
95
|
-
await showLoopSettings(controller, ctx);
|
|
96
|
-
return;
|
|
97
|
-
case "error":
|
|
98
|
-
ctx.ui.notify(command.message, "error");
|
|
99
|
-
return;
|
|
100
|
-
case "start": {
|
|
101
|
-
const existing = controller.state;
|
|
102
|
-
if (existing && existing.status !== "stopped") {
|
|
103
|
-
const replace =
|
|
104
|
-
ctx.mode === "tui"
|
|
105
|
-
? await ctx.ui.confirm(
|
|
106
|
-
"Replace loop?",
|
|
107
|
-
"A loop already exists in this session. Replace it?",
|
|
108
|
-
)
|
|
109
|
-
: false;
|
|
110
|
-
if (!replace) {
|
|
111
|
-
ctx.ui.notify(
|
|
112
|
-
"A loop already exists; /loop stop it first or confirm replacement in the TUI.",
|
|
113
|
-
"warning",
|
|
114
|
-
);
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
const result = controller.startLoop(ctx, command);
|
|
119
|
-
if (!result.ok) ctx.ui.notify(result.message, "error");
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
88
|
+
beginPlanning(ctx);
|
|
89
|
+
sendPlanningMessage(command.text, ctx);
|
|
90
|
+
return;
|
|
122
91
|
}
|
|
92
|
+
// Bare /loop is the front door, and which door it opens is the state:
|
|
93
|
+
// manager, approval card, planning menu, or launch menu.
|
|
94
|
+
if (running) {
|
|
95
|
+
await showLoopManager(controller, ctx);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (controller.planning.proposal) {
|
|
99
|
+
await showLoopApproval(controller, ctx);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (controller.planning.active) {
|
|
103
|
+
await showLoopPlanning(controller, ctx, {
|
|
104
|
+
requestProposal: () => sendPlanningMessage(REQUEST_PROPOSAL_MESSAGE, ctx),
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await showLoopLaunch(controller, ctx, () => beginPlanning(ctx));
|
|
123
109
|
},
|
|
124
110
|
});
|
|
125
111
|
|
|
@@ -155,4 +141,9 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
155
141
|
if (objectivePrompt === undefined) return;
|
|
156
142
|
return { systemPrompt: `${event.systemPrompt}\n\n${objectivePrompt}` };
|
|
157
143
|
});
|
|
144
|
+
|
|
145
|
+
// Pi ignores the return value; a test uses it. The controller *is* the
|
|
146
|
+
// extension's state, and a test holding a different instance of it would
|
|
147
|
+
// quietly assert against a loop nobody is running.
|
|
148
|
+
return controller;
|
|
158
149
|
}
|
package/src/ledger.ts
CHANGED
|
@@ -99,8 +99,8 @@ export function deriveCriteria(objective: string): LoopCriterion[] {
|
|
|
99
99
|
/**
|
|
100
100
|
* Number a list of descriptions into criteria.
|
|
101
101
|
*
|
|
102
|
-
* Shared by the deterministic split and by
|
|
103
|
-
*
|
|
102
|
+
* Shared by the deterministic split and by any criteria supplied with an
|
|
103
|
+
* approved draft, so nothing downstream — the echo at start, the evidence
|
|
104
104
|
* gate, the immutability rule — can tell the two apart. The extension still
|
|
105
105
|
* writes every field but the description: ids are positional, `check` is
|
|
106
106
|
* empty (audit against authoritative state), and a criterion starts unmet.
|
package/src/loop-action-menus.ts
CHANGED
|
@@ -46,11 +46,12 @@ export function loopApprovalScreen(
|
|
|
46
46
|
proposal: LoopProposal,
|
|
47
47
|
): ActionsScreen<Screen, LoopApprovalAction> {
|
|
48
48
|
const criteria = `${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}`;
|
|
49
|
+
const rules = proposal.groundRules?.length;
|
|
49
50
|
return {
|
|
50
51
|
kind: "actions",
|
|
51
52
|
title: "Start this loop?",
|
|
52
53
|
lines: [
|
|
53
|
-
`${criteria} · fallback wake every ${formatDuration(proposal.intervalMs)} · turn cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · expires in ${formatDuration(proposal.expiresInMs)}`,
|
|
54
|
+
`${criteria}${rules ? ` · ${rules} ground rule${rules === 1 ? "" : "s"}` : ""} · fallback wake every ${formatDuration(proposal.intervalMs)} · turn cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · expires in ${formatDuration(proposal.expiresInMs)}`,
|
|
54
55
|
"The card above shows exactly what loop_complete will be held to.",
|
|
55
56
|
],
|
|
56
57
|
items: [
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two pre-loop menus: the launch menu (nothing running, nothing drafted)
|
|
3
|
+
* and the planning menu (a drafting conversation is open, no proposal yet).
|
|
4
|
+
*
|
|
5
|
+
* They are the front door, and they are deliberately shaped like
|
|
6
|
+
* pi-plan-mode's launch menu — same title/status/items/detail-screen skeleton,
|
|
7
|
+
* same "How it works" affordance. The two extensions are one family: a user
|
|
8
|
+
* who has run `/plan` should recognise `/loop` without reading anything.
|
|
9
|
+
*
|
|
10
|
+
* Screen builders are pure and exported so a test can pin exactly what each
|
|
11
|
+
* menu offers without a terminal. That set is the contract; the wiring is not.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { ActionsScreen, DetailScreen } from "@narumitw/pi-tui-kit";
|
|
16
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
17
|
+
|
|
18
|
+
export type LoopLaunchScreen = "main" | "how";
|
|
19
|
+
export type LoopLaunchAction = "start-planning" | "settings";
|
|
20
|
+
export type LoopPlanningAction = "request-proposal" | "cancel" | "settings";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What a loop actually is, for someone who has never run one.
|
|
24
|
+
*
|
|
25
|
+
* It answers the questions the old one-line notification could not: what
|
|
26
|
+
* paces it, what ends it, where its state lives, and what bounds it now that
|
|
27
|
+
* a turn budget no longer does.
|
|
28
|
+
*/
|
|
29
|
+
export const HOW_LOOPS_WORK_LINES = [
|
|
30
|
+
"A loop works one objective across many turns, continuing itself until the objective is met.",
|
|
31
|
+
"It is paced by the session settling, not by a clock: every time the agent goes idle with the objective unfinished, the loop continues it. The interval is only a fallback heartbeat for a session that has gone quiet.",
|
|
32
|
+
"The objective is drafted with you first and becomes the loop's completion criteria. The approval card shows the exact criteria before anything starts.",
|
|
33
|
+
"Ground rules are hard constraints approved alongside the objective — what the loop must never do while nobody is watching.",
|
|
34
|
+
"loop_complete ends the loop, and it is gated: every criterion needs cited evidence. Effort exhaustion is not completion.",
|
|
35
|
+
"A durable ledger (PROGRESS.md and criteria.json) holds the loop's state, so it survives compaction and hands off between sessions.",
|
|
36
|
+
"Nothing caps the turns by default. A loop is bounded by its expiry and by the no-progress breaker, which pauses it when it repeats itself; set a turn budget in Settings to add one.",
|
|
37
|
+
"You stay in control: /loop opens this menu at any time to pause, resume, or stop it, and Esc interrupts the turn in flight.",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
function howItWorksScreen(): DetailScreen {
|
|
41
|
+
return {
|
|
42
|
+
kind: "detail",
|
|
43
|
+
title: "How loops work",
|
|
44
|
+
lines: [...HOW_LOOPS_WORK_LINES],
|
|
45
|
+
hint: "back",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The off-state launch menu. */
|
|
50
|
+
export function loopLaunchScreen(): ActionsScreen<LoopLaunchScreen, LoopLaunchAction> {
|
|
51
|
+
return {
|
|
52
|
+
kind: "actions",
|
|
53
|
+
title: "Loop",
|
|
54
|
+
lines: ["Status: Off."],
|
|
55
|
+
items: [
|
|
56
|
+
{
|
|
57
|
+
id: "start-planning",
|
|
58
|
+
label: "Start loop planning",
|
|
59
|
+
description: "Draft an objective with the agent. Nothing starts until you approve it.",
|
|
60
|
+
action: "start-planning",
|
|
61
|
+
},
|
|
62
|
+
{ id: "settings", label: "Settings", action: "settings" },
|
|
63
|
+
{ id: "how", label: "How loops work", to: "how" },
|
|
64
|
+
],
|
|
65
|
+
hint: "close",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Planning is open and no draft has been proposed yet. */
|
|
70
|
+
export function loopPlanningScreen(): ActionsScreen<LoopLaunchScreen, LoopPlanningAction> {
|
|
71
|
+
return {
|
|
72
|
+
kind: "actions",
|
|
73
|
+
title: "Loop planning",
|
|
74
|
+
lines: [
|
|
75
|
+
"Status: drafting an objective. No loop is running.",
|
|
76
|
+
"Describe what the loop should achieve and how you will know it is done.",
|
|
77
|
+
],
|
|
78
|
+
items: [
|
|
79
|
+
{
|
|
80
|
+
id: "request-proposal",
|
|
81
|
+
label: "Request proposal now",
|
|
82
|
+
description: "Ask the agent to put the current draft up for approval.",
|
|
83
|
+
action: "request-proposal",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "cancel",
|
|
87
|
+
label: "Cancel planning",
|
|
88
|
+
description: "Close planning. Nothing is started.",
|
|
89
|
+
action: "cancel",
|
|
90
|
+
},
|
|
91
|
+
{ id: "settings", label: "Settings", action: "settings" },
|
|
92
|
+
{ id: "how", label: "How loops work", to: "how" },
|
|
93
|
+
],
|
|
94
|
+
hint: "close",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface LoopLaunchMenuOptions {
|
|
99
|
+
signal?: AbortSignal;
|
|
100
|
+
isCurrent?(): boolean;
|
|
101
|
+
startPlanning(): void;
|
|
102
|
+
settings(signal: AbortSignal): Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function showLoopLaunchMenu(ctx: ExtensionContext, options: LoopLaunchMenuOptions) {
|
|
106
|
+
const menu = defineMenu<undefined, LoopLaunchScreen, LoopLaunchAction, ExtensionContext>({
|
|
107
|
+
start: "main",
|
|
108
|
+
screens: { main: () => loopLaunchScreen(), how: () => howItWorksScreen() },
|
|
109
|
+
actions: {
|
|
110
|
+
"start-planning": async () => {
|
|
111
|
+
options.startPlanning();
|
|
112
|
+
return { kind: "close" };
|
|
113
|
+
},
|
|
114
|
+
settings: async ({ signal }) => {
|
|
115
|
+
await options.settings(signal);
|
|
116
|
+
return { kind: "stay" };
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
return runMenu(ctx, menu, { getState: () => undefined, ...lifecycle(options) });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface LoopPlanningMenuOptions {
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
isCurrent?(): boolean;
|
|
126
|
+
requestProposal(): void;
|
|
127
|
+
cancelPlanning(): void;
|
|
128
|
+
settings(signal: AbortSignal): Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function showLoopPlanningMenu(ctx: ExtensionContext, options: LoopPlanningMenuOptions) {
|
|
132
|
+
const menu = defineMenu<undefined, LoopLaunchScreen, LoopPlanningAction, ExtensionContext>({
|
|
133
|
+
start: "main",
|
|
134
|
+
screens: { main: () => loopPlanningScreen(), how: () => howItWorksScreen() },
|
|
135
|
+
actions: {
|
|
136
|
+
"request-proposal": async () => {
|
|
137
|
+
options.requestProposal();
|
|
138
|
+
return { kind: "close" };
|
|
139
|
+
},
|
|
140
|
+
cancel: async () => {
|
|
141
|
+
options.cancelPlanning();
|
|
142
|
+
return { kind: "close" };
|
|
143
|
+
},
|
|
144
|
+
settings: async ({ signal }) => {
|
|
145
|
+
await options.settings(signal);
|
|
146
|
+
return { kind: "stay" };
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
return runMenu(ctx, menu, { getState: () => undefined, ...lifecycle(options) });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function lifecycle(options: { signal?: AbortSignal; isCurrent?(): boolean }) {
|
|
154
|
+
return {
|
|
155
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
156
|
+
...(options.isCurrent ? { isCurrent: options.isCurrent } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|