@hank-warren/pi-loop 0.9.0 → 1.1.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 +26 -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 +119 -87
- 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 +120 -25
- 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/presentation.ts +55 -18
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +35 -14
- package/src/settings.ts +27 -22
- package/src/state.ts +33 -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,123 +8,136 @@
|
|
|
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";
|
|
15
|
+
import { registerLoopProposalRenderer } from "./presentation.js";
|
|
16
16
|
import { LOOP_PLANNING_HINT } from "./planning.js";
|
|
17
|
-
import { registerLoopStartTool } from "./start-tool.js";
|
|
18
17
|
import { registerLoopWaitTool } from "./wait-tool.js";
|
|
19
18
|
import { LoopController, type LoopControllerOptions } from "./loop.js";
|
|
20
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
showLoopApproval,
|
|
21
|
+
showLoopLaunch,
|
|
22
|
+
showLoopManager,
|
|
23
|
+
showLoopPlanning,
|
|
24
|
+
} from "./manager.js";
|
|
21
25
|
import { buildLoopObjectivePrompt } from "./objective.js";
|
|
22
26
|
import { registerLoopMessageRendering } from "./render.js";
|
|
27
|
+
import { readPlanModeEnabled } from "./state.js";
|
|
28
|
+
|
|
29
|
+
/** What the planning menu's "Request proposal now" asks for. */
|
|
30
|
+
export const REQUEST_PROPOSAL_MESSAGE =
|
|
31
|
+
"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.";
|
|
23
32
|
|
|
24
33
|
export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
25
34
|
const controller = new LoopController(pi, options);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
const proposeTools = ["loop_propose"];
|
|
36
|
+
const runtimeTools = ["loop_complete", "loop_progress", "loop_wait"];
|
|
37
|
+
let proposeActivated = false;
|
|
38
|
+
let runtimeActivated = false;
|
|
39
|
+
const reconcileTools = () => {
|
|
40
|
+
const active = pi.getActiveTools();
|
|
41
|
+
const wanted = new Set(active);
|
|
42
|
+
for (const name of proposeTools) proposeActivated ? wanted.add(name) : wanted.delete(name);
|
|
43
|
+
for (const name of runtimeTools) runtimeActivated ? wanted.add(name) : wanted.delete(name);
|
|
44
|
+
const next = [...wanted];
|
|
45
|
+
if (next.length !== active.length || next.some((name, index) => name !== active[index])) {
|
|
46
|
+
pi.setActiveTools(next);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const activatePropose = () => {
|
|
50
|
+
proposeActivated = true;
|
|
51
|
+
reconcileTools();
|
|
52
|
+
};
|
|
53
|
+
const activateRuntime = () => {
|
|
54
|
+
runtimeActivated = true;
|
|
55
|
+
reconcileTools();
|
|
56
|
+
};
|
|
57
|
+
|
|
30
58
|
registerLoopCompleteTool(pi, controller);
|
|
31
|
-
// Registered on the same terms and for the same reason: the tool set is
|
|
32
|
-
// part of the cached prefix, so it never changes with loop state.
|
|
33
59
|
registerLoopWaitTool(pi, controller);
|
|
34
60
|
registerLoopProgressTool(pi, controller);
|
|
35
|
-
registerLoopProposeTool(pi, controller);
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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);
|
|
61
|
+
registerLoopProposeTool(pi, controller, activateRuntime);
|
|
62
|
+
registerLoopProposalRenderer(pi);
|
|
63
|
+
// Narrowing happens at session_start, never here: Pi refuses action methods
|
|
64
|
+
// (getActiveTools/setActiveTools among them) during extension loading.
|
|
43
65
|
// Collapse loop pokes into one-line transcript chips (display-only; the
|
|
44
66
|
// stored message and model context are untouched).
|
|
45
67
|
registerLoopMessageRendering(pi);
|
|
46
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Send a user message on the user's behalf, exactly the way pi-plan-mode
|
|
71
|
+
* seeds a planning conversation: an idle session takes it now, a busy one
|
|
72
|
+
* takes it as a follow-up rather than steering the turn in flight.
|
|
73
|
+
*/
|
|
74
|
+
const sendPlanningMessage = (text: string, ctx: ExtensionCommandContext): void => {
|
|
75
|
+
try {
|
|
76
|
+
if (ctx.isIdle()) pi.sendUserMessage(text);
|
|
77
|
+
else pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
80
|
+
ctx.ui.notify(`Unable to send the loop planning message: ${detail}`, "error");
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const beginPlanning = (ctx: ExtensionCommandContext): void => {
|
|
85
|
+
if (controller.planning.active) return;
|
|
86
|
+
activatePropose();
|
|
87
|
+
controller.beginPlanning();
|
|
88
|
+
ctx.ui.notify(
|
|
89
|
+
"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.",
|
|
90
|
+
"info",
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
47
94
|
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),
|
|
95
|
+
description: "Plan, approve, and manage a long-running loop: /loop [what it should achieve]",
|
|
51
96
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
52
97
|
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();
|
|
98
|
+
const running = controller.state !== undefined && controller.state.status !== "stopped";
|
|
99
|
+
// `/loop <text>` mirrors `/plan <prompt>`: it opens planning and says the
|
|
100
|
+
// first thing. A loop already running owns the session, so the text goes
|
|
101
|
+
// nowhere and the manager opens instead of a second draft.
|
|
102
|
+
if (command.kind === "seed") {
|
|
103
|
+
if (running) {
|
|
76
104
|
ctx.ui.notify(
|
|
77
|
-
"
|
|
78
|
-
"
|
|
105
|
+
"A loop is already running in this session. Stop it from this menu before planning another.",
|
|
106
|
+
"warning",
|
|
79
107
|
);
|
|
108
|
+
await showLoopManager(controller, ctx);
|
|
80
109
|
return;
|
|
81
110
|
}
|
|
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
|
-
}
|
|
111
|
+
beginPlanning(ctx);
|
|
112
|
+
sendPlanningMessage(command.text, ctx);
|
|
113
|
+
return;
|
|
122
114
|
}
|
|
115
|
+
// Bare /loop is the front door, and which door it opens is the state:
|
|
116
|
+
// manager, approval card, planning menu, or launch menu.
|
|
117
|
+
if (running) {
|
|
118
|
+
await showLoopManager(controller, ctx);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (controller.planning.proposal) {
|
|
122
|
+
await showLoopApproval(controller, ctx);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (controller.planning.active) {
|
|
126
|
+
await showLoopPlanning(controller, ctx, {
|
|
127
|
+
requestProposal: () => sendPlanningMessage(REQUEST_PROPOSAL_MESSAGE, ctx),
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
await showLoopLaunch(controller, ctx, () => beginPlanning(ctx));
|
|
123
132
|
},
|
|
124
133
|
});
|
|
125
134
|
|
|
126
135
|
pi.on("session_start", async (_event, ctx) => {
|
|
136
|
+
proposeActivated = false;
|
|
137
|
+
runtimeActivated = false;
|
|
127
138
|
controller.onSessionStart(ctx);
|
|
139
|
+
if (controller.state?.status === "active") activateRuntime();
|
|
140
|
+
else reconcileTools();
|
|
128
141
|
});
|
|
129
142
|
pi.on("session_shutdown", async () => {
|
|
130
143
|
controller.onSessionShutdown();
|
|
@@ -143,7 +156,21 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
143
156
|
// A loop carries its own objective and injects it as a byte-stable system
|
|
144
157
|
// append, which is what lets the poke and continuation messages stay
|
|
145
158
|
// pointer-sized.
|
|
146
|
-
pi.on("before_agent_start", (event) => {
|
|
159
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
160
|
+
// Self-heal the runtime tool set every turn an active loop takes, not just
|
|
161
|
+
// at session_start. `resumeLoop` flips a restored *paused* loop to active
|
|
162
|
+
// and dispatches a continuation from the /loop menu, which has no way to
|
|
163
|
+
// reach activateRuntime — so without this the resumed loop would run with
|
|
164
|
+
// loop_complete stripped, be told by its own objective append to call it,
|
|
165
|
+
// and then be re-paused by enforceToolAvailability blaming --tools for
|
|
166
|
+
// something this extension did to itself. Activation is monotonic, so this
|
|
167
|
+
// covers resumeAfterEdit and the fresh-session handoff too, and costs a
|
|
168
|
+
// no-op set comparison on every other turn.
|
|
169
|
+
if (controller.state?.status === "active") activateRuntime();
|
|
170
|
+
// Plan mode owns the prompt while active. Loop scheduling is already held
|
|
171
|
+
// by the same persisted state; suppressing the append removes the remaining
|
|
172
|
+
// mixed-workflow instruction surface.
|
|
173
|
+
if (readPlanModeEnabled(ctx.sessionManager.getBranch())) return;
|
|
147
174
|
// Planning precedes any loop, so its guidance is injected on the same hook
|
|
148
175
|
// and is mutually exclusive with the objective append below.
|
|
149
176
|
if (controller.planning.active) {
|
|
@@ -155,4 +182,9 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
155
182
|
if (objectivePrompt === undefined) return;
|
|
156
183
|
return { systemPrompt: `${event.systemPrompt}\n\n${objectivePrompt}` };
|
|
157
184
|
});
|
|
185
|
+
|
|
186
|
+
// Pi ignores the return value; a test uses it. The controller *is* the
|
|
187
|
+
// extension's state, and a test holding a different instance of it would
|
|
188
|
+
// quietly assert against a loop nobody is running.
|
|
189
|
+
return controller;
|
|
158
190
|
}
|
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: [
|