@lnilluv/pi-ralph-loop 0.1.1 → 0.2.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.
@@ -24,10 +24,10 @@ jobs:
24
24
  with:
25
25
  fetch-depth: 0
26
26
 
27
- - name: Setup Node.js 22
27
+ - name: Setup Node.js 24
28
28
  uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
29
29
  with:
30
- node-version: 22
30
+ node-version: 24
31
31
  registry-url: https://registry.npmjs.org
32
32
 
33
33
  - name: Install dependencies
package/README.md CHANGED
@@ -41,6 +41,8 @@ commands:
41
41
  run: npm run lint
42
42
  timeout: 60
43
43
  max_iterations: 25
44
+ timeout: 300
45
+ completion_promise: "DONE"
44
46
  guardrails:
45
47
  block_commands:
46
48
  - "rm\\s+-rf\\s+/"
@@ -51,12 +53,15 @@ guardrails:
51
53
  ---
52
54
  You are fixing flaky tests in the auth module.
53
55
 
56
+ <!-- This comment is stripped before sending to the agent -->
57
+
54
58
  Latest test output:
55
59
  {{ commands.tests }}
56
60
 
57
61
  Latest lint output:
58
62
  {{ commands.lint }}
59
63
 
64
+ Iteration {{ ralph.iteration }} of {{ ralph.name }}.
60
65
  Apply the smallest safe fix and explain why it works.
