@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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The approval card's actions, as a menu.
3
+ *
4
+ * Presentation and choices are separate surfaces on purpose, the way
5
+ * pi-plan-mode splits `presentation.ts` from `plan-action-menus.ts`: the card
6
+ * is a durable artifact in the transcript that the user can scroll back to,
7
+ * and the menu is a transient dialog over it. A plain `ui.select` of label
8
+ * strings could not say what "start in a fresh session" means, and that is
9
+ * exactly the entry that needs explaining.
10
+ *
11
+ * The screen is built by a pure function so a test can assert what the menu
12
+ * offers without a terminal.
13
+ */
14
+
15
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
17
+ import type { ActionsScreen } from "@narumitw/pi-tui-kit";
18
+ import { formatDuration } from "./interval.js";
19
+ import type { LoopProposal } from "./planning.js";
20
+
21
+ export type LoopApprovalAction =
22
+ | "start-here"
23
+ | "start-fresh"
24
+ | "change-cadence"
25
+ | "keep-editing"
26
+ | "cancel";
27
+
28
+ type Screen = "approval";
29
+
30
+ export interface LoopApprovalMenuOptions {
31
+ proposal: LoopProposal;
32
+ signal?: AbortSignal;
33
+ isCurrent?(): boolean;
34
+ startHere(): void | Promise<void>;
35
+ startFresh(signal: AbortSignal): void | Promise<void>;
36
+ changeCadence(): void | Promise<void>;
37
+ keepEditing(): void;
38
+ cancel(): void;
39
+ }
40
+
41
+ /**
42
+ * The approval screen. Pure and exported for tests: the set of actions on
43
+ * offer is the contract, and it is cheaper to pin here than through a TUI.
44
+ */
45
+ export function loopApprovalScreen(
46
+ proposal: LoopProposal,
47
+ ): ActionsScreen<Screen, LoopApprovalAction> {
48
+ const criteria = `${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}`;
49
+ const rules = proposal.groundRules?.length;
50
+ return {
51
+ kind: "actions",
52
+ title: "Start this loop?",
53
+ lines: [
54
+ `${criteria}${rules ? ` · ${rules} ground rule${rules === 1 ? "" : "s"}` : ""} · fallback wake every ${formatDuration(proposal.intervalMs)} · turn cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · expires in ${formatDuration(proposal.expiresInMs)}`,
55
+ "The card above shows exactly what loop_complete will be held to.",
56
+ ],
57
+ items: [
58
+ {
59
+ id: "start-here",
60
+ label: "Start loop here",
61
+ description: "Run it in this session, keeping the planning conversation.",
62
+ action: "start-here",
63
+ },
64
+ {
65
+ id: "start-fresh",
66
+ label: "Start loop in a fresh session",
67
+ description:
68
+ "Open a new session that runs the loop with only the objective — no planning history.",
69
+ action: "start-fresh",
70
+ busyLabel: "Starting the loop in a fresh session…",
71
+ },
72
+ {
73
+ id: "change-cadence",
74
+ label: "Change cadence…",
75
+ description: "Edit the fallback heartbeat before starting.",
76
+ action: "change-cadence",
77
+ },
78
+ {
79
+ id: "keep-editing",
80
+ label: "Keep editing",
81
+ description: "Go back to drafting; tell the agent what to change.",
82
+ action: "keep-editing",
83
+ },
84
+ {
85
+ id: "cancel",
86
+ label: "Cancel",
87
+ description: "Discard the draft. Nothing is started.",
88
+ action: "cancel",
89
+ },
90
+ ],
91
+ hint: "close",
92
+ };
93
+ }
94
+
95
+ export async function showLoopApprovalMenu(
96
+ ctx: ExtensionContext,
97
+ options: LoopApprovalMenuOptions,
98
+ ) {
99
+ const menu = defineMenu<undefined, Screen, LoopApprovalAction, ExtensionContext>({
100
+ start: "approval",
101
+ screens: { approval: () => loopApprovalScreen(options.proposal) },
102
+ actions: {
103
+ "start-here": async () => {
104
+ await options.startHere();
105
+ return { kind: "close" };
106
+ },
107
+ "start-fresh": async ({ signal }) => {
108
+ await options.startFresh(signal);
109
+ return { kind: "close" };
110
+ },
111
+ "change-cadence": async () => {
112
+ await options.changeCadence();
113
+ return { kind: "close" };
114
+ },
115
+ "keep-editing": async () => {
116
+ options.keepEditing();
117
+ return { kind: "close" };
118
+ },
119
+ cancel: async () => {
120
+ options.cancel();
121
+ return { kind: "close" };
122
+ },
123
+ },
124
+ });
125
+ return runMenu(ctx, menu, {
126
+ getState: () => undefined,
127
+ ...(options.signal ? { signal: options.signal } : {}),
128
+ ...(options.isCurrent ? { isCurrent: options.isCurrent } : {}),
129
+ });
130
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The loop-active environment contract.
3
+ *
4
+ * pi-loop publishes two variables into its own process environment while a
5
+ * loop is active, and removes them the moment it is not:
6
+ *
7
+ * - `PI_LOOP_ACTIVE=1` — an unattended loop is running in this session.
8
+ * - `PI_LOOP_ID=<id>` — the loop's id, so a reader can tell one loop from the
9
+ * next without asking pi-loop anything.
10
+ *
11
+ * It exists for other extensions, and pi-auto-permissions is the first
12
+ * consumer: a modal permission prompt does not pause a loop, it deadlocks it,
13
+ * so a guardian that would have asked a human needs to know there is no human
14
+ * to ask. The mechanism is deliberately the one `pi-subagents` already
15
+ * established with `PI_SUBAGENT_CHILD=1` and `detectSubagentContext` reads —
16
+ * an environment variable, not a package dependency, not an import, not an
17
+ * RPC. Neither extension needs the other installed, in either direction, and
18
+ * a reader that never sees the variable behaves exactly as it does today.
19
+ *
20
+ * Both variables are set on the process, so they are visible to every
21
+ * extension in the session and inherited by anything it spawns. That is the
22
+ * point: a subagent launched by a looping session is running unattended for
23
+ * the same reason its parent is.
24
+ */
25
+
26
+ import type { LoopState } from "./state.js";
27
+
28
+ export const LOOP_ACTIVE_ENV = "PI_LOOP_ACTIVE";
29
+ export const LOOP_ID_ENV = "PI_LOOP_ID";
30
+
31
+ /**
32
+ * Publish (or withdraw) the loop-active signal for `loop`.
33
+ *
34
+ * Only an `active` loop publishes. A paused loop is not working unattended —
35
+ * the user paused it and is, by construction, present — and a stopped loop is
36
+ * not working at all, so both withdraw the signal rather than leaving a stale
37
+ * one behind for the rest of the session.
38
+ */
39
+ export function publishLoopEnv(
40
+ loop: LoopState | undefined,
41
+ env: Record<string, string | undefined> = process.env,
42
+ ): void {
43
+ if (loop?.status === "active") {
44
+ env[LOOP_ACTIVE_ENV] = "1";
45
+ env[LOOP_ID_ENV] = loop.id;
46
+ return;
47
+ }
48
+ delete env[LOOP_ACTIVE_ENV];
49
+ delete env[LOOP_ID_ENV];
50
+ }
@@ -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
+ }