@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/CHANGELOG.md +32 -0
- package/README.md +52 -32
- package/package.json +4 -1
- package/skills/pi-loop/SKILL.md +73 -45
- package/src/command.ts +33 -187
- package/src/complete-tool.ts +1 -1
- package/src/fresh-launch.ts +128 -0
- package/src/index.ts +70 -79
- package/src/ledger.ts +2 -2
- package/src/loop-action-menus.ts +130 -0
- package/src/loop-env.ts +50 -0
- package/src/loop-launch-menu.ts +158 -0
- package/src/loop-manager-menu.ts +191 -0
- package/src/loop.ts +156 -29
- package/src/manager.ts +213 -147
- package/src/messages.ts +1 -1
- package/src/objective.ts +38 -1
- package/src/planning.ts +78 -24
- package/src/presentation.ts +50 -0
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +42 -15
- package/src/settings.ts +27 -22
- package/src/state.ts +43 -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,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting an approved loop in a fresh session.
|
|
3
|
+
*
|
|
4
|
+
* A loop started interactively owns the session it was planned in, and the
|
|
5
|
+
* planning conversation is the worst possible context for it: every turn of
|
|
6
|
+
* drafting is carried, re-read and re-billed for the whole run, and none of it
|
|
7
|
+
* is the objective. The card is where that gets fixed, because the card is
|
|
8
|
+
* where the user is already deciding how the loop should run.
|
|
9
|
+
*
|
|
10
|
+
* Modelled on `packages/pi-plan-mode/src/fresh-implementation.ts`, which
|
|
11
|
+
* solves the same problem for a plan. The difference is what crosses: a plan
|
|
12
|
+
* hands over a file path, while a loop hands over its state, appended to the
|
|
13
|
+
* new session in `setup` exactly as `persist` would have appended it here.
|
|
14
|
+
* Only the objective and the caps cross; the drafting conversation does not.
|
|
15
|
+
*
|
|
16
|
+
* The ledger is written before the handoff, from this session. It is a
|
|
17
|
+
* filesystem artifact keyed by loop id, not session state, and writing it here
|
|
18
|
+
* is what makes the approved criteria authoritative: the restoring session
|
|
19
|
+
* treats whatever is already on disk as the truth, so criteria written after
|
|
20
|
+
* it restores would arrive too late to be the ones it is held to.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import type { BuiltLoop } from "./loop.js";
|
|
25
|
+
import { LOOP_STATE_ENTRY_TYPE } from "./state.js";
|
|
26
|
+
|
|
27
|
+
type NewSessionOptions = Exclude<Parameters<ExtensionCommandContext["newSession"]>[0], undefined>;
|
|
28
|
+
type SessionManagerLike = Parameters<NonNullable<NewSessionOptions["setup"]>>[0];
|
|
29
|
+
|
|
30
|
+
export type FreshLoopResult =
|
|
31
|
+
| { kind: "started" }
|
|
32
|
+
| { kind: "cancelled" }
|
|
33
|
+
/** The session exists and holds the loop, but it could not be kicked off. */
|
|
34
|
+
| { kind: "partial"; detail: string }
|
|
35
|
+
| { kind: "rejected"; detail: string };
|
|
36
|
+
|
|
37
|
+
export interface FreshLoopRequest {
|
|
38
|
+
built: BuiltLoop;
|
|
39
|
+
/** Write the ledger for `built` before the handoff; returns a failure detail. */
|
|
40
|
+
prepareLedger(): string | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function startLoopInFreshSession(
|
|
44
|
+
ctx: ExtensionContext,
|
|
45
|
+
request: FreshLoopRequest,
|
|
46
|
+
): Promise<FreshLoopResult> {
|
|
47
|
+
if (!isCommandContext(ctx)) {
|
|
48
|
+
return {
|
|
49
|
+
kind: "rejected",
|
|
50
|
+
detail:
|
|
51
|
+
"Starting a loop in a fresh session needs the interactive /loop command. Run /loop again and choose it from the menu.",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (ctx.mode === "print" || ctx.mode === "json") {
|
|
55
|
+
return {
|
|
56
|
+
kind: "rejected",
|
|
57
|
+
detail: "A fresh session is unavailable in print/JSON mode. Start the loop in this session.",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ledgerFailure = request.prepareLedger();
|
|
62
|
+
if (ledgerFailure) {
|
|
63
|
+
// Not fatal to the loop — a loop runs without a ledger — but it is fatal
|
|
64
|
+
// to *this* path: the new session would derive its own criteria from the
|
|
65
|
+
// objective and could be held to a different gate than the one approved.
|
|
66
|
+
return {
|
|
67
|
+
kind: "rejected",
|
|
68
|
+
detail: `The loop's ledger could not be written (${ledgerFailure}), so the approved criteria could not be handed to a new session. Start the loop in this session instead.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await ctx.waitForIdle();
|
|
73
|
+
|
|
74
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
75
|
+
let setupError: string | undefined;
|
|
76
|
+
|
|
77
|
+
let result: Awaited<ReturnType<ExtensionCommandContext["newSession"]>>;
|
|
78
|
+
try {
|
|
79
|
+
result = await ctx.newSession({
|
|
80
|
+
...(parentSession ? { parentSession } : {}),
|
|
81
|
+
setup: async (sessionManager: SessionManagerLike) => {
|
|
82
|
+
try {
|
|
83
|
+
// The same entry `persist` writes, so the new session's ordinary
|
|
84
|
+
// restore path picks it up with no special case — plus the handoff
|
|
85
|
+
// flag, which is what tells that session it owns the first turn.
|
|
86
|
+
// The kickoff cannot be driven from here: Pi builds a new extension
|
|
87
|
+
// instance for the new session, so this session's controller is not
|
|
88
|
+
// the one that ends up holding the loop.
|
|
89
|
+
sessionManager.appendCustomEntry(LOOP_STATE_ENTRY_TYPE, {
|
|
90
|
+
loop: { ...request.built.loop, handoff: true },
|
|
91
|
+
});
|
|
92
|
+
} catch (error: unknown) {
|
|
93
|
+
setupError = errorDetail(error);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
withSession: async (replacementCtx: ExtensionContext) => {
|
|
97
|
+
if (setupError) {
|
|
98
|
+
replacementCtx.ui.notify(
|
|
99
|
+
`Fresh session created, but the loop could not be handed to it: ${setupError}. Nothing is running; start the loop from /loop in either session.`,
|
|
100
|
+
"error",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
} catch (error: unknown) {
|
|
106
|
+
return {
|
|
107
|
+
kind: "rejected",
|
|
108
|
+
detail: `Unable to start a fresh session: ${errorDetail(error)}. The draft is unchanged; start the loop in this session instead.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (result.cancelled) return { kind: "cancelled" };
|
|
113
|
+
return setupError ? { kind: "partial", detail: setupError } : { kind: "started" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isCommandContext(ctx: ExtensionContext): ctx is ExtensionCommandContext {
|
|
117
|
+
return typeof (ctx as Partial<ExtensionCommandContext>).newSession === "function";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function errorDetail(error: unknown) {
|
|
121
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
122
|
+
const normalized =
|
|
123
|
+
detail
|
|
124
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
|
125
|
+
.replace(/\s+/gu, " ")
|
|
126
|
+
.trim() || "unknown error";
|
|
127
|
+
return normalized.length > 500 ? `${normalized.slice(0, 499)}…` : normalized;
|
|
128
|
+
}
|
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.
|