@hank-warren/pi-loop 0.9.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The two pre-loop menus: the launch menu (nothing running, nothing drafted)
3
+ * and the planning menu (a drafting conversation is open, no proposal yet).
4
+ *
5
+ * They are the front door, and they are deliberately shaped like
6
+ * pi-plan-mode's launch menu — same title/status/items/detail-screen skeleton,
7
+ * same "How it works" affordance. The two extensions are one family: a user
8
+ * who has run `/plan` should recognise `/loop` without reading anything.
9
+ *
10
+ * Screen builders are pure and exported so a test can pin exactly what each
11
+ * menu offers without a terminal. That set is the contract; the wiring is not.
12
+ */
13
+
14
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import type { ActionsScreen, DetailScreen } from "@narumitw/pi-tui-kit";
16
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
17
+
18
+ export type LoopLaunchScreen = "main" | "how";
19
+ export type LoopLaunchAction = "start-planning" | "settings";
20
+ export type LoopPlanningAction = "request-proposal" | "cancel" | "settings";
21
+
22
+ /**
23
+ * What a loop actually is, for someone who has never run one.
24
+ *
25
+ * It answers the questions the old one-line notification could not: what
26
+ * paces it, what ends it, where its state lives, and what bounds it now that
27
+ * a turn budget no longer does.
28
+ */
29
+ export const HOW_LOOPS_WORK_LINES = [
30
+ "A loop works one objective across many turns, continuing itself until the objective is met.",
31
+ "It is paced by the session settling, not by a clock: every time the agent goes idle with the objective unfinished, the loop continues it. The interval is only a fallback heartbeat for a session that has gone quiet.",
32
+ "The objective is drafted with you first and becomes the loop's completion criteria. The approval card shows the exact criteria before anything starts.",
33
+ "Ground rules are hard constraints approved alongside the objective — what the loop must never do while nobody is watching.",
34
+ "loop_complete ends the loop, and it is gated: every criterion needs cited evidence. Effort exhaustion is not completion.",
35
+ "A durable ledger (PROGRESS.md and criteria.json) holds the loop's state, so it survives compaction and hands off between sessions.",
36
+ "Nothing caps the turns by default. A loop is bounded by its expiry and by the no-progress breaker, which pauses it when it repeats itself; set a turn budget in Settings to add one.",
37
+ "You stay in control: /loop opens this menu at any time to pause, resume, or stop it, and Esc interrupts the turn in flight.",
38
+ ] as const;
39
+
40
+ function howItWorksScreen(): DetailScreen {
41
+ return {
42
+ kind: "detail",
43
+ title: "How loops work",
44
+ lines: [...HOW_LOOPS_WORK_LINES],
45
+ hint: "back",
46
+ };
47
+ }
48
+
49
+ /** The off-state launch menu. */
50
+ export function loopLaunchScreen(): ActionsScreen<LoopLaunchScreen, LoopLaunchAction> {
51
+ return {
52
+ kind: "actions",
53
+ title: "Loop",
54
+ lines: ["Status: Off."],
55
+ items: [
56
+ {
57
+ id: "start-planning",
58
+ label: "Start loop planning",
59
+ description: "Draft an objective with the agent. Nothing starts until you approve it.",
60
+ action: "start-planning",
61
+ },
62
+ { id: "settings", label: "Settings", action: "settings" },
63
+ { id: "how", label: "How loops work", to: "how" },
64
+ ],
65
+ hint: "close",
66
+ };
67
+ }
68
+
69
+ /** Planning is open and no draft has been proposed yet. */
70
+ export function loopPlanningScreen(): ActionsScreen<LoopLaunchScreen, LoopPlanningAction> {
71
+ return {
72
+ kind: "actions",
73
+ title: "Loop planning",
74
+ lines: [
75
+ "Status: drafting an objective. No loop is running.",
76
+ "Describe what the loop should achieve and how you will know it is done.",
77
+ ],
78
+ items: [
79
+ {
80
+ id: "request-proposal",
81
+ label: "Request proposal now",
82
+ description: "Ask the agent to put the current draft up for approval.",
83
+ action: "request-proposal",
84
+ },
85
+ {
86
+ id: "cancel",
87
+ label: "Cancel planning",
88
+ description: "Close planning. Nothing is started.",
89
+ action: "cancel",
90
+ },
91
+ { id: "settings", label: "Settings", action: "settings" },
92
+ { id: "how", label: "How loops work", to: "how" },
93
+ ],
94
+ hint: "close",
95
+ };
96
+ }
97
+
98
+ export interface LoopLaunchMenuOptions {
99
+ signal?: AbortSignal;
100
+ isCurrent?(): boolean;
101
+ startPlanning(): void;
102
+ settings(signal: AbortSignal): Promise<void>;
103
+ }
104
+
105
+ export async function showLoopLaunchMenu(ctx: ExtensionContext, options: LoopLaunchMenuOptions) {
106
+ const menu = defineMenu<undefined, LoopLaunchScreen, LoopLaunchAction, ExtensionContext>({
107
+ start: "main",
108
+ screens: { main: () => loopLaunchScreen(), how: () => howItWorksScreen() },
109
+ actions: {
110
+ "start-planning": async () => {
111
+ options.startPlanning();
112
+ return { kind: "close" };
113
+ },
114
+ settings: async ({ signal }) => {
115
+ await options.settings(signal);
116
+ return { kind: "stay" };
117
+ },
118
+ },
119
+ });
120
+ return runMenu(ctx, menu, { getState: () => undefined, ...lifecycle(options) });
121
+ }
122
+
123
+ export interface LoopPlanningMenuOptions {
124
+ signal?: AbortSignal;
125
+ isCurrent?(): boolean;
126
+ requestProposal(): void;
127
+ cancelPlanning(): void;
128
+ settings(signal: AbortSignal): Promise<void>;
129
+ }
130
+
131
+ export async function showLoopPlanningMenu(ctx: ExtensionContext, options: LoopPlanningMenuOptions) {
132
+ const menu = defineMenu<undefined, LoopLaunchScreen, LoopPlanningAction, ExtensionContext>({
133
+ start: "main",
134
+ screens: { main: () => loopPlanningScreen(), how: () => howItWorksScreen() },
135
+ actions: {
136
+ "request-proposal": async () => {
137
+ options.requestProposal();
138
+ return { kind: "close" };
139
+ },
140
+ cancel: async () => {
141
+ options.cancelPlanning();
142
+ return { kind: "close" };
143
+ },
144
+ settings: async ({ signal }) => {
145
+ await options.settings(signal);
146
+ return { kind: "stay" };
147
+ },
148
+ },
149
+ });
150
+ return runMenu(ctx, menu, { getState: () => undefined, ...lifecycle(options) });
151
+ }
152
+
153
+ function lifecycle(options: { signal?: AbortSignal; isCurrent?(): boolean }) {
154
+ return {
155
+ ...(options.signal ? { signal: options.signal } : {}),
156
+ ...(options.isCurrent ? { isCurrent: options.isCurrent } : {}),
157
+ };
158
+ }
@@ -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
+ }