omp-auto-loop 0.1.1

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.
Files changed (5) hide show
  1. package/README.md +50 -0
  2. package/index.ts +116 -0
  3. package/package.json +17 -0
  4. package/state.ts +50 -0
  5. package/tool.ts +68 -0
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # 🔄 Agent Loop
2
+
3
+ General-purpose loop extension for pi. Repeats agent turns automatically in three modes: until a goal is met, for a fixed number of passes, or through a sequence of named pipeline stages.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:pi-agent-loop
9
+ ```
10
+
11
+ ## What it does
12
+
13
+ Registers a `/loop` command and a `loop_control` tool. On each iteration, the agent does its work then calls `loop_control` to either advance to the next iteration (`next`) or declare the goal complete (`done`). The loop context is injected into the system prompt automatically so the agent always knows where it is.
14
+
15
+ ## Commands
16
+
17
+ | Command | Description |
18
+ |---|---|
19
+ | `/loop goal <description>` | Repeat until the LLM declares the goal met (open-ended) |
20
+ | `/loop passes <N> <task>` | Run exactly N passes |
21
+ | `/loop pipeline <s1\|s2\|s3> <goal>` | Run named stages sequentially, stop after the last |
22
+ | `/loop-stop` | Stop the active loop immediately |
23
+
24
+ ## Shortcut
25
+
26
+ | Shortcut | Description |
27
+ |---|---|
28
+ | `Ctrl+Shift+X` | Emergency abort — stops the loop and cancels the current turn |
29
+
30
+ ## Tool: `loop_control`
31
+
32
+ The LLM calls this tool to signal progress. It is only active during a loop.
33
+
34
+ | Parameter | Type | Description |
35
+ |---|---|---|
36
+ | `status` | `"next" \| "done"` | Advance to next iteration or declare completion |
37
+ | `summary` | `string` | Brief summary of what was accomplished this iteration |
38
+ | `reason` | `string?` | Why the goal is met (used with `"done"`) |
39
+
40
+ ## Examples
41
+
42
+ ```
43
+ /loop goal Refactor all test files to use the new assertion API
44
+ /loop passes 3 Review and improve the README
45
+ /loop pipeline analyze|implement|test Write and test the new auth module
46
+ ```
47
+
48
+ ## TUI widget
49
+
50
+ While a loop is active, a status bar entry and widget show the current mode, goal, and iteration count.
package/index.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
2
+ import { Key } from "@oh-my-pi/pi-tui";
3
+ import { buildPrompt, emptyState, getSystemPromptAddition, type LoopState, updateWidget } from "./state.js";
4
+ import { getLoopControlToolDefinition, handleLoopControlTool, renderLoopControlCall, renderLoopControlResult } from "./tool.js";
5
+
6
+ export default function (pi: ExtensionAPI) {
7
+ let state = emptyState();
8
+
9
+ const reconstruct = (ctx: ExtensionContext) => {
10
+ state = emptyState();
11
+ for (const entry of ctx.sessionManager.getBranch()) {
12
+ if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "loop_control") {
13
+ const d = entry.message.details as LoopState | undefined;
14
+ if (d) state = { ...d };
15
+ }
16
+ }
17
+ };
18
+
19
+ pi.on("session_start", async (_e, ctx) => reconstruct(ctx));
20
+ pi.on("session_switch", async (_e, ctx) => reconstruct(ctx));
21
+ pi.on("session_fork", async (_e, ctx) => reconstruct(ctx));
22
+ pi.on("session_tree", async (_e, ctx) => reconstruct(ctx));
23
+
24
+ pi.on("input", async (event, ctx) => {
25
+ const text = event.text.trim();
26
+ // Handle commands natively
27
+ if (text.startsWith("/")) {
28
+ if (text.startsWith("/once ") || text === "/once") {
29
+ return { text: text.slice(5).trim() };
30
+ }
31
+ return {};
32
+ }
33
+
34
+ // Normal message: start loop
35
+ state = { active: true, currentStep: 0, goal: text, done: false, reasonDone: "" };
36
+ updateWidget(state, ctx);
37
+
38
+ // Delay the prompt steer delivery to avoid conflicting with the pending user message
39
+ setTimeout(() => {
40
+ pi.sendMessage({ customType: "loop-iteration", content: buildPrompt(state), display: false }, { triggerTurn: false, deliverAs: "steer" });
41
+ }, 50);
42
+
43
+ return {}; // let original text through
44
+ });
45
+
46
+ pi.on("before_agent_start", async (event) => {
47
+ if (!state.active) return;
48
+ return { systemPrompt: event.systemPrompt + getSystemPromptAddition(state) };
49
+ });
50
+
51
+ pi.on("turn_end", async (_e, ctx) => {
52
+ if (!state.active) return;
53
+
54
+ if (state.confirmingDone) {
55
+ state.active = false;
56
+ state.done = true;
57
+ state.reasonDone = "Confirmed complete by skipping loop_control";
58
+ state.confirmingDone = false;
59
+ updateWidget(state, ctx);
60
+ return;
61
+ }
62
+
63
+ if (state.nextScheduled) {
64
+ state.nextScheduled = false;
65
+ return;
66
+ }
67
+
68
+ // Fallback: LLM stopped without calling loop_control or confirming done
69
+ state.currentStep++;
70
+ updateWidget(state, ctx);
71
+ setTimeout(() => {
72
+ pi.sendMessage({
73
+ customType: "loop-fallback",
74
+ content: "You stopped without calling `loop_control`. If the task is incomplete, continue working. If done, call `loop_control` with status 'done'.",
75
+ display: false
76
+ }, { triggerTurn: true, deliverAs: "steer" });
77
+ }, 100);
78
+ });
79
+
80
+ pi.registerTool({
81
+ ...getLoopControlToolDefinition(),
82
+ async execute(_id, params, _signal, _onUpdate, ctx) {
83
+ const result = handleLoopControlTool({ params, state, pi, ctx });
84
+ state = result.newState;
85
+ updateWidget(state, ctx);
86
+ return { content: result.content, details: result.details };
87
+ },
88
+ renderCall: renderLoopControlCall as any,
89
+ renderResult: renderLoopControlResult as any,
90
+ });
91
+
92
+ const stopLoop = (ctx: ExtensionContext, reason: string) => {
93
+ if (!state.active) {
94
+ ctx.ui.notify("No active loop", "info");
95
+ return;
96
+ }
97
+ state.active = false;
98
+ state.done = true;
99
+ state.reasonDone = reason;
100
+ updateWidget(state, ctx);
101
+ ctx.ui.notify(`Loop stopped after ${state.currentStep + 1} iteration(s)`, "warning");
102
+ };
103
+
104
+ pi.registerCommand("loop-stop", {
105
+ description: "Stop the active loop",
106
+ handler: async (_args, ctx) => stopLoop(ctx, "Stopped by user"),
107
+ });
108
+
109
+ pi.registerShortcut(Key.ctrlShift("x"), {
110
+ description: "Stop the active loop",
111
+ handler: async (ctx) => {
112
+ stopLoop(ctx, "Stopped by shortcut");
113
+ ctx.abort();
114
+ },
115
+ });
116
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "omp-auto-loop",
3
+ "version": "0.1.1",
4
+ "description": "General-purpose agent loop extension for pi. Supports goal loops (repeat until done), fixed-pass loops, and multi-stage pipelines via /loop goal, /loop passes, and /loop pipeline commands.",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "license": "MIT",
9
+ "pi": {
10
+ "extensions": [
11
+ "."
12
+ ]
13
+ },
14
+ "peerDependencies": {
15
+ "@oh-my-pi/pi-coding-agent": "*"
16
+ }
17
+ }
package/state.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
2
+
3
+ export interface LoopState {
4
+ active: boolean;
5
+ currentStep: number;
6
+ goal: string;
7
+ done: boolean;
8
+ reasonDone: string;
9
+ confirmingDone?: boolean;
10
+ nextScheduled?: boolean;
11
+ }
12
+
13
+ export function emptyState(): LoopState {
14
+ return { active: false, currentStep: 0, goal: "", done: false, reasonDone: "", confirmingDone: false, nextScheduled: false };
15
+ }
16
+
17
+ export function buildPrompt(state: LoopState): string {
18
+ return [
19
+ `## Loop — Iteration ${state.currentStep + 1}`,
20
+ `Goal: ${state.goal}`,
21
+ `Work toward the goal. When the goal is fully met, call loop_control with status "done" and explain why.`,
22
+ `If more work is needed, call loop_control with status "next" describing what's left.`,
23
+ ].join("\n");
24
+ }
25
+
26
+ export function updateWidget(state: LoopState, ctx: ExtensionContext) {
27
+ if (!state.active) {
28
+ ctx.ui.setStatus("loop", undefined);
29
+ ctx.ui.setWidget("loop", undefined);
30
+ return;
31
+ }
32
+ const label = `iteration ${state.currentStep + 1}`;
33
+ ctx.ui.setStatus("loop", `🔄 ${label}`);
34
+ ctx.ui.setWidget("loop", [
35
+ `┌─ Loop ──────────`,
36
+ `│ ${label}`,
37
+ `└─ Ctrl+Shift+X to stop ─`,
38
+ ]);
39
+ }
40
+
41
+ export function getSystemPromptAddition(state: LoopState): string {
42
+ return [
43
+ "",
44
+ "## Active Loop",
45
+ `Step: ${state.currentStep + 1}`,
46
+ `Goal: ${state.goal}`,
47
+ "You MUST call `loop_control` when you finish your work for this iteration.",
48
+ 'Use status "next" to advance or "done" when the goal is fully met.',
49
+ ].join("\n");
50
+ }
package/tool.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { StringEnum } from "@oh-my-pi/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
3
+ import { Text } from "@oh-my-pi/pi-tui";
4
+ import { Type } from "@sinclair/typebox";
5
+ import { buildPrompt, type LoopState } from "./state.js";
6
+
7
+ export function handleLoopControlTool(opts: {
8
+ params: { status: "next" | "done"; summary: string; reason?: string };
9
+ state: LoopState;
10
+ pi: ExtensionAPI;
11
+ ctx: ExtensionContext;
12
+ }): { content: any[]; details?: LoopState; newState: LoopState } {
13
+ const { params, state, pi } = opts;
14
+ if (!state.active) {
15
+ return { content: [{ type: "text", text: "No active loop." }], newState: state };
16
+ }
17
+
18
+ if (params.status === "done") {
19
+ if (!state.confirmingDone) {
20
+ const newState = { ...state, confirmingDone: true };
21
+ return {
22
+ content: [{ type: "text", text: "Please confirm that the work is completely done. If there are still pending tasks, call loop_control with status 'next'. If you are truly done, just finish your response without calling loop_control again." }],
23
+ details: { ...newState },
24
+ newState,
25
+ };
26
+ }
27
+ const newState = { ...state, done: true, reasonDone: params.reason ?? params.summary, active: false, confirmingDone: false };
28
+ return {
29
+ content: [{ type: "text", text: `✓ Loop complete after ${state.currentStep + 1} iteration(s). Reason: ${newState.reasonDone}` }],
30
+ details: { ...newState },
31
+ newState,
32
+ };
33
+ }
34
+
35
+ const newState = { ...state, currentStep: state.currentStep + 1, confirmingDone: false, nextScheduled: true };
36
+ setTimeout(() => {
37
+ pi.sendMessage({ customType: "loop-iteration", content: buildPrompt(newState), display: false }, { triggerTurn: true, deliverAs: "steer" });
38
+ }, 100);
39
+
40
+ return {
41
+ content: [{ type: "text", text: `→ Advancing to step ${newState.currentStep + 1}. Summary: ${params.summary}` }],
42
+ details: { ...newState },
43
+ newState,
44
+ };
45
+ }
46
+
47
+ export function getLoopControlToolDefinition() {
48
+ return {
49
+ name: "loop_control",
50
+ label: "Loop Control",
51
+ description: "Signal loop progress. Call this when you finish a loop iteration. status 'next' to advance, 'done' to finish.",
52
+ parameters: Type.Object({
53
+ status: StringEnum(["next", "done"] as const),
54
+ summary: Type.String({ description: "Brief summary of what was accomplished this iteration" }),
55
+ reason: Type.Optional(Type.String({ description: "Why the goal is met (for 'done')" })),
56
+ }),
57
+ };
58
+ }
59
+
60
+ export function renderLoopControlCall(args: { status: string }, theme: any) {
61
+ return new Text(theme.fg("toolTitle", theme.bold("loop_control ")) + theme.fg(args.status === "done" ? "success" : "accent", args.status), 0, 0);
62
+ }
63
+
64
+ export function renderLoopControlResult(result: { details?: LoopState }, _opts: unknown, theme: any) {
65
+ const d = result.details;
66
+ if (!d) return new Text("", 0, 0);
67
+ return new Text(theme.fg(d.done ? "success" : "accent", `${d.done ? "✓" : "→"} step ${d.currentStep + 1}`), 0, 0);
68
+ }