61
66
  ```
62
67
 
@@ -67,9 +72,21 @@ Apply the smallest safe fix and explain why it works.
67
72
  | `commands[].run` | string | required | Shell command |
68
73
  | `commands[].timeout` | number | `60` | Seconds before kill |
69
74
  | `max_iterations` | number | `50` | Stop after N iterations |
75
+ | `timeout` | number | `300` | Per-iteration timeout in seconds; stops the loop if the agent is stuck |
76
+ | `completion_promise` | string | — | Agent signals completion by sending `<promise>DONE</promise>`; loop breaks on match |
70
77
  | `guardrails.block_commands` | string[] | `[]` | Regex patterns to block in bash |
71
78
  | `guardrails.protected_files` | string[] | `[]` | Glob patterns to block writes |
72
79
 
80
+ ### Placeholders
81
+
82
+ | Placeholder | Description |
83
+ |-------------|-------------|
84
+ | `{{ commands.<name> }}` | Output from the named command |
85
+ | `{{ ralph.iteration }}` | Current 1-based iteration number |
86
+ | `{{ ralph.name }}` | Directory name containing the RALPH.md |
87
+
88
+ HTML comments (`<!-- ... -->`) are stripped from the prompt body after placeholder resolution, so you can annotate your RALPH.md freely.
89
+
73
90
  ## Commands
74
91
 
75
92
  - `/ralph <path>`: Start the loop from a `RALPH.md` file or directory.
@@ -79,7 +96,7 @@ Apply the smallest safe fix and explain why it works.
79
96
 
80
97
  ### Guardrails
81
98
 
82
- `guardrails.block_commands` and `guardrails.protected_files` come from RALPH frontmatter. The extension enforces them in the `tool_call` hook. Matching bash commands are blocked, and writes/edits to protected file globs are denied.
99
+ `guardrails.block_commands` and `guardrails.protected_files` come from RALPH frontmatter. The extension enforces them in the `tool_call` hook — but only for sessions created by the loop, so they don't leak into unrelated conversations. Matching bash commands are blocked, and writes/edits to protected file globs are denied.
83
100
 
84
101
  ### Cross-iteration memory
85
102
 
@@ -89,6 +106,18 @@ After each iteration, the extension stores a short summary with iteration number
89
106
 
90
107
  In the `tool_result` hook, bash outputs are scanned for failure patterns. After three or more failures in the same iteration, the extension appends a stop-and-think warning to push root-cause analysis before another retry.
91
108
 
109
+ ### Completion promise
110
+
111
+ When `completion_promise` is set (e.g., `"DONE"`), the loop scans the agent's messages for `<promise>DONE</promise>` after each iteration. If found, the loop stops early — the agent signals it's finished rather than relying solely on `max_iterations`.
112
+
113
+ ### Iteration timeout
114
+
115
+ Each iteration has a configurable timeout (default 300 seconds). If the agent is stuck and doesn't become idle within the timeout, the loop stops with a warning. This prevents runaway iterations from running forever.
116
+
117
+ ### Input validation
118
+
119
+ The extension validates `RALPH.md` frontmatter before starting and on each re-parse: `max_iterations` must be a positive integer, `timeout` must be positive, `block_commands` regexes must compile, and commands must have non-empty names and run strings with positive timeouts.
120
+
92
121
  ## Comparison table
93
122
 
94
123
  | Feature | **@lnilluv/pi-ralph-loop** | pi-ralph | pi-ralph-wiggum | ralphi | ralphify |
@@ -99,8 +128,13 @@ In the `tool_result` hook, bash outputs are scanned for failure patterns. After
99
128
  | Cross-iteration memory | ✓ | ✗ | ✗ | ✗ | ✗ |
100
129
  | Mid-turn steering | ✓ | ✗ | ✗ | ✗ | ✗ |
101
130
  | Live prompt editing | ✓ | ✗ | ✗ | ✗ | ✓ |
131
+ | Completion promise | ✓ | ✗ | ✗ | ✗ | ✓ |
132
+ | Iteration timeout | ✓ | ✗ | ✗ | ✗ | ✗ |
133
+ | Session-scoped hooks | ✓ | ✗ | ✗ | ✗ | ✗ |
134
+ | Input validation | ✓ | ✗ | ✗ | ✗ | ✗ |
102
135
  | Setup required | RALPH.md | config | RALPH.md | PRD pipeline | RALPH.md |
103
136
 
104
137
  ## License
105
138
 
106
139
  MIT
140
+ # CI provenance test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lnilluv/pi-ralph-loop",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Pi-native ralph loop — autonomous coding iterations with mid-turn supervision",
5
5
  "type": "module",
6
6
  "pi": {
package/src/index.ts CHANGED
@@ -4,64 +4,102 @@ import { readFileSync, existsSync } from "node:fs";
4
4
  import { resolve, join, dirname, basename } from "node:path";
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
6
 
7
- // --- Types ---
8
-
9
7
  type CommandDef = { name: string; run: string; timeout: number };
10
-
11
8
  type Frontmatter = {
12
9
  commands: CommandDef[];
13
10
  maxIterations: number;
11
+ timeout: number;
12
+ completionPromise?: string;
14
13
  guardrails: { blockCommands: string[]; protectedFiles: string[] };
15
14
  };
16
-
17
15
  type ParsedRalph = { frontmatter: Frontmatter; body: string };
18
16
  type CommandOutput = { name: string; output: string };
19
-
20
17
  type LoopState = {
21
18
  active: boolean;
22
19
  ralphPath: string;
23
20
  iteration: number;
24
21
  maxIterations: number;
22
+ timeout: number;
23
+ completionPromise?: string;
25
24
  stopRequested: boolean;
26
- failCount: number;
27
25
  iterationSummaries: Array<{ iteration: number; duration: number }>;
28
- guardrails: { blockCommands: RegExp[]; protectedFiles: string[] };
26
+ guardrails: { blockCommands: string[]; protectedFiles: string[] };
27
+ loopSessionFile?: string;
28
+ };
29
+ type PersistedLoopState = {
30
+ active: boolean;
31
+ sessionFile?: string;
32
+ iteration?: number;
33
+ maxIterations?: number;
34
+ iterationSummaries?: Array<{ iteration: number; duration: number }>;
35
+ guardrails?: { blockCommands: string[]; protectedFiles: string[] };
36
+ stopRequested?: boolean;
29
37
  };
30
-
31
- // --- Parsing ---
32
38
 
33
39
  function defaultFrontmatter(): Frontmatter {
34
- return { commands: [], maxIterations: 50, guardrails: { blockCommands: [], protectedFiles: [] } };
40
+ return { commands: [], maxIterations: 50, timeout: 300, guardrails: { blockCommands: [], protectedFiles: [] } };
35
41
  }
36
42
 
37
43
  function parseRalphMd(filePath: string): ParsedRalph {
38
- const raw = readFileSync(filePath, "utf8");
39
- const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
44
+ let raw = readFileSync(filePath, "utf8");
45
+ raw = raw.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
46
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
40
47
  if (!match) return { frontmatter: defaultFrontmatter(), body: raw };
41
48
 
42
- const yaml = parseYaml(match[1]) ?? {};
49
+ const yaml = (parseYaml(match[1]) ?? {}) as Record<string, any>;
43
50
  const commands: CommandDef[] = Array.isArray(yaml.commands)
44
- ? yaml.commands.map((c: Record<string, unknown>) => ({
45
- name: String(c.name ?? ""),
46
- run: String(c.run ?? ""),
47
- timeout: Number(c.timeout ?? 60),
48
- }))
51
+ ? yaml.commands.map((c: Record<string, any>) => ({ name: String(c.name ?? ""), run: String(c.run ?? ""), timeout: Number(c.timeout ?? 60) }))
49
52
  : [];
53
+ const guardrails = (yaml.guardrails ?? {}) as Record<string, any>;
50
54
 
51
- const guardrails = yaml.guardrails ?? {};
52
55
  return {
53
56
  frontmatter: {
54
57
  commands,
55
58
  maxIterations: Number(yaml.max_iterations ?? 50),
59
+ timeout: Number(yaml.timeout ?? 300),
60
+ completionPromise:
61
+ typeof yaml.completion_promise === "string" && yaml.completion_promise.trim() ? yaml.completion_promise : undefined,
56
62
  guardrails: {
57
- blockCommands: Array.isArray(guardrails.block_commands) ? guardrails.block_commands : [],
58
- protectedFiles: Array.isArray(guardrails.protected_files) ? guardrails.protected_files : [],
63
+ blockCommands: Array.isArray(guardrails.block_commands) ? guardrails.block_commands.map((p: unknown) => String(p)) : [],
64
+ protectedFiles: Array.isArray(guardrails.protected_files) ? guardrails.protected_files.map((p: unknown) => String(p)) : [],
59
65
  },
60
66
  },
61
- body: match[2],
67
+ body: match[2] ?? "",
62
68
  };
63
69
  }
64
70
 
71
+ function validateFrontmatter(fm: Frontmatter, ctx: any): boolean {
72
+ if (!Number.isFinite(fm.maxIterations) || !Number.isInteger(fm.maxIterations) || fm.maxIterations <= 0) {
73
+ ctx.ui.notify("Invalid max_iterations: must be a positive finite integer", "error");
74
+ return false;
75
+ }
76
+ if (!Number.isFinite(fm.timeout) || fm.timeout <= 0) {
77
+ ctx.ui.notify("Invalid timeout: must be a positive finite number", "error");
78
+ return false;
79
+ }
80
+ for (const pattern of fm.guardrails.blockCommands) {
81
+ try { new RegExp(pattern); } catch {
82
+ ctx.ui.notify(`Invalid block_commands regex: ${pattern}`, "error");
83
+ return false;
84
+ }
85
+ }
86
+ for (const cmd of fm.commands) {
87
+ if (!cmd.name.trim()) {
88
+ ctx.ui.notify("Invalid command: name is required", "error");
89
+ return false;
90
+ }
91
+ if (!cmd.run.trim()) {
92
+ ctx.ui.notify(`Invalid command ${cmd.name}: run is required`, "error");
93
+ return false;
94
+ }
95
+ if (!Number.isFinite(cmd.timeout) || cmd.timeout <= 0) {
96
+ ctx.ui.notify(`Invalid command ${cmd.name}: timeout must be positive`, "error");
97
+ return false;
98
+ }
99
+ }
100
+ return true;
101
+ }
102
+
65
103
  function resolveRalphPath(args: string, cwd: string): string {
66
104
  const target = args.trim() || ".";
67
105
  const abs = resolve(cwd, target);
@@ -70,201 +108,290 @@ function resolveRalphPath(args: string, cwd: string): string {
70
108
  throw new Error(`No RALPH.md found at ${abs}`);
71
109
  }
72
110
 
73
- function resolvePlaceholders(body: string, outputs: CommandOutput[]): string {
111
+ function resolvePlaceholders(body: string, outputs: CommandOutput[], ralph: { iteration: number; name: string }): string {
74
112
  const map = new Map(outputs.map((o) => [o.name, o.output]));
75
- return body.replace(/\{\{\s*commands\.(\w[\w-]*)\s*\}\}/g, (_, name) => map.get(name) ?? "");
113
+ return body
114
+ .replace(/\{\{\s*commands\.(\w[\w-]*)\s*\}\}/g, (_, name) => map.get(name) ?? "")
115
+ .replace(/\{\{\s*ralph\.iteration\s*\}\}/g, String(ralph.iteration))
116
+ .replace(/\{\{\s*ralph\.name\s*\}\}/g, ralph.name);
76
117
  }
77
118
 
78
- function parseGuardrails(fm: Frontmatter): LoopState["guardrails"] {
79
- return {
80
- blockCommands: fm.guardrails.blockCommands.map((p) => new RegExp(p)),
81
- protectedFiles: fm.guardrails.protectedFiles,
82
- };
83
- }
84
-
85
- // --- Command execution ---
86
-
87
- async function runCommands(
88
- commands: CommandDef[],
89
- cwd: string,
90
- pi: ExtensionAPI,
91
- ): Promise<CommandOutput[]> {
119
+ async function runCommands(commands: CommandDef[], pi: ExtensionAPI): Promise<CommandOutput[]> {
92
120
  const results: CommandOutput[] = [];
93
121
  for (const cmd of commands) {
94
122
  try {
95
123
  const result = await pi.exec("bash", ["-c", cmd.run], { timeout: cmd.timeout * 1000 });
96
- results.push({
97
- name: cmd.name,
98
- output: (result.stdout + result.stderr).trim(),
99
- });
100
- } catch {
101
- results.push({ name: cmd.name, output: `[timed out after ${cmd.timeout}s]` });
124
+ results.push(result.killed
125
+ ? { name: cmd.name, output: `[timed out after ${cmd.timeout}s]` }
126
+ : { name: cmd.name, output: (result.stdout + result.stderr).trim() });
127
+ } catch (err) {
128
+ const message = err instanceof Error ? err.message : String(err);
129
+ results.push({ name: cmd.name, output: `[error: ${message}]` });
102
130
  }
103
131
  }
104
132
  return results;
105
133
  }
106
134
 
107
- // --- Extension ---
108
-
109
- let loopState: LoopState = {
110
- active: false,
111
- ralphPath: "",
112
- iteration: 0,
113
- maxIterations: 50,
114
- stopRequested: false,
115
- failCount: 0,
116
- iterationSummaries: [],
117
- guardrails: { blockCommands: [], protectedFiles: [] },
118
- };
135
+ function defaultLoopState(): LoopState {
136
+ return { active: false, ralphPath: "", iteration: 0, maxIterations: 50, timeout: 300, completionPromise: undefined, stopRequested: false, iterationSummaries: [], guardrails: { blockCommands: [], protectedFiles: [] }, loopSessionFile: undefined };
137
+ }
138
+
139
+ function readPersistedLoopState(ctx: any): PersistedLoopState | undefined {
140
+ const entries = ctx.sessionManager.getEntries();
141
+ for (let i = entries.length - 1; i >= 0; i--) {
142
+ const entry = entries[i];
143
+ if (entry.type === "custom" && entry.customType === "ralph-loop-state") {
144
+ return typeof entry.data === "object" && entry.data ? (entry.data as PersistedLoopState) : undefined;
145
+ }
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ function persistLoopState(pi: ExtensionAPI, data: PersistedLoopState) {
151
+ pi.appendEntry("ralph-loop-state", data);
152
+ }
153
+
154
+ let loopState: LoopState = defaultLoopState();
119
155
 
120
156
  export default function (pi: ExtensionAPI) {
121
- // Guardrails: block dangerous tool calls during loop
122
- pi.on("tool_call", async (event) => {
123
- if (!loopState.active) return;
157
+ const failCounts = new Map<string, number>();
158
+ const isLoopSession = (ctx: any): boolean => {
159
+ const state = readPersistedLoopState(ctx);
160
+ const sessionFile = ctx.sessionManager.getSessionFile();
161
+ return state?.active === true && state.sessionFile === sessionFile;
162
+ };
163
+
164
+ pi.on("tool_call", async (event: any, ctx: any) => {
165
+ if (!isLoopSession(ctx)) return;
166
+ const persisted = readPersistedLoopState(ctx);
167
+ if (!persisted) return;
124
168
 
125
169
  if (event.toolName === "bash") {
126
170
  const cmd = (event.input as { command?: string }).command ?? "";
127
- for (const pattern of loopState.guardrails.blockCommands) {
128
- if (pattern.test(cmd)) {
129
- return { block: true, reason: `ralph: blocked (${pattern.source})` };
171
+ for (const pattern of persisted.guardrails?.blockCommands ?? []) {
172
+ try {
173
+ if (new RegExp(pattern).test(cmd)) return { block: true, reason: `ralph: blocked (${pattern})` };
174
+ } catch {
175
+ // ignore malformed persisted regex
130
176
  }
131
177
  }
132
178
  }
133
179
 
134
180
  if (event.toolName === "write" || event.toolName === "edit") {
135
181
  const filePath = (event.input as { path?: string }).path ?? "";
136
- for (const glob of loopState.guardrails.protectedFiles) {
137
- if (minimatch(filePath, glob, { matchBase: true })) {
138
- return { block: true, reason: `ralph: ${filePath} is protected` };
139
- }
182
+ for (const glob of persisted.guardrails?.protectedFiles ?? []) {
183
+ if (minimatch(filePath, glob, { matchBase: true })) return { block: true, reason: `ralph: ${filePath} is protected` };
140
184
  }
141
185
  }
142
186
  });
143
187
 
144
- // Cross-iteration memory: inject context into system prompt
145
- pi.on("before_agent_start", async (event) => {
146
- if (!loopState.active || loopState.iterationSummaries.length === 0) return;
147
-
148
- const history = loopState.iterationSummaries
149
- .map((s) => `- Iteration ${s.iteration}: ${s.duration}s`)
150
- .join("\n");
188
+ pi.on("before_agent_start", async (event: any, ctx: any) => {
189
+ if (!isLoopSession(ctx)) return;
190
+ const persisted = readPersistedLoopState(ctx);
191
+ const summaries = persisted?.iterationSummaries ?? [];
192
+ if (summaries.length === 0) return;
151
193
 
194
+ const history = summaries.map((s) => `- Iteration ${s.iteration}: ${s.duration}s`).join("\n");
152
195
  return {
153
196
  systemPrompt:
154
197
  event.systemPrompt +
155
- `\n\n## Ralph Loop Context\nIteration ${loopState.iteration}/${loopState.maxIterations}\n\nPrevious iterations:\n${history}\n\nDo not repeat completed work. Check git log for recent changes.`,
198
+ `\n\n## Ralph Loop Context\nIteration ${persisted?.iteration ?? 0}/${persisted?.maxIterations ?? 0}\n\nPrevious iterations:\n${history}\n\nDo not repeat completed work. Check git log for recent changes.`,
156
199
  };
