@brettinternet/pi-loop 0.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/README.md +28 -0
- package/index.ts +725 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Pi Loop
|
|
2
|
+
|
|
3
|
+
`@brettinternet/pi-loop` runs one prompt for a bounded number of iterations. Every iteration gets a new Pi session, so files remain available while conversation history does not.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
/loop <count> <prompt> Start a loop; count must be positive
|
|
9
|
+
/loop <count> While active or stopping, replace the future-iteration budget
|
|
10
|
+
/loop +<count> Add future iterations while active or stopping
|
|
11
|
+
/loop -<count> Remove future iterations while active or stopping
|
|
12
|
+
/loop status Show run, iteration, budget, and pending retune
|
|
13
|
+
/loop Gracefully stop after the active iteration
|
|
14
|
+
/loop stop Gracefully stop, or stop a paused loop immediately
|
|
15
|
+
/loop resume Retry a paused iteration, or cancel a pending stop
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A retune changes only the next boundary's future budget; it never changes the prompt. Retuning or resuming while a graceful stop is pending cancels the stop and reuses the loop's prompt. A subtraction may reduce that budget to zero, ending the loop after the active iteration. Invalid or ambiguous forms are rejected instead of guessing.
|
|
19
|
+
|
|
20
|
+
Loops continue only after `agent_settled`. Aborted or error assistant output pauses the loop without consuming an iteration. State is stored in custom session entries, and each replacement records its parent session while keeping conversational messages out of the new session. The compact status widget is shown only while a loop is active, stopping, or paused. It counts down (`loop active 4/4`, then `3/4`) and shows the prompt after a middle dot, truncated to one line at the current terminal width.
|
|
21
|
+
|
|
22
|
+
The extension does not use dialogs and is safe to load in print, JSON, and RPC modes.
|
|
23
|
+
|
|
24
|
+
Install it with:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pi install npm:@brettinternet/pi-loop
|
|
28
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionCommandContext,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
SessionEntry,
|
|
7
|
+
SessionManager,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
export const LOOP_STATE_ENTRY = "pi-loop-state-v1";
|
|
12
|
+
export const LOOP_WIDGET_KEY = "pi-loop";
|
|
13
|
+
export const LOOP_USAGE =
|
|
14
|
+
"usage: /loop <positive-count> <prompt> | /loop <positive-count> | /loop <+|-><count> | /loop status | /loop resume | /loop stop";
|
|
15
|
+
|
|
16
|
+
export type LoopStatus = "active" | "stopping" | "paused" | "completed" | "stopped" | "inactive";
|
|
17
|
+
|
|
18
|
+
export interface LoopState {
|
|
19
|
+
version: 1;
|
|
20
|
+
runId: string;
|
|
21
|
+
prompt: string;
|
|
22
|
+
currentIteration: number;
|
|
23
|
+
remainingBudget: number;
|
|
24
|
+
pendingRetune: number | null;
|
|
25
|
+
status: LoopStatus;
|
|
26
|
+
ownerSessionId?: string;
|
|
27
|
+
ownerSessionFile?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ParsedLoopCommand =
|
|
31
|
+
| { kind: "start"; count: number; prompt: string }
|
|
32
|
+
| { kind: "retune"; count: number }
|
|
33
|
+
| { kind: "adjust"; delta: number }
|
|
34
|
+
| { kind: "status" }
|
|
35
|
+
| { kind: "resume" }
|
|
36
|
+
| { kind: "stop" }
|
|
37
|
+
| { kind: "continue"; runId: string; iteration: number }
|
|
38
|
+
| { kind: "pause"; runId: string; iteration: number };
|
|
39
|
+
|
|
40
|
+
const ACTIVE_STATUSES = new Set<LoopStatus>(["active", "stopping"]);
|
|
41
|
+
const VISIBLE_STATUSES = new Set<LoopStatus>(["active", "stopping", "paused"]);
|
|
42
|
+
const TERMINAL_STATUSES = new Set<LoopStatus>(["completed", "stopped", "inactive"]);
|
|
43
|
+
|
|
44
|
+
type ArgumentCompletion = { value: string; label: string; description?: string };
|
|
45
|
+
|
|
46
|
+
function completeArguments(
|
|
47
|
+
prefix: string,
|
|
48
|
+
candidates: readonly ArgumentCompletion[],
|
|
49
|
+
): ArgumentCompletion[] | null {
|
|
50
|
+
const query = prefix.trimStart().toLowerCase();
|
|
51
|
+
const matches = candidates.filter(({ value }) => value.toLowerCase().includes(query));
|
|
52
|
+
return matches.length > 0 ? matches : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
60
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isNonNegativeInteger(value: unknown): value is number {
|
|
64
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Parse public and internal /loop arguments without consulting current run state. */
|
|
68
|
+
export function parseLoopCommand(args: string): ParsedLoopCommand {
|
|
69
|
+
const input = args.trim();
|
|
70
|
+
if (!input) return { kind: "stop" };
|
|
71
|
+
|
|
72
|
+
const firstSpace = input.search(/\s/);
|
|
73
|
+
const first = firstSpace < 0 ? input : input.slice(0, firstSpace);
|
|
74
|
+
const rest = firstSpace < 0 ? "" : input.slice(firstSpace).trim();
|
|
75
|
+
|
|
76
|
+
if (first === "status") {
|
|
77
|
+
if (rest) throw new Error(`status does not accept arguments; ${LOOP_USAGE}`);
|
|
78
|
+
return { kind: "status" };
|
|
79
|
+
}
|
|
80
|
+
if (first === "stop") {
|
|
81
|
+
if (rest) throw new Error(`stop does not accept arguments; ${LOOP_USAGE}`);
|
|
82
|
+
return { kind: "stop" };
|
|
83
|
+
}
|
|
84
|
+
if (first === "resume") {
|
|
85
|
+
if (rest) throw new Error(`resume does not accept arguments; ${LOOP_USAGE}`);
|
|
86
|
+
return { kind: "resume" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// These commands are only emitted by the extension itself. Keeping them in
|
|
90
|
+
// the same dispatcher gives boundary transitions command-only session APIs
|
|
91
|
+
// while preventing user input from accidentally looking like one.
|
|
92
|
+
if (first === "__continue" || first === "__pause") {
|
|
93
|
+
const fields = rest.split(/\s+/).filter(Boolean);
|
|
94
|
+
if (fields.length !== 2 || !/^[A-Za-z0-9_-]+$/.test(fields[0])) {
|
|
95
|
+
throw new Error("invalid internal loop command");
|
|
96
|
+
}
|
|
97
|
+
const iteration = Number(fields[1]);
|
|
98
|
+
if (!isPositiveInteger(iteration) || !/^\d+$/.test(fields[1])) {
|
|
99
|
+
throw new Error("invalid internal loop command iteration");
|
|
100
|
+
}
|
|
101
|
+
return first === "__continue"
|
|
102
|
+
? { kind: "continue", runId: fields[0], iteration }
|
|
103
|
+
: { kind: "pause", runId: fields[0], iteration };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (/^[+\-]\d/.test(first)) {
|
|
107
|
+
if (rest || !/^[+\-]\d+$/.test(first)) {
|
|
108
|
+
throw new Error(`adjustment must be +<count> or -<count>; ${LOOP_USAGE}`);
|
|
109
|
+
}
|
|
110
|
+
const count = Number(first.slice(1));
|
|
111
|
+
if (!isPositiveInteger(count)) throw new Error(`count must be a positive integer; ${LOOP_USAGE}`);
|
|
112
|
+
return { kind: "adjust", delta: first[0] === "+" ? count : -count };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Treat anything that starts like a count as a count error, rather than as
|
|
116
|
+
// an opaque command, so zero, decimals, and overflow are explicit.
|
|
117
|
+
if (/^\d/.test(first)) {
|
|
118
|
+
if (!/^\d+$/.test(first)) throw new Error(`count must be a positive integer; ${LOOP_USAGE}`);
|
|
119
|
+
const count = Number(first);
|
|
120
|
+
if (!isPositiveInteger(count)) throw new Error(`count must be a positive integer; ${LOOP_USAGE}`);
|
|
121
|
+
return rest ? { kind: "start", count, prompt: rest } : { kind: "retune", count };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new Error(`expected a positive count or a loop command; ${LOOP_USAGE}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Return the latest loop state on a session branch. */
|
|
128
|
+
export function readLoopState(entries: readonly SessionEntry[] | readonly unknown[]): LoopState | undefined {
|
|
129
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
130
|
+
const entry = entries[index];
|
|
131
|
+
if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== LOOP_STATE_ENTRY) continue;
|
|
132
|
+
return parseLoopState(entry.data);
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function parseLoopState(value: unknown): LoopState | undefined {
|
|
138
|
+
if (!isRecord(value)) return undefined;
|
|
139
|
+
const status = value.status;
|
|
140
|
+
if (
|
|
141
|
+
status !== "active" &&
|
|
142
|
+
status !== "stopping" &&
|
|
143
|
+
status !== "paused" &&
|
|
144
|
+
status !== "completed" &&
|
|
145
|
+
status !== "stopped" &&
|
|
146
|
+
status !== "inactive"
|
|
147
|
+
) {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
value.version !== 1 ||
|
|
152
|
+
typeof value.runId !== "string" ||
|
|
153
|
+
!value.runId ||
|
|
154
|
+
typeof value.prompt !== "string" ||
|
|
155
|
+
!value.prompt.trim() ||
|
|
156
|
+
!isPositiveInteger(value.currentIteration) ||
|
|
157
|
+
!isNonNegativeInteger(value.remainingBudget) ||
|
|
158
|
+
(value.pendingRetune !== null && !isNonNegativeInteger(value.pendingRetune))
|
|
159
|
+
) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
if (value.ownerSessionId !== undefined && typeof value.ownerSessionId !== "string") return undefined;
|
|
163
|
+
if (value.ownerSessionFile !== undefined && typeof value.ownerSessionFile !== "string") return undefined;
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
version: 1,
|
|
167
|
+
runId: value.runId,
|
|
168
|
+
prompt: value.prompt,
|
|
169
|
+
currentIteration: value.currentIteration,
|
|
170
|
+
remainingBudget: value.remainingBudget,
|
|
171
|
+
pendingRetune: value.pendingRetune,
|
|
172
|
+
status,
|
|
173
|
+
...(value.ownerSessionId ? { ownerSessionId: value.ownerSessionId } : {}),
|
|
174
|
+
...(value.ownerSessionFile ? { ownerSessionFile: value.ownerSessionFile } : {}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function formatLoopStatus(state: LoopState | undefined): string {
|
|
179
|
+
if (!state || state.status === "inactive") return "loop: idle";
|
|
180
|
+
const pending = state.pendingRetune === null ? "none" : String(state.pendingRetune);
|
|
181
|
+
return [
|
|
182
|
+
`loop: ${state.status}`,
|
|
183
|
+
`run: ${state.runId}`,
|
|
184
|
+
`iteration: ${state.currentIteration}`,
|
|
185
|
+
`remaining: ${state.remainingBudget}`,
|
|
186
|
+
`pending retune: ${pending}`,
|
|
187
|
+
].join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type SessionIdentity = {
|
|
191
|
+
id?: string;
|
|
192
|
+
file?: string;
|
|
193
|
+
token?: string;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
type ContextWithSession = Pick<ExtensionContext, "sessionManager">;
|
|
197
|
+
type SessionIdentitySource = Pick<SessionManager, "getSessionId" | "getSessionFile">;
|
|
198
|
+
type ReplacementContext = ExtensionCommandContext & {
|
|
199
|
+
sendUserMessage(
|
|
200
|
+
content: string,
|
|
201
|
+
options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean },
|
|
202
|
+
): Promise<void>;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
function sessionIdentity(value: SessionIdentitySource): SessionIdentity {
|
|
206
|
+
try {
|
|
207
|
+
const id = value.getSessionId();
|
|
208
|
+
const file = value.getSessionFile();
|
|
209
|
+
return {
|
|
210
|
+
id: id || undefined,
|
|
211
|
+
file: file || undefined,
|
|
212
|
+
token: file || id || undefined,
|
|
213
|
+
};
|
|
214
|
+
} catch {
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function contextIdentity(ctx: ContextWithSession): SessionIdentity {
|
|
220
|
+
try {
|
|
221
|
+
return sessionIdentity(ctx.sessionManager);
|
|
222
|
+
} catch {
|
|
223
|
+
return {};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function stateBelongsToContext(state: LoopState, ctx: ContextWithSession): boolean {
|
|
228
|
+
const identity = contextIdentity(ctx);
|
|
229
|
+
if (state.ownerSessionFile && identity.file) return state.ownerSessionFile === identity.file;
|
|
230
|
+
if (state.ownerSessionId && identity.id) return state.ownerSessionId === identity.id;
|
|
231
|
+
// Older/in-memory test sessions may not expose identity metadata. The
|
|
232
|
+
// persisted status still protects them from stale callbacks.
|
|
233
|
+
return !state.ownerSessionFile && !state.ownerSessionId;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function stateForSession(state: LoopState, identity: SessionIdentity): LoopState {
|
|
237
|
+
const { ownerSessionId: _oldOwnerId, ownerSessionFile: _oldOwnerFile, ...withoutOwner } = state;
|
|
238
|
+
return {
|
|
239
|
+
...withoutOwner,
|
|
240
|
+
...(identity.id ? { ownerSessionId: identity.id } : {}),
|
|
241
|
+
...(identity.file ? { ownerSessionFile: identity.file } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function latestStateFromContext(ctx: ContextWithSession): LoopState | undefined {
|
|
246
|
+
try {
|
|
247
|
+
return readLoopState(ctx.sessionManager.getBranch());
|
|
248
|
+
} catch {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function statusIsActive(state: LoopState | undefined): state is LoopState {
|
|
254
|
+
return Boolean(state && ACTIVE_STATUSES.has(state.status));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function statusIsVisible(state: LoopState | undefined): state is LoopState {
|
|
258
|
+
return Boolean(state && VISIBLE_STATUSES.has(state.status));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function isTerminal(state: LoopState | undefined): boolean {
|
|
262
|
+
return Boolean(state && TERMINAL_STATUSES.has(state.status));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function formatLoopWidget(state: LoopState, width: number): string {
|
|
266
|
+
const prompt = state.prompt.replace(/\s+/g, " ").trim();
|
|
267
|
+
if (state.status === "stopping") {
|
|
268
|
+
return truncateToWidth(`loop stopping · ${prompt}`, width, "…");
|
|
269
|
+
}
|
|
270
|
+
const futureIterations = state.pendingRetune ?? state.remainingBudget;
|
|
271
|
+
const remainingIterations = futureIterations + 1;
|
|
272
|
+
const totalIterations = state.currentIteration + futureIterations;
|
|
273
|
+
return truncateToWidth(
|
|
274
|
+
`loop ${state.status} ${remainingIterations}/${totalIterations} · ${prompt}`,
|
|
275
|
+
width,
|
|
276
|
+
"…",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export default function loopExtension(pi: ExtensionAPI): void {
|
|
281
|
+
let runState: LoopState | undefined;
|
|
282
|
+
let transitionInFlight = false;
|
|
283
|
+
let handledSettlementKey: string | undefined;
|
|
284
|
+
let currentSessionManagerRef: unknown;
|
|
285
|
+
|
|
286
|
+
function stateFrom(ctx: ContextWithSession): LoopState | undefined {
|
|
287
|
+
runState = latestStateFromContext(ctx);
|
|
288
|
+
return runState;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function persist(ctx: Pick<ExtensionAPI, "appendEntry">, state: LoopState): void {
|
|
292
|
+
ctx.appendEntry(LOOP_STATE_ENTRY, state);
|
|
293
|
+
runState = state;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function notify(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info"): void {
|
|
297
|
+
if (ctx.hasUI) {
|
|
298
|
+
ctx.ui.notify(message, type);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const output = `[pi-loop] ${message}`;
|
|
302
|
+
if (type === "error") console.error(output);
|
|
303
|
+
else console.warn(output);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function clearWidget(ctx: ExtensionContext): void {
|
|
307
|
+
if (ctx.hasUI) ctx.ui.setWidget(LOOP_WIDGET_KEY, undefined);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function showWidget(ctx: ExtensionContext, state: LoopState): void {
|
|
311
|
+
if (ctx.mode !== "tui") {
|
|
312
|
+
ctx.ui.setWidget(LOOP_WIDGET_KEY, [formatLoopWidget(state, Number.MAX_SAFE_INTEGER)], { placement: "belowEditor" });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
ctx.ui.setWidget(LOOP_WIDGET_KEY, (_tui, _theme) => ({
|
|
316
|
+
render: (width) => [formatLoopWidget(state, width)],
|
|
317
|
+
invalidate: () => {},
|
|
318
|
+
}), { placement: "belowEditor" });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function renderWidget(ctx: ExtensionContext, state = runState): void {
|
|
322
|
+
if (!ctx.hasUI || !statusIsVisible(state)) {
|
|
323
|
+
clearWidget(ctx);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
showWidget(ctx, state);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function currentState(ctx: ContextWithSession): LoopState | undefined {
|
|
330
|
+
let sessionManager: unknown;
|
|
331
|
+
try {
|
|
332
|
+
sessionManager = ctx.sessionManager;
|
|
333
|
+
} catch {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
if (currentSessionManagerRef !== undefined && sessionManager !== currentSessionManagerRef) return undefined;
|
|
337
|
+
const loaded = stateFrom(ctx);
|
|
338
|
+
if (!loaded) return undefined;
|
|
339
|
+
if (!stateBelongsToContext(loaded, ctx)) return undefined;
|
|
340
|
+
return loaded;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function stateKey(ctx: ContextWithSession, state: LoopState): string {
|
|
344
|
+
const identity = contextIdentity(ctx);
|
|
345
|
+
return `${state.runId}:${state.currentIteration}:${identity.token ?? "unknown"}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function transferState(state: LoopState, manager: SessionManager): LoopState {
|
|
349
|
+
const transferred = stateForSession({ ...state, status: "active" }, sessionIdentity(manager));
|
|
350
|
+
manager.appendCustomEntry(LOOP_STATE_ENTRY, transferred);
|
|
351
|
+
return transferred;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function sendIteration(
|
|
355
|
+
replacement: ReplacementContext,
|
|
356
|
+
state: LoopState,
|
|
357
|
+
): Promise<void> {
|
|
358
|
+
// The new extension instance restores this entry in before_agent_start.
|
|
359
|
+
// This callback still owns the command context, so it is the safe place to
|
|
360
|
+
// start the turn after the replacement is complete.
|
|
361
|
+
if (replacement.hasUI) showWidget(replacement, state);
|
|
362
|
+
await replacement.sendUserMessage(state.prompt);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function replaceForIteration(ctx: ExtensionCommandContext, next: LoopState): Promise<void> {
|
|
366
|
+
const sourceIdentity = contextIdentity(ctx);
|
|
367
|
+
const parentSession = sourceIdentity.file;
|
|
368
|
+
const inactive = {
|
|
369
|
+
...next,
|
|
370
|
+
status: "inactive" as const,
|
|
371
|
+
...(sourceIdentity.id ? { ownerSessionId: sourceIdentity.id } : {}),
|
|
372
|
+
...(sourceIdentity.file ? { ownerSessionFile: sourceIdentity.file } : {}),
|
|
373
|
+
};
|
|
374
|
+
transitionInFlight = true;
|
|
375
|
+
// Persist the ownership handoff before invoking newSession. If the switch
|
|
376
|
+
// is cancelled, this marker is replaced with paused state below.
|
|
377
|
+
persist(pi, inactive);
|
|
378
|
+
clearWidget(ctx);
|
|
379
|
+
|
|
380
|
+
let transferred: LoopState | undefined;
|
|
381
|
+
try {
|
|
382
|
+
const result = await ctx.newSession({
|
|
383
|
+
...(parentSession ? { parentSession } : {}),
|
|
384
|
+
setup: async (manager) => {
|
|
385
|
+
transferred = transferState(next, manager);
|
|
386
|
+
},
|
|
387
|
+
withSession: async (replacement) => {
|
|
388
|
+
if (!transferred) throw new Error("loop state was not transferred into the new session");
|
|
389
|
+
transitionInFlight = false;
|
|
390
|
+
try {
|
|
391
|
+
await sendIteration(replacement, transferred);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
// A prompt can fail before agent_end (for example when no model is
|
|
394
|
+
// configured). Dispatch a private command in the replacement
|
|
395
|
+
// runtime so its own pi.appendEntry remains current.
|
|
396
|
+
try {
|
|
397
|
+
await replacement.sendUserMessage(
|
|
398
|
+
`/loop __pause ${transferred.runId} ${transferred.currentIteration}`,
|
|
399
|
+
{ expandPromptTemplates: true },
|
|
400
|
+
);
|
|
401
|
+
} catch {
|
|
402
|
+
// The replacement may already be shutting down; its inactive
|
|
403
|
+
// ownership marker still prevents an accidental continuation.
|
|
404
|
+
}
|
|
405
|
+
notify(
|
|
406
|
+
replacement,
|
|
407
|
+
`loop paused: ${error instanceof Error ? error.message : String(error)}`,
|
|
408
|
+
"error",
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (result.cancelled) {
|
|
415
|
+
transitionInFlight = false;
|
|
416
|
+
const paused: LoopState = {
|
|
417
|
+
...next,
|
|
418
|
+
status: "paused",
|
|
419
|
+
...(sourceIdentity.id ? { ownerSessionId: sourceIdentity.id } : {}),
|
|
420
|
+
...(sourceIdentity.file ? { ownerSessionFile: sourceIdentity.file } : {}),
|
|
421
|
+
};
|
|
422
|
+
persist(pi, paused);
|
|
423
|
+
runState = paused;
|
|
424
|
+
renderWidget(ctx, paused);
|
|
425
|
+
notify(ctx, "loop paused: session replacement was cancelled", "warning");
|
|
426
|
+
}
|
|
427
|
+
} catch (error) {
|
|
428
|
+
transitionInFlight = false;
|
|
429
|
+
// A replacement can invalidate ctx before throwing. In that case the
|
|
430
|
+
// inactive marker remains authoritative and a later resume is required.
|
|
431
|
+
try {
|
|
432
|
+
const paused: LoopState = {
|
|
433
|
+
...next,
|
|
434
|
+
status: "paused",
|
|
435
|
+
...(sourceIdentity.id ? { ownerSessionId: sourceIdentity.id } : {}),
|
|
436
|
+
...(sourceIdentity.file ? { ownerSessionFile: sourceIdentity.file } : {}),
|
|
437
|
+
};
|
|
438
|
+
persist(pi, paused);
|
|
439
|
+
runState = paused;
|
|
440
|
+
renderWidget(ctx, paused);
|
|
441
|
+
notify(ctx, `loop paused: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
442
|
+
} catch {
|
|
443
|
+
console.error(`[pi-loop] session replacement failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function advanceAtBoundary(ctx: ExtensionCommandContext, expectedRunId: string, expectedIteration: number): Promise<void> {
|
|
449
|
+
const state = currentState(ctx);
|
|
450
|
+
if (!state || state.runId !== expectedRunId || state.currentIteration !== expectedIteration) return;
|
|
451
|
+
if (!statusIsActive(state) || transitionInFlight) return;
|
|
452
|
+
|
|
453
|
+
if (state.status === "stopping") {
|
|
454
|
+
const stopped = { ...state, status: "stopped" as const };
|
|
455
|
+
persist(pi, stopped);
|
|
456
|
+
clearWidget(ctx);
|
|
457
|
+
runState = stopped;
|
|
458
|
+
notify(ctx, "loop stopped", "info");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const nextBudget = state.pendingRetune ?? state.remainingBudget;
|
|
463
|
+
if (nextBudget <= 0) {
|
|
464
|
+
const completed = { ...state, status: "completed" as const, pendingRetune: null };
|
|
465
|
+
persist(pi, completed);
|
|
466
|
+
clearWidget(ctx);
|
|
467
|
+
runState = completed;
|
|
468
|
+
notify(ctx, `loop completed after ${state.currentIteration} iteration${state.currentIteration === 1 ? "" : "s"}`, "info");
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const next: LoopState = {
|
|
473
|
+
...state,
|
|
474
|
+
currentIteration: state.currentIteration + 1,
|
|
475
|
+
remainingBudget: nextBudget - 1,
|
|
476
|
+
pendingRetune: null,
|
|
477
|
+
status: "active",
|
|
478
|
+
};
|
|
479
|
+
await replaceForIteration(ctx, next);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function dispatchContinuation(ctx: ExtensionContext, state: LoopState): void {
|
|
483
|
+
const command = `/loop __continue ${state.runId} ${state.currentIteration}`;
|
|
484
|
+
try {
|
|
485
|
+
const result = (pi.sendUserMessage as unknown as (
|
|
486
|
+
content: string,
|
|
487
|
+
options?: { expandPromptTemplates?: boolean },
|
|
488
|
+
) => unknown)(command, { expandPromptTemplates: true });
|
|
489
|
+
if (result && typeof (result as Promise<unknown>).then === "function") {
|
|
490
|
+
void (result as Promise<unknown>).catch((error) => {
|
|
491
|
+
const latest = currentState(ctx);
|
|
492
|
+
if (!latest || latest.runId !== state.runId || latest.currentIteration !== state.currentIteration) return;
|
|
493
|
+
const paused = { ...latest, status: "paused" as const };
|
|
494
|
+
try {
|
|
495
|
+
persist(pi, paused);
|
|
496
|
+
renderWidget(ctx, paused);
|
|
497
|
+
} catch {
|
|
498
|
+
// The runtime may already have replaced this session.
|
|
499
|
+
}
|
|
500
|
+
console.error(`[pi-loop] continuation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
} catch (error) {
|
|
504
|
+
const latest = currentState(ctx);
|
|
505
|
+
if (!latest || latest.runId !== state.runId || latest.currentIteration !== state.currentIteration) return;
|
|
506
|
+
const paused = { ...latest, status: "paused" as const };
|
|
507
|
+
try {
|
|
508
|
+
persist(pi, paused);
|
|
509
|
+
renderWidget(ctx, paused);
|
|
510
|
+
} catch {
|
|
511
|
+
// The runtime may already have replaced this session.
|
|
512
|
+
}
|
|
513
|
+
notify(ctx, `loop paused: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function handleCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
518
|
+
const parsed = parseLoopCommand(args);
|
|
519
|
+
|
|
520
|
+
if (parsed.kind === "continue") {
|
|
521
|
+
await advanceAtBoundary(ctx, parsed.runId, parsed.iteration);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (parsed.kind === "pause") {
|
|
526
|
+
const state = currentState(ctx);
|
|
527
|
+
if (!state || state.runId !== parsed.runId || state.currentIteration !== parsed.iteration || !statusIsActive(state)) return;
|
|
528
|
+
const paused = { ...state, status: "paused" as const };
|
|
529
|
+
persist(pi, paused);
|
|
530
|
+
renderWidget(ctx, paused);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const state = currentState(ctx);
|
|
535
|
+
|
|
536
|
+
if (parsed.kind === "status") {
|
|
537
|
+
notify(ctx, formatLoopStatus(state), "info");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (parsed.kind === "stop") {
|
|
542
|
+
if (!state || state.status === "inactive" || state.status === "completed" || state.status === "stopped") {
|
|
543
|
+
notify(ctx, "loop: no active run", "info");
|
|
544
|
+
clearWidget(ctx);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (state.status === "paused") {
|
|
548
|
+
const stopped = { ...state, status: "stopped" as const };
|
|
549
|
+
persist(pi, stopped);
|
|
550
|
+
clearWidget(ctx);
|
|
551
|
+
runState = stopped;
|
|
552
|
+
notify(ctx, "loop stopped", "info");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (state.status === "stopping") {
|
|
556
|
+
notify(ctx, "loop is already stopping", "info");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const stopping = { ...state, status: "stopping" as const };
|
|
560
|
+
persist(pi, stopping);
|
|
561
|
+
renderWidget(ctx, stopping);
|
|
562
|
+
notify(ctx, "loop will stop after the active iteration", "info");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (parsed.kind === "resume") {
|
|
567
|
+
if (state?.status === "stopping") {
|
|
568
|
+
const resumed = { ...state, status: "active" as const };
|
|
569
|
+
persist(pi, resumed);
|
|
570
|
+
renderWidget(ctx, resumed);
|
|
571
|
+
notify(ctx, "loop resumed", "info");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (!state || state.status !== "paused") {
|
|
575
|
+
notify(ctx, state && statusIsActive(state) ? "loop is already active" : "loop is not paused", "error");
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
await replaceForIteration(ctx, { ...state, status: "active" });
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (parsed.kind === "retune" || parsed.kind === "adjust") {
|
|
583
|
+
if (!state || (state.status !== "active" && state.status !== "stopping")) {
|
|
584
|
+
notify(ctx, "a loop must be active to retune its remaining budget", "error");
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const currentBudget = state.pendingRetune ?? state.remainingBudget;
|
|
588
|
+
const nextBudget = parsed.kind === "retune" ? parsed.count : currentBudget + parsed.delta;
|
|
589
|
+
if (nextBudget < 0) {
|
|
590
|
+
notify(ctx, `cannot subtract more than the ${currentBudget} future iteration${currentBudget === 1 ? "" : "s"}`, "error");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const retuned = { ...state, pendingRetune: nextBudget, status: "active" as const };
|
|
594
|
+
persist(pi, retuned);
|
|
595
|
+
renderWidget(ctx, retuned);
|
|
596
|
+
notify(ctx, `loop will run ${nextBudget} future iteration${nextBudget === 1 ? "" : "s"}`, "info");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (state && !isTerminal(state)) {
|
|
601
|
+
if (state.status === "paused") {
|
|
602
|
+
notify(ctx, "loop is paused; use /loop resume or /loop stop", "error");
|
|
603
|
+
} else {
|
|
604
|
+
notify(ctx, "a loop is already active; use /loop <positive-count> to retune it", "error");
|
|
605
|
+
}
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const initial: LoopState = {
|
|
610
|
+
version: 1,
|
|
611
|
+
runId: randomUUID(),
|
|
612
|
+
prompt: parsed.prompt,
|
|
613
|
+
currentIteration: 1,
|
|
614
|
+
remainingBudget: parsed.count - 1,
|
|
615
|
+
pendingRetune: null,
|
|
616
|
+
status: "active",
|
|
617
|
+
...(contextIdentity(ctx).id ? { ownerSessionId: contextIdentity(ctx).id } : {}),
|
|
618
|
+
...(contextIdentity(ctx).file ? { ownerSessionFile: contextIdentity(ctx).file } : {}),
|
|
619
|
+
};
|
|
620
|
+
await replaceForIteration(ctx, initial);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
pi.on("session_start", (event, ctx) => {
|
|
624
|
+
currentSessionManagerRef = ctx.sessionManager;
|
|
625
|
+
transitionInFlight = false;
|
|
626
|
+
handledSettlementKey = undefined;
|
|
627
|
+
const loaded = latestStateFromContext(ctx);
|
|
628
|
+
const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
|
|
629
|
+
runState = owned;
|
|
630
|
+
if (!owned || owned.status === "inactive") clearWidget(ctx);
|
|
631
|
+
else renderWidget(ctx, owned);
|
|
632
|
+
// `event` is intentionally accepted so this handler is safe for all
|
|
633
|
+
// startup/new/resume reasons. New-session setup writes the transferred
|
|
634
|
+
// state just after this event; before_agent_start restores it lazily.
|
|
635
|
+
void event;
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
639
|
+
const loaded = currentState(ctx);
|
|
640
|
+
if (!loaded || !statusIsActive(loaded)) return;
|
|
641
|
+
transitionInFlight = false;
|
|
642
|
+
renderWidget(ctx, loaded);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
646
|
+
const loaded = currentState(ctx);
|
|
647
|
+
if (loaded && statusIsActive(loaded)) renderWidget(ctx, loaded);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
pi.on("message_end", (event, ctx) => {
|
|
651
|
+
if (event.message.role !== "assistant") return;
|
|
652
|
+
const stopReason = (event.message as { stopReason?: string }).stopReason;
|
|
653
|
+
if (stopReason !== "aborted" && stopReason !== "error") return;
|
|
654
|
+
const loaded = currentState(ctx);
|
|
655
|
+
if (!loaded || !statusIsActive(loaded) || transitionInFlight) return;
|
|
656
|
+
const paused = { ...loaded, status: "paused" as const };
|
|
657
|
+
persist(pi, paused);
|
|
658
|
+
renderWidget(ctx, paused);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
pi.on("agent_end", (event, ctx) => {
|
|
662
|
+
const assistant = [...event.messages]
|
|
663
|
+
.reverse()
|
|
664
|
+
.find((message) => message.role === "assistant") as { stopReason?: string } | undefined;
|
|
665
|
+
if (!assistant || (assistant.stopReason !== "aborted" && assistant.stopReason !== "error")) return;
|
|
666
|
+
const loaded = currentState(ctx);
|
|
667
|
+
if (!loaded || !statusIsActive(loaded) || transitionInFlight) return;
|
|
668
|
+
const paused = { ...loaded, status: "paused" as const };
|
|
669
|
+
persist(pi, paused);
|
|
670
|
+
renderWidget(ctx, paused);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
674
|
+
const loaded = currentState(ctx);
|
|
675
|
+
if (!loaded || !statusIsActive(loaded) || transitionInFlight) return;
|
|
676
|
+
const key = stateKey(ctx, loaded);
|
|
677
|
+
if (handledSettlementKey === key) return;
|
|
678
|
+
handledSettlementKey = key;
|
|
679
|
+
dispatchContinuation(ctx, loaded);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
683
|
+
currentSessionManagerRef = ctx.sessionManager;
|
|
684
|
+
handledSettlementKey = undefined;
|
|
685
|
+
const loaded = latestStateFromContext(ctx);
|
|
686
|
+
const owned = loaded && stateBelongsToContext(loaded, ctx) ? loaded : undefined;
|
|
687
|
+
runState = owned;
|
|
688
|
+
if (!owned || owned.status === "inactive") clearWidget(ctx);
|
|
689
|
+
else renderWidget(ctx, owned);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
693
|
+
const loaded = currentState(ctx);
|
|
694
|
+
if (loaded && statusIsActive(loaded) && !transitionInFlight) {
|
|
695
|
+
try {
|
|
696
|
+
persist(pi, { ...loaded, status: "inactive" });
|
|
697
|
+
} catch {
|
|
698
|
+
// Shutdown may already have detached the runtime's append action.
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
clearWidget(ctx);
|
|
702
|
+
currentSessionManagerRef = undefined;
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
pi.registerCommand("loop", {
|
|
706
|
+
description: "<count> <prompt> | <count> | ±<count> | status | resume | stop — Run a bounded fresh-session loop",
|
|
707
|
+
getArgumentCompletions: (prefix) => completeArguments(prefix, [
|
|
708
|
+
{ value: "status", label: "status", description: "Show the current loop state" },
|
|
709
|
+
{ value: "resume", label: "resume", description: "Retry a paused iteration" },
|
|
710
|
+
{ value: "stop", label: "stop", description: "Stop gracefully" },
|
|
711
|
+
{ value: "+1", label: "+1", description: "Add one future iteration" },
|
|
712
|
+
{ value: "-1", label: "-1", description: "Remove one future iteration" },
|
|
713
|
+
{ value: "1 ", label: "1 <prompt>", description: "Run a prompt once" },
|
|
714
|
+
{ value: "3 ", label: "3 <prompt>", description: "Run a prompt three times" },
|
|
715
|
+
{ value: "5 ", label: "5 <prompt>", description: "Run a prompt five times" },
|
|
716
|
+
]),
|
|
717
|
+
handler: async (args, ctx) => {
|
|
718
|
+
try {
|
|
719
|
+
await handleCommand(args, ctx);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@brettinternet/pi-loop",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run a prompt repeatedly in fresh Pi sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/brettinternet/pi-extensions.git",
|
|
10
|
+
"directory": "extensions/loop"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/brettinternet/pi-extensions#loop",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/brettinternet/pi-extensions/issues"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"*.ts",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"pi-package",
|
|
25
|
+
"pi-extension",
|
|
26
|
+
"loop",
|
|
27
|
+
"iteration"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22.19.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
34
|
+
},
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./index.ts"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|