@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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The manager: every lifecycle control a running loop has.
|
|
3
|
+
*
|
|
4
|
+
* Pause, resume, stop, status, focus and cadence used to be typed
|
|
5
|
+
* subcommands (`/loop pause`, `/loop status`, …). They are here instead,
|
|
6
|
+
* because a lifecycle control nobody can discover is a control nobody uses,
|
|
7
|
+
* and because the loop is the kind of thing you reach for when you want to
|
|
8
|
+
* *look* at it — at which point a menu that shows the state and offers the
|
|
9
|
+
* actions beats remembering six words.
|
|
10
|
+
*
|
|
11
|
+
* Pause and Resume are mutually exclusive by construction: the screen is
|
|
12
|
+
* built from the loop's status, so a paused loop never offers Pause and an
|
|
13
|
+
* active one never offers Resume. Pinned in the tests, because an item that
|
|
14
|
+
* silently does nothing is the failure mode a menu invites.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { ActionsScreen, DetailScreen, InputScreen } from "@narumitw/pi-tui-kit";
|
|
19
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
20
|
+
|
|
21
|
+
export type LoopManagerScreen = "main" | "status" | "focus" | "cadence";
|
|
22
|
+
export type LoopManagerAction =
|
|
23
|
+
| "pause"
|
|
24
|
+
| "resume"
|
|
25
|
+
| "stop"
|
|
26
|
+
| "settings"
|
|
27
|
+
| "set-focus"
|
|
28
|
+
| "set-cadence";
|
|
29
|
+
|
|
30
|
+
/** What the manager renders: the loop as the menu needs to see it. */
|
|
31
|
+
export interface LoopManagerView {
|
|
32
|
+
/** `stopped` never reaches here; the menu only exists for a live loop. */
|
|
33
|
+
status: "active" | "paused";
|
|
34
|
+
/** The widget line, so the manager and the footer tell the same story. */
|
|
35
|
+
headline: string;
|
|
36
|
+
/** Full status detail, one line each. */
|
|
37
|
+
statusLines: readonly string[];
|
|
38
|
+
/** The loop's recurring focus, when it has one. */
|
|
39
|
+
focus?: string;
|
|
40
|
+
/** The current fallback heartbeat, formatted. */
|
|
41
|
+
interval: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function loopManagerScreen(
|
|
45
|
+
view: LoopManagerView,
|
|
46
|
+
): ActionsScreen<LoopManagerScreen, LoopManagerAction> {
|
|
47
|
+
return {
|
|
48
|
+
kind: "actions",
|
|
49
|
+
title: `Loop · ${view.status}`,
|
|
50
|
+
lines: [view.headline],
|
|
51
|
+
items: [
|
|
52
|
+
{ id: "status", label: "Status", description: "The loop's full state.", to: "status" },
|
|
53
|
+
...(view.status === "active"
|
|
54
|
+
? ([
|
|
55
|
+
{
|
|
56
|
+
id: "pause",
|
|
57
|
+
label: "Pause",
|
|
58
|
+
description: "Stop continuing and waking. The loop keeps its state.",
|
|
59
|
+
action: "pause" as const,
|
|
60
|
+
},
|
|
61
|
+
] as const)
|
|
62
|
+
: ([
|
|
63
|
+
{
|
|
64
|
+
id: "resume",
|
|
65
|
+
label: "Resume",
|
|
66
|
+
description: "Continue the objective now, and re-arm the heartbeat.",
|
|
67
|
+
action: "resume" as const,
|
|
68
|
+
},
|
|
69
|
+
] as const)),
|
|
70
|
+
{
|
|
71
|
+
id: "focus",
|
|
72
|
+
label: "Edit focus…",
|
|
73
|
+
description: "A recurring note restated on every loop message.",
|
|
74
|
+
to: "focus",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "cadence",
|
|
78
|
+
label: "Edit cadence…",
|
|
79
|
+
description: `Fallback heartbeat, currently every ${view.interval}.`,
|
|
80
|
+
to: "cadence",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "stop",
|
|
84
|
+
label: "Stop",
|
|
85
|
+
description: "End the loop. The ledger stays on disk.",
|
|
86
|
+
action: "stop",
|
|
87
|
+
},
|
|
88
|
+
{ id: "settings", label: "Settings", action: "settings" },
|
|
89
|
+
],
|
|
90
|
+
hint: "close",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function loopStatusScreen(view: LoopManagerView): DetailScreen {
|
|
95
|
+
return {
|
|
96
|
+
kind: "detail",
|
|
97
|
+
title: "Loop status",
|
|
98
|
+
lines: [...view.statusLines],
|
|
99
|
+
hint: "back",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function loopFocusScreen(view: LoopManagerView): InputScreen<LoopManagerAction> {
|
|
104
|
+
return {
|
|
105
|
+
kind: "input",
|
|
106
|
+
title: "Loop focus",
|
|
107
|
+
lines: [
|
|
108
|
+
view.focus ? `Currently: ${view.focus}` : "No focus set.",
|
|
109
|
+
"Restated on every loop message. Leave empty to clear it.",
|
|
110
|
+
],
|
|
111
|
+
placeholder: view.focus ?? "e.g. keep the diff small and reversible",
|
|
112
|
+
action: "set-focus",
|
|
113
|
+
hint: "back",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function loopCadenceScreen(view: LoopManagerView): InputScreen<LoopManagerAction> {
|
|
118
|
+
return {
|
|
119
|
+
kind: "input",
|
|
120
|
+
title: "Fallback heartbeat",
|
|
121
|
+
lines: [
|
|
122
|
+
`Currently every ${view.interval}.`,
|
|
123
|
+
"The loop advances whenever the session settles; this only fires when it has gone quiet.",
|
|
124
|
+
],
|
|
125
|
+
placeholder: view.interval,
|
|
126
|
+
action: "set-cadence",
|
|
127
|
+
hint: "back",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface LoopManagerMenuOptions {
|
|
132
|
+
getView(): LoopManagerView | undefined;
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
isCurrent?(): boolean;
|
|
135
|
+
pause(): void;
|
|
136
|
+
resume(): void;
|
|
137
|
+
stop(): void;
|
|
138
|
+
settings(signal: AbortSignal): Promise<void>;
|
|
139
|
+
/** Returns false when the value was rejected, so the input screen stays open. */
|
|
140
|
+
setFocus(value: string): boolean;
|
|
141
|
+
setCadence(value: string): boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function showLoopManagerMenu(ctx: ExtensionContext, options: LoopManagerMenuOptions) {
|
|
145
|
+
const first = options.getView();
|
|
146
|
+
if (!first) return;
|
|
147
|
+
// The view is re-read on every render: a pause taken from this menu has to
|
|
148
|
+
// turn the Pause item into Resume without closing and reopening it.
|
|
149
|
+
const view = () => options.getView() ?? first;
|
|
150
|
+
const menu = defineMenu<
|
|
151
|
+
LoopManagerView,
|
|
152
|
+
LoopManagerScreen,
|
|
153
|
+
LoopManagerAction,
|
|
154
|
+
ExtensionContext
|
|
155
|
+
>({
|
|
156
|
+
start: "main",
|
|
157
|
+
screens: {
|
|
158
|
+
main: ({ state }) => loopManagerScreen(state),
|
|
159
|
+
status: ({ state }) => loopStatusScreen(state),
|
|
160
|
+
focus: ({ state }) => loopFocusScreen(state),
|
|
161
|
+
cadence: ({ state }) => loopCadenceScreen(state),
|
|
162
|
+
},
|
|
163
|
+
actions: {
|
|
164
|
+
pause: async () => {
|
|
165
|
+
options.pause();
|
|
166
|
+
return { kind: "stay" };
|
|
167
|
+
},
|
|
168
|
+
resume: async () => {
|
|
169
|
+
options.resume();
|
|
170
|
+
return { kind: "stay" };
|
|
171
|
+
},
|
|
172
|
+
stop: async () => {
|
|
173
|
+
options.stop();
|
|
174
|
+
return { kind: "close" };
|
|
175
|
+
},
|
|
176
|
+
settings: async ({ signal }) => {
|
|
177
|
+
await options.settings(signal);
|
|
178
|
+
return { kind: "stay" };
|
|
179
|
+
},
|
|
180
|
+
"set-focus": async ({ value }) =>
|
|
181
|
+
options.setFocus(value ?? "") ? { kind: "to", screen: "main" } : { kind: "rejected" },
|
|
182
|
+
"set-cadence": async ({ value }) =>
|
|
183
|
+
options.setCadence(value ?? "") ? { kind: "to", screen: "main" } : { kind: "rejected" },
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
return runMenu(ctx, menu, {
|
|
187
|
+
getState: view,
|
|
188
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
189
|
+
...(options.isCurrent ? { isCurrent: options.isCurrent } : {}),
|
|
190
|
+
});
|
|
191
|
+
}
|
package/src/loop.ts
CHANGED
|
@@ -89,6 +89,8 @@ import {
|
|
|
89
89
|
buildProposal,
|
|
90
90
|
type LoopPlanningState,
|
|
91
91
|
type LoopProposal,
|
|
92
|
+
type LoopProposalOverrides,
|
|
93
|
+
normalizeGroundRules,
|
|
92
94
|
} from "./planning.js";
|
|
93
95
|
import {
|
|
94
96
|
clearLoopWidget,
|
|
@@ -130,10 +132,9 @@ type RunOrigin = "continuation" | "fallback";
|
|
|
130
132
|
* The outcome of a start attempt.
|
|
131
133
|
*
|
|
132
134
|
* `startLoop` used to report its refusals by calling `ctx.ui.notify` itself,
|
|
133
|
-
* which tied the only start path to a UI.
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* returned and each caller renders it.
|
|
135
|
+
* which tied the only start path to a UI. The approval card's two start
|
|
136
|
+
* actions share that path — one installs the loop here, the other hands it to
|
|
137
|
+
* a fresh session — so the decision is returned and each caller renders it.
|
|
137
138
|
*/
|
|
138
139
|
export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
|
|
139
140
|
|
|
@@ -293,7 +294,7 @@ export class LoopController {
|
|
|
293
294
|
this.state = rest;
|
|
294
295
|
this.persist();
|
|
295
296
|
ctx.ui.notify(
|
|
296
|
-
"Loop started in this session: only the objective crossed over, not the planning conversation. It works from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you
|
|
297
|
+
"Loop started in this session: only the objective crossed over, not the planning conversation. It works from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you stop it from the /loop menu.",
|
|
297
298
|
"info",
|
|
298
299
|
);
|
|
299
300
|
this.sendKickoffAnchor(ctx);
|
|
@@ -371,14 +372,14 @@ export class LoopController {
|
|
|
371
372
|
case "usage-limited":
|
|
372
373
|
this.transition(
|
|
373
374
|
"paused",
|
|
374
|
-
"the provider reports the usage limit is reached; resume
|
|
375
|
+
"the provider reports the usage limit is reached; resume it from the /loop menu once it resets",
|
|
375
376
|
"usage limit reached",
|
|
376
377
|
);
|
|
377
378
|
return true;
|
|
378
379
|
case "fatal":
|
|
379
380
|
this.transition(
|
|
380
381
|
"paused",
|
|
381
|
-
"the turn failed with an error a retry cannot fix; resolve it, then /loop
|
|
382
|
+
"the turn failed with an error a retry cannot fix; resolve it, then resume it from the /loop menu",
|
|
382
383
|
"unrecoverable provider error",
|
|
383
384
|
);
|
|
384
385
|
return true;
|
|
@@ -386,7 +387,7 @@ export class LoopController {
|
|
|
386
387
|
// Esc, or another extension stopping the turn. A loop-caused run
|
|
387
388
|
// that the user interrupted must not be immediately re-sent.
|
|
388
389
|
if (origin === undefined) return false;
|
|
389
|
-
this.transition("paused", "the turn was interrupted; resume
|
|
390
|
+
this.transition("paused", "the turn was interrupted; resume it from the /loop menu", "interrupted");
|
|
390
391
|
return true;
|
|
391
392
|
case "context-overflow":
|
|
392
393
|
// The request no longer fits: compact first, then continue. The
|
|
@@ -408,7 +409,7 @@ export class LoopController {
|
|
|
408
409
|
* The no-progress breaker: consecutive tool-free loop turns with identical
|
|
409
410
|
* visible output pause the loop instead of waking it again forever. It
|
|
410
411
|
* pauses rather than stops, so the loop stays configured and one
|
|
411
|
-
*
|
|
412
|
+
* Resuming from the /loop menu (or the next user prompt) puts it back to work.
|
|
412
413
|
*/
|
|
413
414
|
private enforceNoProgress(
|
|
414
415
|
ctx: ExtensionContext,
|
|
@@ -451,7 +452,7 @@ export class LoopController {
|
|
|
451
452
|
void ctx;
|
|
452
453
|
this.transition(
|
|
453
454
|
"paused",
|
|
454
|
-
`${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so /loop
|
|
455
|
+
`${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so resuming from the /loop menu (or your next message) continues it`,
|
|
455
456
|
"no progress",
|
|
456
457
|
);
|
|
457
458
|
return true;
|
|
@@ -534,7 +535,7 @@ export class LoopController {
|
|
|
534
535
|
if (!objective) {
|
|
535
536
|
this.transition(
|
|
536
537
|
"paused",
|
|
537
|
-
"it was bound to a goal that is gone and has no objective of its own;
|
|
538
|
+
"it was bound to a goal that is gone and has no objective of its own; run /loop to plan and approve a new one",
|
|
538
539
|
"loop with no objective",
|
|
539
540
|
);
|
|
540
541
|
return true;
|
|
@@ -564,7 +565,7 @@ export class LoopController {
|
|
|
564
565
|
if (this.completeToolAvailable()) return false;
|
|
565
566
|
this.transition(
|
|
566
567
|
"paused",
|
|
567
|
-
`the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then /loop
|
|
568
|
+
`the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then resume the loop from the /loop menu`,
|
|
568
569
|
"loop_complete unavailable",
|
|
569
570
|
);
|
|
570
571
|
void ctx;
|
|
@@ -598,7 +599,7 @@ export class LoopController {
|
|
|
598
599
|
if (this.deadDeliveries < MAX_DEAD_DELIVERIES) return false;
|
|
599
600
|
this.transition(
|
|
600
601
|
"paused",
|
|
601
|
-
`${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then /loop
|
|
602
|
+
`${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then resume the loop from the /loop menu`,
|
|
602
603
|
"deliveries produce no turns",
|
|
603
604
|
);
|
|
604
605
|
return true;
|
|
@@ -679,7 +680,7 @@ export class LoopController {
|
|
|
679
680
|
* no writable ledger still runs, it just loses the durable record, so the
|
|
680
681
|
* failure is warned once and never repeated.
|
|
681
682
|
*
|
|
682
|
-
* `criteria` is passed at start: the criteria
|
|
683
|
+
* `criteria` is passed at start: the criteria approved with the draft, or
|
|
683
684
|
* the deterministic split of the objective. On restore it is omitted, and
|
|
684
685
|
* the criteria already on disk are authoritative — they are the ones the
|
|
685
686
|
* user saw echoed, and re-deriving them would both discard a proposed set
|
|
@@ -817,7 +818,7 @@ export class LoopController {
|
|
|
817
818
|
// Nothing is scheduled once the timer has fired. `runTick` re-arms it
|
|
818
819
|
// through `scheduleTick` when it pokes, but a busy or compacting
|
|
819
820
|
// session coalesces into `wakePending` instead — and leaving the old
|
|
820
|
-
// deadline here made
|
|
821
|
+
// deadline here made the /loop status screen report a clock time that had
|
|
821
822
|
// already passed.
|
|
822
823
|
this.nextWakeAt = undefined;
|
|
823
824
|
const ctx = this.sessionCtx;
|
|
@@ -1128,7 +1129,7 @@ export class LoopController {
|
|
|
1128
1129
|
|
|
1129
1130
|
statusLines(ctx: ExtensionContext): string[] {
|
|
1130
1131
|
const loop = this.state;
|
|
1131
|
-
if (!loop) return ["No loop in this session.
|
|
1132
|
+
if (!loop) return ["No loop in this session. Run /loop to plan one."];
|
|
1132
1133
|
const lines = [
|
|
1133
1134
|
`Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
|
|
1134
1135
|
...(loop.waiting
|
|
@@ -1208,10 +1209,7 @@ export class LoopController {
|
|
|
1208
1209
|
}
|
|
1209
1210
|
|
|
1210
1211
|
/** Record a drafted loop for approval, replacing any previous draft. */
|
|
1211
|
-
propose(
|
|
1212
|
-
objective: string,
|
|
1213
|
-
overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
|
|
1214
|
-
): LoopProposal {
|
|
1212
|
+
propose(objective: string, overrides: LoopProposalOverrides = {}): LoopProposal {
|
|
1215
1213
|
const proposal = buildProposal(
|
|
1216
1214
|
objective,
|
|
1217
1215
|
{
|
|
@@ -1287,7 +1285,7 @@ export class LoopController {
|
|
|
1287
1285
|
return {
|
|
1288
1286
|
ok: false,
|
|
1289
1287
|
message:
|
|
1290
|
-
"A loop needs something to work on.
|
|
1288
|
+
"A loop needs something to work on. Run /loop and draft an objective with completion criteria first.",
|
|
1291
1289
|
};
|
|
1292
1290
|
}
|
|
1293
1291
|
// A loop with no way to call loop_complete would work, finish, and then be
|
|
@@ -1307,10 +1305,12 @@ export class LoopController {
|
|
|
1307
1305
|
: this.settings.compaction.enabled
|
|
1308
1306
|
? this.settings.compaction.threshold
|
|
1309
1307
|
: null;
|
|
1308
|
+
const groundRules = normalizeGroundRules(start.groundRules);
|
|
1310
1309
|
const loop: LoopState = {
|
|
1311
1310
|
id: randomUUID().slice(0, 8),
|
|
1312
1311
|
status: "active",
|
|
1313
1312
|
objective,
|
|
1313
|
+
...(groundRules ? { groundRules } : {}),
|
|
1314
1314
|
intervalMs: start.intervalMs,
|
|
1315
1315
|
maxTurns: start.maxTurns !== undefined ? start.maxTurns : this.settings.maxTurns,
|
|
1316
1316
|
compactAt,
|
|
@@ -1356,7 +1356,7 @@ export class LoopController {
|
|
|
1356
1356
|
? ` (requested ${formatDuration(built.requestedMs)}, clamped to the ${formatDuration(started.intervalMs)} minimum)`
|
|
1357
1357
|
: "";
|
|
1358
1358
|
ctx.ui.notify(
|
|
1359
|
-
`Loop started: working its objective from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you
|
|
1359
|
+
`Loop started: working its objective from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you stop it from the /loop menu. Fallback wake every ${formatDuration(started.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(built.expiryMs)} (one final turn to write its state down, then it stops).`,
|
|
1360
1360
|
"info",
|
|
1361
1361
|
);
|
|
1362
1362
|
if (this.ledger) {
|
package/src/manager.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The `/loop` surfaces, wired to the controller: the manager for a live loop,
|
|
3
|
+
* the approval card's actions, and the settings editor.
|
|
4
|
+
*
|
|
5
|
+
* The top-level menus are tui-kit screens (see `loop-manager-menu.ts` and
|
|
6
|
+
* `loop-launch-menu.ts`), so the family renders the same way pi-plan-mode
|
|
7
|
+
* does. The settings editor keeps its `ui.select`/`ui.input` internals: it is
|
|
8
|
+
* a value editor, not a navigation surface, and rewriting it would change
|
|
9
|
+
* nothing a user can see. Non-TUI modes get status notifications instead of
|
|
10
|
+
* menus, exactly as before.
|
|
5
11
|
*/
|
|
6
12
|
|
|
7
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
14
|
import { formatDuration, parseInterval } from "./interval.js";
|
|
9
15
|
import type { LoopController } from "./loop.js";
|
|
10
16
|
import {
|
|
@@ -17,134 +23,141 @@ import { parseDuration } from "./interval.js";
|
|
|
17
23
|
import type { LoopStartArguments } from "./command.js";
|
|
18
24
|
import { startLoopInFreshSession } from "./fresh-launch.js";
|
|
19
25
|
import { showLoopApprovalMenu } from "./loop-action-menus.js";
|
|
26
|
+
import { type LoopManagerView, showLoopManagerMenu } from "./loop-manager-menu.js";
|
|
27
|
+
import { showLoopLaunchMenu, showLoopPlanningMenu } from "./loop-launch-menu.js";
|
|
20
28
|
import type { LoopProposal } from "./planning.js";
|
|
29
|
+
import { loopWidgetLine } from "./widget.js";
|
|
21
30
|
|
|
22
|
-
|
|
31
|
+
/** The launch menu: no loop, no draft, nothing planning. */
|
|
32
|
+
export async function showLoopLaunch(
|
|
23
33
|
controller: LoopController,
|
|
24
34
|
ctx: ExtensionCommandContext,
|
|
35
|
+
startPlanning: () => void,
|
|
25
36
|
): Promise<void> {
|
|
26
37
|
if (ctx.mode !== "tui") {
|
|
27
|
-
|
|
38
|
+
ctx.ui.notify(
|
|
39
|
+
"No loop in this session. The interactive /loop menu is unavailable in print and JSON modes.",
|
|
40
|
+
"info",
|
|
41
|
+
);
|
|
28
42
|
return;
|
|
29
43
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
options.push("Settings");
|
|
37
|
-
const choice = await ctx.ui.select(`Pi Loop${loop ? ` · ${loop.status}` : ""}`, options);
|
|
38
|
-
if (choice === undefined) return;
|
|
39
|
-
switch (choice) {
|
|
40
|
-
case "Status":
|
|
41
|
-
notifyStatus(controller, ctx);
|
|
42
|
-
break;
|
|
43
|
-
case "Pause":
|
|
44
|
-
controller.pauseLoop(ctx);
|
|
45
|
-
break;
|
|
46
|
-
case "Resume":
|
|
47
|
-
controller.resumeLoop(ctx);
|
|
48
|
-
break;
|
|
49
|
-
case "Stop":
|
|
50
|
-
controller.stopLoop(ctx);
|
|
51
|
-
break;
|
|
52
|
-
case "Start a loop…":
|
|
53
|
-
await startFromMenu(controller, ctx);
|
|
54
|
-
break;
|
|
55
|
-
case "Edit focus":
|
|
56
|
-
await editPrompt(controller, ctx);
|
|
57
|
-
break;
|
|
58
|
-
case "Edit interval":
|
|
59
|
-
await editInterval(controller, ctx);
|
|
60
|
-
break;
|
|
61
|
-
case "Settings":
|
|
62
|
-
await showLoopSettings(controller, ctx);
|
|
63
|
-
break;
|
|
64
|
-
default:
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function notifyStatus(controller: LoopController, ctx: ExtensionCommandContext): void {
|
|
71
|
-
ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
|
|
44
|
+
await showLoopLaunchMenu(ctx, {
|
|
45
|
+
startPlanning,
|
|
46
|
+
settings: async () => {
|
|
47
|
+
await showLoopSettings(controller, ctx);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
72
50
|
}
|
|
73
51
|
|
|
74
|
-
|
|
52
|
+
/** The planning menu: a drafting conversation is open with no draft yet. */
|
|
53
|
+
export async function showLoopPlanning(
|
|
75
54
|
controller: LoopController,
|
|
76
55
|
ctx: ExtensionCommandContext,
|
|
56
|
+
options: { requestProposal: () => void },
|
|
77
57
|
): Promise<void> {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
58
|
+
if (ctx.mode !== "tui") {
|
|
59
|
+
ctx.ui.notify(
|
|
60
|
+
"Loop planning is open: describe the objective, and the agent will put a loop up for approval.",
|
|
61
|
+
"info",
|
|
62
|
+
);
|
|
83
63
|
return;
|
|
84
64
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
65
|
+
await showLoopPlanningMenu(ctx, {
|
|
66
|
+
requestProposal: options.requestProposal,
|
|
67
|
+
cancelPlanning: () => {
|
|
68
|
+
controller.endPlanning();
|
|
69
|
+
ctx.ui.notify("Loop planning cancelled. Nothing was started.", "info");
|
|
70
|
+
},
|
|
71
|
+
settings: async () => {
|
|
72
|
+
await showLoopSettings(controller, ctx);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function showLoopManager(
|
|
78
|
+
controller: LoopController,
|
|
79
|
+
ctx: ExtensionCommandContext,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
if (ctx.mode !== "tui") {
|
|
82
|
+
notifyStatus(controller, ctx);
|
|
95
83
|
return;
|
|
96
84
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
85
|
+
await showLoopManagerMenu(ctx, {
|
|
86
|
+
getView: () => managerView(controller, ctx),
|
|
87
|
+
pause: () => controller.pauseLoop(ctx),
|
|
88
|
+
resume: () => controller.resumeLoop(ctx),
|
|
89
|
+
stop: () => controller.stopLoop(ctx),
|
|
90
|
+
settings: async () => {
|
|
91
|
+
await showLoopSettings(controller, ctx);
|
|
92
|
+
},
|
|
93
|
+
setFocus: (value) => setFocus(controller, ctx, value),
|
|
94
|
+
setCadence: (value) => setCadence(controller, ctx, value),
|
|
103
95
|
});
|
|
104
|
-
if (!result.ok) ctx.ui.notify(result.message, "error");
|
|
105
96
|
}
|
|
106
97
|
|
|
107
|
-
|
|
98
|
+
/** The live loop as the manager needs it, or undefined once it has stopped. */
|
|
99
|
+
function managerView(
|
|
100
|
+
controller: LoopController,
|
|
101
|
+
ctx: ExtensionContext,
|
|
102
|
+
): LoopManagerView | undefined {
|
|
108
103
|
const loop = controller.state;
|
|
109
|
-
if (!loop || loop.status === "stopped") return;
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
loop.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
104
|
+
if (!loop || loop.status === "stopped") return undefined;
|
|
105
|
+
const view = controller.widgetView();
|
|
106
|
+
return {
|
|
107
|
+
status: loop.status,
|
|
108
|
+
headline: view ? loopWidgetLine(view) : `loop ${loop.status}`,
|
|
109
|
+
statusLines: controller.statusLines(ctx),
|
|
110
|
+
...(loop.prompt ? { focus: loop.prompt } : {}),
|
|
111
|
+
interval: formatDuration(loop.intervalMs),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function notifyStatus(controller: LoopController, ctx: ExtensionCommandContext): void {
|
|
116
|
+
ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** An empty value clears the focus; anything else replaces it. */
|
|
120
|
+
function setFocus(
|
|
121
|
+
controller: LoopController,
|
|
122
|
+
ctx: ExtensionCommandContext,
|
|
123
|
+
value: string,
|
|
124
|
+
): boolean {
|
|
125
|
+
const loop = controller.state;
|
|
126
|
+
if (!loop || loop.status === "stopped") return false;
|
|
127
|
+
const prompt = value.trim();
|
|
116
128
|
if (prompt) controller.state = { ...loop, prompt };
|
|
117
129
|
else {
|
|
118
130
|
const { prompt: _dropped, ...rest } = loop;
|
|
119
131
|
controller.state = rest;
|
|
120
132
|
}
|
|
121
133
|
controller.persist();
|
|
122
|
-
|
|
134
|
+
controller.updateWidget();
|
|
135
|
+
ctx.ui.notify(prompt ? "Loop focus updated." : "Loop focus cleared.", "info");
|
|
136
|
+
return true;
|
|
123
137
|
}
|
|
124
138
|
|
|
125
|
-
|
|
139
|
+
function setCadence(
|
|
126
140
|
controller: LoopController,
|
|
127
141
|
ctx: ExtensionCommandContext,
|
|
128
|
-
|
|
142
|
+
value: string,
|
|
143
|
+
): boolean {
|
|
129
144
|
const loop = controller.state;
|
|
130
|
-
if (!loop || loop.status === "stopped") return;
|
|
131
|
-
const
|
|
132
|
-
if (next === undefined) return;
|
|
133
|
-
const interval = parseInterval(next.trim());
|
|
145
|
+
if (!loop || loop.status === "stopped") return false;
|
|
146
|
+
const interval = parseInterval(value.trim());
|
|
134
147
|
if (!interval) {
|
|
135
|
-
ctx.ui.notify(`Invalid interval: ${
|
|
136
|
-
return;
|
|
148
|
+
ctx.ui.notify(`Invalid interval: ${value}. Use <number><unit>, e.g. 5m.`, "error");
|
|
149
|
+
return false;
|
|
137
150
|
}
|
|
138
151
|
controller.state = { ...loop, intervalMs: interval.effectiveMs };
|
|
139
152
|
controller.persist();
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
153
|
+
// Re-arm on the new cadence from now.
|
|
154
|
+
if (loop.status === "active") controller.resumeAfterEdit();
|
|
155
|
+
else controller.updateWidget();
|
|
144
156
|
ctx.ui.notify(
|
|
145
157
|
`Loop interval set to ${formatDuration(interval.effectiveMs)}${interval.clamped ? " (clamped to the minimum)" : ""}.`,
|
|
146
158
|
"info",
|
|
147
159
|
);
|
|
160
|
+
return true;
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
export async function showLoopSettings(
|
|
@@ -169,7 +182,8 @@ export async function showLoopSettings(
|
|
|
169
182
|
const next = structuredClone(s);
|
|
170
183
|
if (index === 0) {
|
|
171
184
|
// Unlimited is a first-class choice, not a magic word typed into a free
|
|
172
|
-
// text box: it is only reachable by discovery otherwise.
|
|
185
|
+
// text box: it is only reachable by discovery otherwise. It is also the
|
|
186
|
+
// default, so this editor is where a user opts *into* a budget.
|
|
173
187
|
const cap = await editCap(ctx, "Max loop turns", "no turn cap", s.maxTurns);
|
|
174
188
|
if (cap === undefined) continue;
|
|
175
189
|
next.maxTurns = cap === "unlimited" ? null : cap;
|
|
@@ -217,8 +231,7 @@ export async function showLoopSettings(
|
|
|
217
231
|
/**
|
|
218
232
|
* One cap editor for every cap. Unlimited is a first-class choice, not a
|
|
219
233
|
* magic word typed into a free text box: it is only reachable by discovery
|
|
220
|
-
* otherwise. The typed word still works
|
|
221
|
-
* muscle memory keep working.
|
|
234
|
+
* otherwise. The typed word still works too.
|
|
222
235
|
*/
|
|
223
236
|
async function editCap(
|
|
224
237
|
ctx: ExtensionCommandContext,
|
|
@@ -275,11 +288,11 @@ function applySettings(
|
|
|
275
288
|
* The approval card and its actions.
|
|
276
289
|
*
|
|
277
290
|
* This is where a planned loop starts, and the approval is what authorises it.
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* than routing back through a tool
|
|
291
|
+
* A loop is self-continuing and must never begin on model initiative; an
|
|
292
|
+
* explicit choice here, on a card showing the objective, the derived criteria,
|
|
293
|
+
* the ground rules, the cadence and the caps, is the strongest evidence of
|
|
294
|
+
* intent there is — stronger than any token the model could also emit — so it
|
|
295
|
+
* starts the loop directly rather than routing back through a tool.
|
|
283
296
|
*/
|
|
284
297
|
export async function showLoopApproval(
|
|
285
298
|
controller: LoopController,
|
|
@@ -292,10 +305,7 @@ export async function showLoopApproval(
|
|
|
292
305
|
// this only renders one when the user reached the approval some other way.
|
|
293
306
|
controller.showProposalCard(ctx);
|
|
294
307
|
if (ctx.mode !== "tui") {
|
|
295
|
-
ctx.ui.notify(
|
|
296
|
-
"Approve it from a TUI session, or start it directly with /loop <interval> <objective>.",
|
|
297
|
-
"info",
|
|
298
|
-
);
|
|
308
|
+
ctx.ui.notify("Approve it from a TUI session; a loop cannot be started headless.", "info");
|
|
299
309
|
return;
|
|
300
310
|
}
|
|
301
311
|
await showLoopApprovalMenu(ctx, {
|
|
@@ -331,6 +341,9 @@ function startArgumentsFor(proposal: LoopProposal): LoopStartArguments {
|
|
|
331
341
|
maxTurns: proposal.maxTurns,
|
|
332
342
|
expiresInMs: proposal.expiresInMs,
|
|
333
343
|
prompt: proposal.objective,
|
|
344
|
+
// The approved constraints cross with the objective; they are part of what
|
|
345
|
+
// the user said yes to.
|
|
346
|
+
...(proposal.groundRules ? { groundRules: proposal.groundRules } : {}),
|
|
334
347
|
};
|
|
335
348
|
}
|
|
336
349
|
|
|
@@ -389,6 +402,7 @@ async function changeCadence(
|
|
|
389
402
|
intervalMs: interval.effectiveMs,
|
|
390
403
|
maxTurns: proposal.maxTurns,
|
|
391
404
|
expiresInMs: proposal.expiresInMs,
|
|
405
|
+
...(proposal.groundRules ? { groundRules: proposal.groundRules } : {}),
|
|
392
406
|
});
|
|
393
407
|
await showLoopApproval(controller, ctx);
|
|
394
408
|
}
|