157
200
  });
158
201
 
159
- // Mid-turn steering: warn after repeated failures
160
- pi.on("tool_result", async (event) => {
161
- if (!loopState.active || event.toolName !== "bash") return;
202
+ pi.on("tool_result", async (event: any, ctx: any) => {
203
+ if (!isLoopSession(ctx) || event.toolName !== "bash") return;
204
+ const output = event.content.map((c: { type: string; text?: string }) => (c.type === "text" ? c.text ?? "" : "")).join("");
205
+ if (!/FAIL|ERROR|error:|failed/i.test(output)) return;
162
206
 
163
- const output = event.content
164
- .map((c: { type: string; text?: string }) => (c.type === "text" ? c.text ?? "" : ""))
165
- .join("");
207
+ const sessionFile = ctx.sessionManager.getSessionFile();
208
+ if (!sessionFile) return;
166
209
 
167
- if (/FAIL|ERROR|error:|failed/i.test(output)) {
168
- loopState.failCount++;
169
- }
170
-
171
- if (loopState.failCount >= 3) {
210
+ const next = (failCounts.get(sessionFile) ?? 0) + 1;
211
+ failCounts.set(sessionFile, next);
212
+ if (next >= 3) {
172
213
  return {
173
214
  content: [
174
215
  ...event.content,
175
- {
176
- type: "text" as const,
177
- text: "\n\n⚠️ ralph: 3+ failures this iteration. Stop and describe the root cause before retrying.",
178
- },
216
+ { type: "text" as const, text: "\n\n⚠️ ralph: 3+ failures this iteration. Stop and describe the root cause before retrying." },
179
217
  ],
180
218
  };
181
219
  }
182
220
  });
183
221
 
184
- // /ralph command: start the loop
185
222
  pi.registerCommand("ralph", {
186
223
  description: "Start an autonomous ralph loop from a RALPH.md file",
187
- handler: async (args, ctx) => {
224
+ handler: async (args: string, ctx: any) => {
188
225
  if (loopState.active) {
189
226
  ctx.ui.notify("A ralph loop is already running. Use /ralph-stop first.", "warning");
190
227
  return;
191
228
  }
192
229
 
193
- let ralphPath: string;
230
+ let name: string;
194
231
  try {
195
- ralphPath = resolveRalphPath(args ?? "", ctx.cwd);
232
+ const ralphPath = resolveRalphPath(args ?? "", ctx.cwd);
233
+ const { frontmatter } = parseRalphMd(ralphPath);
234
+ if (!validateFrontmatter(frontmatter, ctx)) return;
235
+ name = basename(dirname(ralphPath));
236
+ loopState = {
237
+ active: true,
238
+ ralphPath,
239
+ iteration: 0,
240
+ maxIterations: frontmatter.maxIterations,
241
+ timeout: frontmatter.timeout,
242
+ completionPromise: frontmatter.completionPromise,
243
+ stopRequested: false,
244
+ iterationSummaries: [],
245
+ guardrails: { blockCommands: frontmatter.guardrails.blockCommands, protectedFiles: frontmatter.guardrails.protectedFiles },
246
+ loopSessionFile: undefined,
247
+ };
196
248
  } catch (err) {
197
249
  ctx.ui.notify(String(err), "error");
198
250
  return;
199
251
  }
200
-
201
- const { frontmatter } = parseRalphMd(ralphPath);
202
- loopState = {
203
- active: true,
204
- ralphPath,
205
- iteration: 0,
206
- maxIterations: frontmatter.maxIterations,
207
- stopRequested: false,
208
- failCount: 0,
209
- iterationSummaries: [],
210
- guardrails: parseGuardrails(frontmatter),
211
- };
212
-
213
- const name = basename(dirname(ralphPath));
214
252
  ctx.ui.notify(`Ralph loop started: ${name} (max ${loopState.maxIterations} iterations)`, "info");
215
253
 
216
- for (let i = 1; i <= loopState.maxIterations; i++) {
217
- if (loopState.stopRequested) break;
218
-
219
- loopState.iteration = i;
220
- loopState.failCount = 0;
221
- const iterStart = Date.now();
222
-
223
- // Re-parse every iteration (live editing support)
224
- const { frontmatter: fm, body } = parseRalphMd(loopState.ralphPath);
225
- loopState.maxIterations = fm.maxIterations;
226
- loopState.guardrails = parseGuardrails(fm);
227
-
228
- // Run commands and resolve placeholders
229
- const outputs = await runCommands(fm.commands, ctx.cwd, pi);
230
- const header = `[ralph: iteration ${i}/${loopState.maxIterations}]\n\n`;
231
- const prompt = header + resolvePlaceholders(body, outputs);
232
-
233
- // Fresh session
234
- ctx.ui.setStatus("ralph", `🔁 ${name}: iteration ${i}/${loopState.maxIterations}`);
235
- await ctx.newSession();
236
-
237
- // Send prompt and wait for agent to finish
238
- pi.sendUserMessage(prompt);
239
- await ctx.waitForIdle();
240
-
241
- // Record iteration
242
- const elapsed = Math.round((Date.now() - iterStart) / 1000);
243
- loopState.iterationSummaries.push({ iteration: i, duration: elapsed });
244
- pi.appendEntry("ralph-iteration", { iteration: i, duration: elapsed, ralphPath: loopState.ralphPath });
254
+ try {
255
+ iterationLoop: for (let i = 1; i <= loopState.maxIterations; i++) {
256
+ if (loopState.stopRequested) break;
257
+ const persistedBefore = readPersistedLoopState(ctx);
258
+ if (persistedBefore?.active && persistedBefore.stopRequested) {
259
+ loopState.stopRequested = true;
260
+ ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
261
+ break;
262
+ }
263
+
264
+ loopState.iteration = i;
265
+ const iterStart = Date.now();
266
+ const { frontmatter: fm, body: rawBody } = parseRalphMd(loopState.ralphPath);
267
+ if (!validateFrontmatter(fm, ctx)) {
268
+ ctx.ui.notify(`Invalid RALPH.md on iteration ${i}, stopping loop`, "error");
269
+ break;
270
+ }
271
+
272
+ loopState.maxIterations = fm.maxIterations;
273
+ loopState.timeout = fm.timeout;
274
+ loopState.completionPromise = fm.completionPromise;
275
+ loopState.guardrails = { blockCommands: fm.guardrails.blockCommands, protectedFiles: fm.guardrails.protectedFiles };
276
+
277
+ const outputs = await runCommands(fm.commands, pi);
278
+ let body = resolvePlaceholders(rawBody, outputs, { iteration: i, name });
279
+ body = body.replace(/<!--[\s\S]*?-->/g, "");
280
+ const prompt = `[ralph: iteration ${i}/${loopState.maxIterations}]\n\n${body}`;
281
+
282
+ const prevPersisted = readPersistedLoopState(ctx);
283
+ if (prevPersisted?.active && prevPersisted.sessionFile === ctx.sessionManager.getSessionFile()) persistLoopState(pi, { ...prevPersisted, active: false });
284
+ ctx.ui.setStatus("ralph", `🔁 ${name}: iteration ${i}/${loopState.maxIterations}`);
285
+ const prevSessionFile = loopState.loopSessionFile;
286
+ const { cancelled } = await ctx.newSession();
287
+ if (cancelled) {
288
+ ctx.ui.notify("Session switch cancelled, stopping loop", "warning");
289
+ break;
290
+ }
291
+
292
+ loopState.loopSessionFile = ctx.sessionManager.getSessionFile();
293
+ if (prevSessionFile && prevSessionFile !== loopState.loopSessionFile) failCounts.delete(prevSessionFile);
294
+ if (loopState.loopSessionFile) failCounts.set(loopState.loopSessionFile, 0);
295
+ persistLoopState(pi, {
296
+ active: true,
297
+ sessionFile: loopState.loopSessionFile,
298
+ iteration: loopState.iteration,
299
+ maxIterations: loopState.maxIterations,
300
+ iterationSummaries: loopState.iterationSummaries,
301
+ guardrails: { blockCommands: loopState.guardrails.blockCommands, protectedFiles: loopState.guardrails.protectedFiles },
302
+ stopRequested: false,
303
+ });
304
+
305
+ pi.sendUserMessage(prompt);
306
+ const timeoutMs = fm.timeout * 1000;
307
+ let timedOut = false;
308
+ let idleError: Error | undefined;
309
+ let timer: ReturnType<typeof setTimeout> | undefined;
310
+ try {
311
+ await Promise.race([
312
+ ctx.waitForIdle().catch((e: any) => {
313
+ idleError = e instanceof Error ? e : new Error(String(e));
314
+ throw e;
315
+ }),
316
+ new Promise<never>((_, reject) => {
317
+ timer = setTimeout(() => {
318
+ timedOut = true;
319
+ reject(new Error("timeout"));
320
+ }, timeoutMs);
321
+ }),
322
+ ]);
323
+ } catch {
324
+ // timedOut is set by timer; idleError means waitForIdle failed
325
+ }
326
+ if (timer) clearTimeout(timer);
327
+ if (timedOut) {
328
+ ctx.ui.notify(`Iteration ${i} timed out after ${fm.timeout}s, stopping loop`, "warning");
329
+ break;
330
+ }
331
+ if (idleError) {
332
+ ctx.ui.notify(`Iteration ${i} agent error: ${idleError.message}, stopping loop`, "error");
333
+ break;
334
+ }
335
+
336
+ const elapsed = Math.round((Date.now() - iterStart) / 1000);
337
+ loopState.iterationSummaries.push({ iteration: i, duration: elapsed });
338
+ pi.appendEntry("ralph-iteration", { iteration: i, duration: elapsed, ralphPath: loopState.ralphPath });
339
+
340
+ const persistedAfter = readPersistedLoopState(ctx);
341
+ if (persistedAfter?.active && persistedAfter.stopRequested) {
342
+ loopState.stopRequested = true;
343
+ ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
344
+ break;
345
+ }
346
+
347
+ if (fm.completionPromise) {
348
+ const entries = ctx.sessionManager.getEntries();
349
+ for (const entry of entries) {
350
+ if (entry.type === "message" && entry.message?.role === "assistant") {
351
+ const text = entry.message.content?.filter((b: any) => b.type === "text")?.map((b: any) => b.text)?.join("") ?? "";
352
+ const match = text.match(/<promise>([^<]+)<\/promise>/);
353
+ if (match && fm.completionPromise && match[1].trim() === fm.completionPromise.trim()) {
354
+ ctx.ui.notify(`Completion promise matched on iteration ${i}`, "info");
355
+ break iterationLoop;
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ ctx.ui.notify(`Iteration ${i} complete (${elapsed}s)`, "info");
362
+ }
245
363
 
246
- ctx.ui.notify(`Iteration ${i} complete (${elapsed}s)`, "info");
364
+ const total = loopState.iterationSummaries.reduce((a, s) => a + s.duration, 0);
365
+ ctx.ui.notify(`Ralph loop done: ${loopState.iteration} iterations, ${total}s total`, "info");
366
+ } catch (err) {
367
+ const message = err instanceof Error ? err.message : String(err);
368
+ ctx.ui.notify(`Ralph loop failed: ${message}`, "error");
369
+ } finally {
370
+ failCounts.clear();
371
+ loopState.active = false;
372
+ loopState.stopRequested = false;
373
+ loopState.loopSessionFile = undefined;
374
+ ctx.ui.setStatus("ralph", undefined);
375
+ persistLoopState(pi, { active: false });
247
376
  }
248
-
249
- loopState.active = false;
250
- ctx.ui.setStatus("ralph", undefined);
251
- const total = loopState.iterationSummaries.reduce((a, s) => a + s.duration, 0);
252
- ctx.ui.notify(
253
- `Ralph loop done: ${loopState.iteration} iterations, ${total}s total`,
254
- "info",
255
- );
256
377
  },
257
378
  });
258
379
 
259
- // /ralph-stop command: graceful stop
260
380
  pi.registerCommand("ralph-stop", {
261
381
  description: "Stop the ralph loop after the current iteration",
262
- handler: async (_args, ctx) => {
263
- if (!loopState.active) {
264
- ctx.ui.notify("No active ralph loop", "warning");
382
+ handler: async (_args: string, ctx: any) => {
383
+ const persisted = readPersistedLoopState(ctx);
384
+ if (!persisted?.active) {
385
+ if (!loopState.active) {
386
+ ctx.ui.notify("No active ralph loop", "warning");
387
+ return;
388
+ }
389
+ loopState.stopRequested = true;
390
+ ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
265
391
  return;
266
392
  }
267
393
  loopState.stopRequested = true;
394
+ persistLoopState(pi, { ...persisted, stopRequested: true });
268
395
  ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
269
396
  },
270
397
  });
package/src/shims.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ declare module "yaml" {
2
+ export function parse(input: string): any;
3
+ }
4
+
5
+ declare module "minimatch" {
6
+ export function minimatch(path: string, pattern: string, options?: any): boolean;
7
+ }
8
+
9
+ declare module "node:fs" {
10
+ export function readFileSync(path: string, encoding: string): string;
11
+ export function existsSync(path: string): boolean;
12
+ }
13
+
14
+ declare module "node:path" {
15
+ export function resolve(...paths: string[]): string;
16
+ export function join(...paths: string[]): string;
17
+ export function dirname(path: string): string;
18
+ export function basename(path: string): string;
19
+ }
20
+
21
+ declare module "@mariozechner/pi-coding-agent" {
22
+ export type ExtensionAPI = any;
23
+ }