pi-extensions 0.1.9

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 (135) hide show
  1. package/.ralph/import-cc-codex.md +31 -0
  2. package/.ralph/import-cc-codex.state.json +14 -0
  3. package/.ralph/mario-not-impl.md +69 -0
  4. package/.ralph/mario-not-impl.state.json +14 -0
  5. package/.ralph/mario-not-spec.md +163 -0
  6. package/.ralph/mario-not-spec.state.json +14 -0
  7. package/LICENSE +21 -0
  8. package/README.md +65 -0
  9. package/RELEASING.md +34 -0
  10. package/agent-guidance/CHANGELOG.md +4 -0
  11. package/agent-guidance/README.md +102 -0
  12. package/agent-guidance/agent-guidance.ts +147 -0
  13. package/agent-guidance/package.json +22 -0
  14. package/agent-guidance/setup.sh +75 -0
  15. package/agent-guidance/templates/CLAUDE.md +5 -0
  16. package/agent-guidance/templates/CODEX.md +92 -0
  17. package/agent-guidance/templates/GEMINI.md +5 -0
  18. package/arcade/CHANGELOG.md +4 -0
  19. package/arcade/README.md +85 -0
  20. package/arcade/assets/picman.png +0 -0
  21. package/arcade/assets/ping.png +0 -0
  22. package/arcade/assets/spice-invaders.png +0 -0
  23. package/arcade/assets/tetris.png +0 -0
  24. package/arcade/mario-not/README.md +30 -0
  25. package/arcade/mario-not/boss.js +103 -0
  26. package/arcade/mario-not/camera.js +59 -0
  27. package/arcade/mario-not/collision.js +91 -0
  28. package/arcade/mario-not/colors.js +36 -0
  29. package/arcade/mario-not/constants.js +97 -0
  30. package/arcade/mario-not/core.js +39 -0
  31. package/arcade/mario-not/death.js +77 -0
  32. package/arcade/mario-not/effects.js +84 -0
  33. package/arcade/mario-not/enemies.js +31 -0
  34. package/arcade/mario-not/engine.js +171 -0
  35. package/arcade/mario-not/fireballs.js +98 -0
  36. package/arcade/mario-not/items.js +24 -0
  37. package/arcade/mario-not/levels.js +403 -0
  38. package/arcade/mario-not/logic.js +104 -0
  39. package/arcade/mario-not/mario-not.ts +297 -0
  40. package/arcade/mario-not/player.js +244 -0
  41. package/arcade/mario-not/render.js +257 -0
  42. package/arcade/mario-not/spec.md +548 -0
  43. package/arcade/mario-not/state.js +246 -0
  44. package/arcade/mario-not/tests/e2e.test.js +855 -0
  45. package/arcade/mario-not/tests/engine.test.js +888 -0
  46. package/arcade/mario-not/tests/fixtures/story0-frame.txt +4 -0
  47. package/arcade/mario-not/tests/fixtures/story1-camera.txt +4 -0
  48. package/arcade/mario-not/tests/fixtures/story1-glyphs.txt +4 -0
  49. package/arcade/mario-not/tests/fixtures/story10-item.txt +4 -0
  50. package/arcade/mario-not/tests/fixtures/story11-hazards.txt +4 -0
  51. package/arcade/mario-not/tests/fixtures/story12-used-block.txt +4 -0
  52. package/arcade/mario-not/tests/fixtures/story13-pipes.txt +4 -0
  53. package/arcade/mario-not/tests/fixtures/story14-goal.txt +4 -0
  54. package/arcade/mario-not/tests/fixtures/story15-hud-narrow.txt +2 -0
  55. package/arcade/mario-not/tests/fixtures/story16-unknown-tile.txt +4 -0
  56. package/arcade/mario-not/tests/fixtures/story17-mix.txt +4 -0
  57. package/arcade/mario-not/tests/fixtures/story18-hud-score.txt +2 -0
  58. package/arcade/mario-not/tests/fixtures/story19-cue.txt +4 -0
  59. package/arcade/mario-not/tests/fixtures/story2-enemy.txt +4 -0
  60. package/arcade/mario-not/tests/fixtures/story20-camera-offset.txt +4 -0
  61. package/arcade/mario-not/tests/fixtures/story21-hud-zero.txt +2 -0
  62. package/arcade/mario-not/tests/fixtures/story22-big-viewport.txt +4 -0
  63. package/arcade/mario-not/tests/fixtures/story23-camera-negative.txt +4 -0
  64. package/arcade/mario-not/tests/fixtures/story24-camera-width.txt +4 -0
  65. package/arcade/mario-not/tests/fixtures/story25-camera-positive.txt +4 -0
  66. package/arcade/mario-not/tests/fixtures/story26-hud-lives.txt +2 -0
  67. package/arcade/mario-not/tests/fixtures/story27-hud-coins.txt +2 -0
  68. package/arcade/mario-not/tests/fixtures/story28-item-viewport.txt +4 -0
  69. package/arcade/mario-not/tests/fixtures/story29-enemy-viewport.txt +4 -0
  70. package/arcade/mario-not/tests/fixtures/story3-hud.txt +2 -0
  71. package/arcade/mario-not/tests/fixtures/story30-hud-score.txt +2 -0
  72. package/arcade/mario-not/tests/fixtures/story31-particles-viewport.txt +4 -0
  73. package/arcade/mario-not/tests/fixtures/story32-paused-frame.txt +4 -0
  74. package/arcade/mario-not/tests/fixtures/story4-big.txt +4 -0
  75. package/arcade/mario-not/tests/fixtures/story5-resume-hud.txt +2 -0
  76. package/arcade/mario-not/tests/fixtures/story6-particles.txt +4 -0
  77. package/arcade/mario-not/tests/fixtures/story6-paused.txt +4 -0
  78. package/arcade/mario-not/tests/fixtures/story7-powerup.txt +4 -0
  79. package/arcade/mario-not/tests/fixtures/story8-hud-time.txt +2 -0
  80. package/arcade/mario-not/tests/fixtures/story9-hud-level.txt +2 -0
  81. package/arcade/mario-not/tiles.js +79 -0
  82. package/arcade/mario-not/tsconfig.json +14 -0
  83. package/arcade/mario-not/types.js +225 -0
  84. package/arcade/package.json +26 -0
  85. package/arcade/picman.ts +328 -0
  86. package/arcade/ping.ts +594 -0
  87. package/arcade/spice-invaders.ts +1104 -0
  88. package/arcade/tetris.ts +662 -0
  89. package/code-actions/CHANGELOG.md +4 -0
  90. package/code-actions/README.md +65 -0
  91. package/code-actions/actions.ts +107 -0
  92. package/code-actions/index.ts +148 -0
  93. package/code-actions/package.json +22 -0
  94. package/code-actions/search.ts +79 -0
  95. package/code-actions/snippets.ts +179 -0
  96. package/code-actions/ui.ts +120 -0
  97. package/files-widget/CHANGELOG.md +90 -0
  98. package/files-widget/DESIGN.md +452 -0
  99. package/files-widget/README.md +122 -0
  100. package/files-widget/TODO.md +141 -0
  101. package/files-widget/browser.ts +922 -0
  102. package/files-widget/comment.ts +5 -0
  103. package/files-widget/constants.ts +18 -0
  104. package/files-widget/demo.svg +1 -0
  105. package/files-widget/file-tree.ts +224 -0
  106. package/files-widget/file-viewer.ts +93 -0
  107. package/files-widget/git.ts +107 -0
  108. package/files-widget/index.ts +140 -0
  109. package/files-widget/input-utils.ts +3 -0
  110. package/files-widget/package.json +22 -0
  111. package/files-widget/types.ts +28 -0
  112. package/files-widget/utils.ts +26 -0
  113. package/files-widget/viewer.ts +424 -0
  114. package/import-cc-codex/research/import-chats-from-other-agents.md +135 -0
  115. package/import-cc-codex/spec.md +79 -0
  116. package/package.json +29 -0
  117. package/ralph-wiggum/CHANGELOG.md +7 -0
  118. package/ralph-wiggum/README.md +96 -0
  119. package/ralph-wiggum/SKILL.md +73 -0
  120. package/ralph-wiggum/index.ts +792 -0
  121. package/ralph-wiggum/package.json +25 -0
  122. package/raw-paste/CHANGELOG.md +7 -0
  123. package/raw-paste/README.md +52 -0
  124. package/raw-paste/index.ts +112 -0
  125. package/raw-paste/package.json +22 -0
  126. package/tab-status/CHANGELOG.md +4 -0
  127. package/tab-status/README.md +61 -0
  128. package/tab-status/assets/tab-status.png +0 -0
  129. package/tab-status/package.json +22 -0
  130. package/tab-status/tab-status.ts +179 -0
  131. package/usage-extension/CHANGELOG.md +17 -0
  132. package/usage-extension/README.md +120 -0
  133. package/usage-extension/index.ts +628 -0
  134. package/usage-extension/package.json +22 -0
  135. package/usage-extension/screenshot.png +0 -0
@@ -0,0 +1,792 @@
1
+ /**
2
+ * Ralph Wiggum - Long-running agent loops for iterative development.
3
+ * Port of Geoffrey Huntley's approach.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
9
+ import { Type } from "@sinclair/typebox";
10
+
11
+ const RALPH_DIR = ".ralph";
12
+ const COMPLETE_MARKER = "<promise>COMPLETE</promise>";
13
+
14
+ const DEFAULT_TEMPLATE = `# Task
15
+
16
+ Describe your task here.
17
+
18
+ ## Goals
19
+ - Goal 1
20
+ - Goal 2
21
+
22
+ ## Checklist
23
+ - [ ] Item 1
24
+ - [ ] Item 2
25
+
26
+ ## Notes
27
+ (Update this as you work)
28
+ `;
29
+
30
+ const DEFAULT_REFLECT_INSTRUCTIONS = `REFLECTION CHECKPOINT
31
+
32
+ Pause and reflect on your progress:
33
+ 1. What has been accomplished so far?
34
+ 2. What's working well?
35
+ 3. What's not working or blocking progress?
36
+ 4. Should the approach be adjusted?
37
+ 5. What are the next priorities?
38
+
39
+ Update the task file with your reflection, then continue working.`;
40
+
41
+ type LoopStatus = "active" | "paused" | "completed";
42
+
43
+ interface LoopState {
44
+ name: string;
45
+ taskFile: string;
46
+ iteration: number;
47
+ maxIterations: number;
48
+ itemsPerIteration: number; // Prompt hint only - "process N items per turn"
49
+ reflectEvery: number; // Reflect every N iterations
50
+ reflectInstructions: string;
51
+ active: boolean; // Backwards compat
52
+ status: LoopStatus;
53
+ startedAt: string;
54
+ completedAt?: string;
55
+ lastReflectionAt: number; // Last iteration we reflected at
56
+ }
57
+
58
+ const STATUS_ICONS: Record<LoopStatus, string> = { active: "▶", paused: "⏸", completed: "✓" };
59
+
60
+ export default function (pi: ExtensionAPI) {
61
+ let currentLoop: string | null = null;
62
+
63
+ // --- File helpers ---
64
+
65
+ const ralphDir = (ctx: ExtensionContext) => path.resolve(ctx.cwd, RALPH_DIR);
66
+ const archiveDir = (ctx: ExtensionContext) => path.join(ralphDir(ctx), "archive");
67
+ const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/g, "_");
68
+
69
+ function getPath(ctx: ExtensionContext, name: string, ext: string, archived = false): string {
70
+ const dir = archived ? archiveDir(ctx) : ralphDir(ctx);
71
+ return path.join(dir, `${sanitize(name)}${ext}`);
72
+ }
73
+
74
+ function ensureDir(filePath: string): void {
75
+ const dir = path.dirname(filePath);
76
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
77
+ }
78
+
79
+ function tryDelete(filePath: string): void {
80
+ try {
81
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
82
+ } catch {
83
+ /* ignore */
84
+ }
85
+ }
86
+
87
+ function tryRead(filePath: string): string | null {
88
+ try {
89
+ return fs.readFileSync(filePath, "utf-8");
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function tryRemoveDir(dirPath: string): boolean {
96
+ try {
97
+ if (fs.existsSync(dirPath)) {
98
+ fs.rmSync(dirPath, { recursive: true, force: true });
99
+ }
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ // --- State management ---
107
+
108
+ function migrateState(raw: Partial<LoopState> & { name: string }): LoopState {
109
+ if (!raw.status) raw.status = raw.active ? "active" : "paused";
110
+ raw.active = raw.status === "active";
111
+ // Migrate old field names
112
+ if ("reflectEveryItems" in raw && !raw.reflectEvery) {
113
+ raw.reflectEvery = (raw as any).reflectEveryItems;
114
+ }
115
+ if ("lastReflectionAtItems" in raw && raw.lastReflectionAt === undefined) {
116
+ raw.lastReflectionAt = (raw as any).lastReflectionAtItems;
117
+ }
118
+ return raw as LoopState;
119
+ }
120
+
121
+ function loadState(ctx: ExtensionContext, name: string, archived = false): LoopState | null {
122
+ const content = tryRead(getPath(ctx, name, ".state.json", archived));
123
+ return content ? migrateState(JSON.parse(content)) : null;
124
+ }
125
+
126
+ function saveState(ctx: ExtensionContext, state: LoopState, archived = false): void {
127
+ state.active = state.status === "active";
128
+ const filePath = getPath(ctx, state.name, ".state.json", archived);
129
+ ensureDir(filePath);
130
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8");
131
+ }
132
+
133
+ function listLoops(ctx: ExtensionContext, archived = false): LoopState[] {
134
+ const dir = archived ? archiveDir(ctx) : ralphDir(ctx);
135
+ if (!fs.existsSync(dir)) return [];
136
+ return fs
137
+ .readdirSync(dir)
138
+ .filter((f) => f.endsWith(".state.json"))
139
+ .map((f) => {
140
+ const content = tryRead(path.join(dir, f));
141
+ return content ? migrateState(JSON.parse(content)) : null;
142
+ })
143
+ .filter((s): s is LoopState => s !== null);
144
+ }
145
+
146
+ // --- Loop state transitions ---
147
+
148
+ function pauseLoop(ctx: ExtensionContext, state: LoopState, message?: string): void {
149
+ state.status = "paused";
150
+ state.active = false;
151
+ saveState(ctx, state);
152
+ currentLoop = null;
153
+ updateUI(ctx);
154
+ if (message && ctx.hasUI) ctx.ui.notify(message, "info");
155
+ }
156
+
157
+ function completeLoop(ctx: ExtensionContext, state: LoopState, banner: string): void {
158
+ state.status = "completed";
159
+ state.completedAt = new Date().toISOString();
160
+ state.active = false;
161
+ saveState(ctx, state);
162
+ currentLoop = null;
163
+ updateUI(ctx);
164
+ pi.sendUserMessage(banner);
165
+ }
166
+
167
+ function stopLoop(ctx: ExtensionContext, state: LoopState, message?: string): void {
168
+ state.status = "completed";
169
+ state.completedAt = new Date().toISOString();
170
+ state.active = false;
171
+ saveState(ctx, state);
172
+ currentLoop = null;
173
+ updateUI(ctx);
174
+ if (message && ctx.hasUI) ctx.ui.notify(message, "info");
175
+ }
176
+
177
+ // --- UI ---
178
+
179
+ function formatLoop(l: LoopState): string {
180
+ const status = `${STATUS_ICONS[l.status]} ${l.status}`;
181
+ const iter = l.maxIterations > 0 ? `${l.iteration}/${l.maxIterations}` : `${l.iteration}`;
182
+ return `${l.name}: ${status} (iteration ${iter})`;
183
+ }
184
+
185
+ function updateUI(ctx: ExtensionContext): void {
186
+ if (!ctx.hasUI) return;
187
+
188
+ const state = currentLoop ? loadState(ctx, currentLoop) : null;
189
+ if (!state) {
190
+ ctx.ui.setStatus("ralph", undefined);
191
+ ctx.ui.setWidget("ralph", undefined);
192
+ return;
193
+ }
194
+
195
+ const { theme } = ctx.ui;
196
+ const maxStr = state.maxIterations > 0 ? `/${state.maxIterations}` : "";
197
+
198
+ ctx.ui.setStatus("ralph", theme.fg("accent", `🔄 ${state.name} (${state.iteration}${maxStr})`));
199
+
200
+ const lines = [
201
+ theme.fg("accent", theme.bold("Ralph Wiggum")),
202
+ theme.fg("muted", `Loop: ${state.name}`),
203
+ theme.fg("dim", `Status: ${STATUS_ICONS[state.status]} ${state.status}`),
204
+ theme.fg("dim", `Iteration: ${state.iteration}${maxStr}`),
205
+ theme.fg("dim", `Task: ${state.taskFile}`),
206
+ ];
207
+ if (state.reflectEvery > 0) {
208
+ const next = state.reflectEvery - ((state.iteration - 1) % state.reflectEvery);
209
+ lines.push(theme.fg("dim", `Next reflection in: ${next} iterations`));
210
+ }
211
+ // Warning about stopping
212
+ lines.push("");
213
+ lines.push(theme.fg("warning", "ESC pauses the assistant"));
214
+ lines.push(theme.fg("warning", "Send a message to resume; /ralph-stop ends the loop"));
215
+ ctx.ui.setWidget("ralph", lines);
216
+ }
217
+
218
+ // --- Prompt building ---
219
+
220
+ function buildPrompt(state: LoopState, taskContent: string, isReflection: boolean): string {
221
+ const maxStr = state.maxIterations > 0 ? `/${state.maxIterations}` : "";
222
+ const header = `───────────────────────────────────────────────────────────────────────
223
+ 🔄 RALPH LOOP: ${state.name} | Iteration ${state.iteration}${maxStr}${isReflection ? " | 🪞 REFLECTION" : ""}
224
+ ───────────────────────────────────────────────────────────────────────`;
225
+
226
+ const parts = [header, ""];
227
+ if (isReflection) parts.push(state.reflectInstructions, "\n---\n");
228
+
229
+ parts.push(`## Current Task (from ${state.taskFile})\n\n${taskContent}\n\n---`);
230
+ parts.push(`\n## Instructions\n`);
231
+ parts.push("User controls: ESC pauses the assistant. Send a message to resume. Run /ralph-stop when idle to stop the loop.\n");
232
+ parts.push(
233
+ `You are in a Ralph loop (iteration ${state.iteration}${state.maxIterations > 0 ? ` of ${state.maxIterations}` : ""}).\n`,
234
+ );
235
+
236
+ if (state.itemsPerIteration > 0) {
237
+ parts.push(`**THIS ITERATION: Process approximately ${state.itemsPerIteration} items, then call ralph_done.**\n`);
238
+ parts.push(`1. Work on the next ~${state.itemsPerIteration} items from your checklist`);
239
+ } else {
240
+ parts.push(`1. Continue working on the task`);
241
+ }
242
+ parts.push(`2. Update the task file (${state.taskFile}) with your progress`);
243
+ parts.push(`3. When FULLY COMPLETE, respond with: ${COMPLETE_MARKER}`);
244
+ parts.push(`4. Otherwise, call the ralph_done tool to proceed to next iteration`);
245
+
246
+ return parts.join("\n");
247
+ }
248
+
249
+ // --- Arg parsing ---
250
+
251
+ function parseArgs(argsStr: string) {
252
+ const tokens = argsStr.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
253
+ const result = {
254
+ name: "",
255
+ maxIterations: 50,
256
+ itemsPerIteration: 0,
257
+ reflectEvery: 0,
258
+ reflectInstructions: DEFAULT_REFLECT_INSTRUCTIONS,
259
+ };
260
+
261
+ for (let i = 0; i < tokens.length; i++) {
262
+ const tok = tokens[i];
263
+ const next = tokens[i + 1];
264
+ if (tok === "--max-iterations" && next) {
265
+ result.maxIterations = parseInt(next, 10) || 0;
266
+ i++;
267
+ } else if (tok === "--items-per-iteration" && next) {
268
+ result.itemsPerIteration = parseInt(next, 10) || 0;
269
+ i++;
270
+ } else if (tok === "--reflect-every" && next) {
271
+ result.reflectEvery = parseInt(next, 10) || 0;
272
+ i++;
273
+ } else if (tok === "--reflect-instructions" && next) {
274
+ result.reflectInstructions = next.replace(/^"|"$/g, "");
275
+ i++;
276
+ } else if (!tok.startsWith("--")) {
277
+ result.name = tok;
278
+ }
279
+ }
280
+ return result;
281
+ }
282
+
283
+ // --- Commands ---
284
+
285
+ const commands: Record<string, (rest: string, ctx: ExtensionContext) => void> = {
286
+ start(rest, ctx) {
287
+ const args = parseArgs(rest);
288
+ if (!args.name) {
289
+ ctx.ui.notify(
290
+ "Usage: /ralph start <name|path> [--items-per-iteration N] [--reflect-every N] [--max-iterations N]",
291
+ "warning",
292
+ );
293
+ return;
294
+ }
295
+
296
+ const isPath = args.name.includes("/") || args.name.includes("\\");
297
+ const loopName = isPath ? sanitize(path.basename(args.name, path.extname(args.name))) : args.name;
298
+ const taskFile = isPath ? args.name : path.join(RALPH_DIR, `${loopName}.md`);
299
+
300
+ const existing = loadState(ctx, loopName);
301
+ if (existing?.status === "active") {
302
+ ctx.ui.notify(`Loop "${loopName}" is already active. Use /ralph resume ${loopName}`, "warning");
303
+ return;
304
+ }
305
+
306
+ const fullPath = path.resolve(ctx.cwd, taskFile);
307
+ if (!fs.existsSync(fullPath)) {
308
+ ensureDir(fullPath);
309
+ fs.writeFileSync(fullPath, DEFAULT_TEMPLATE, "utf-8");
310
+ ctx.ui.notify(`Created task file: ${taskFile}`, "info");
311
+ }
312
+
313
+ const state: LoopState = {
314
+ name: loopName,
315
+ taskFile,
316
+ iteration: 1,
317
+ maxIterations: args.maxIterations,
318
+ itemsPerIteration: args.itemsPerIteration,
319
+ reflectEvery: args.reflectEvery,
320
+ reflectInstructions: args.reflectInstructions,
321
+ active: true,
322
+ status: "active",
323
+ startedAt: existing?.startedAt || new Date().toISOString(),
324
+ lastReflectionAt: 0,
325
+ };
326
+
327
+ saveState(ctx, state);
328
+ currentLoop = loopName;
329
+ updateUI(ctx);
330
+
331
+ const content = tryRead(fullPath);
332
+ if (!content) {
333
+ ctx.ui.notify(`Could not read task file: ${taskFile}`, "error");
334
+ return;
335
+ }
336
+ pi.sendUserMessage(buildPrompt(state, content, false));
337
+ },
338
+
339
+ stop(_rest, ctx) {
340
+ if (!currentLoop) {
341
+ // Check persisted state for any active loop
342
+ const active = listLoops(ctx).find((l) => l.status === "active");
343
+ if (active) {
344
+ pauseLoop(ctx, active, `Paused Ralph loop: ${active.name} (iteration ${active.iteration})`);
345
+ } else {
346
+ ctx.ui.notify("No active Ralph loop", "warning");
347
+ }
348
+ return;
349
+ }
350
+ const state = loadState(ctx, currentLoop);
351
+ if (state) {
352
+ pauseLoop(ctx, state, `Paused Ralph loop: ${currentLoop} (iteration ${state.iteration})`);
353
+ }
354
+ },
355
+
356
+ resume(rest, ctx) {
357
+ const loopName = rest.trim();
358
+ if (!loopName) {
359
+ ctx.ui.notify("Usage: /ralph resume <name>", "warning");
360
+ return;
361
+ }
362
+
363
+ const state = loadState(ctx, loopName);
364
+ if (!state) {
365
+ ctx.ui.notify(`Loop "${loopName}" not found`, "error");
366
+ return;
367
+ }
368
+ if (state.status === "completed") {
369
+ ctx.ui.notify(`Loop "${loopName}" is completed. Use /ralph start ${loopName} to restart`, "warning");
370
+ return;
371
+ }
372
+
373
+ // Pause current loop if different
374
+ if (currentLoop && currentLoop !== loopName) {
375
+ const curr = loadState(ctx, currentLoop);
376
+ if (curr) pauseLoop(ctx, curr);
377
+ }
378
+
379
+ state.status = "active";
380
+ state.active = true;
381
+ state.iteration++;
382
+ saveState(ctx, state);
383
+ currentLoop = loopName;
384
+ updateUI(ctx);
385
+
386
+ ctx.ui.notify(`Resumed: ${loopName} (iteration ${state.iteration})`, "info");
387
+
388
+ const content = tryRead(path.resolve(ctx.cwd, state.taskFile));
389
+ if (!content) {
390
+ ctx.ui.notify(`Could not read task file: ${state.taskFile}`, "error");
391
+ return;
392
+ }
393
+
394
+ const needsReflection =
395
+ state.reflectEvery > 0 && state.iteration > 1 && (state.iteration - 1) % state.reflectEvery === 0;
396
+ pi.sendUserMessage(buildPrompt(state, content, needsReflection));
397
+ },
398
+
399
+ status(_rest, ctx) {
400
+ const loops = listLoops(ctx);
401
+ if (loops.length === 0) {
402
+ ctx.ui.notify("No Ralph loops found.", "info");
403
+ return;
404
+ }
405
+ ctx.ui.notify(`Ralph loops:\n${loops.map((l) => formatLoop(l)).join("\n")}`, "info");
406
+ },
407
+
408
+ cancel(rest, ctx) {
409
+ const loopName = rest.trim();
410
+ if (!loopName) {
411
+ ctx.ui.notify("Usage: /ralph cancel <name>", "warning");
412
+ return;
413
+ }
414
+ if (!loadState(ctx, loopName)) {
415
+ ctx.ui.notify(`Loop "${loopName}" not found`, "error");
416
+ return;
417
+ }
418
+ if (currentLoop === loopName) currentLoop = null;
419
+ tryDelete(getPath(ctx, loopName, ".state.json"));
420
+ ctx.ui.notify(`Cancelled: ${loopName}`, "info");
421
+ updateUI(ctx);
422
+ },
423
+
424
+ archive(rest, ctx) {
425
+ const loopName = rest.trim();
426
+ if (!loopName) {
427
+ ctx.ui.notify("Usage: /ralph archive <name>", "warning");
428
+ return;
429
+ }
430
+ const state = loadState(ctx, loopName);
431
+ if (!state) {
432
+ ctx.ui.notify(`Loop "${loopName}" not found`, "error");
433
+ return;
434
+ }
435
+ if (state.status === "active") {
436
+ ctx.ui.notify("Cannot archive active loop. Stop it first.", "warning");
437
+ return;
438
+ }
439
+
440
+ if (currentLoop === loopName) currentLoop = null;
441
+
442
+ const srcState = getPath(ctx, loopName, ".state.json");
443
+ const dstState = getPath(ctx, loopName, ".state.json", true);
444
+ ensureDir(dstState);
445
+ if (fs.existsSync(srcState)) fs.renameSync(srcState, dstState);
446
+
447
+ const srcTask = path.resolve(ctx.cwd, state.taskFile);
448
+ if (srcTask.startsWith(ralphDir(ctx)) && !srcTask.startsWith(archiveDir(ctx))) {
449
+ const dstTask = getPath(ctx, loopName, ".md", true);
450
+ if (fs.existsSync(srcTask)) fs.renameSync(srcTask, dstTask);
451
+ }
452
+
453
+ ctx.ui.notify(`Archived: ${loopName}`, "info");
454
+ updateUI(ctx);
455
+ },
456
+
457
+ clean(rest, ctx) {
458
+ const all = rest.trim() === "--all";
459
+ const completed = listLoops(ctx).filter((l) => l.status === "completed");
460
+
461
+ if (completed.length === 0) {
462
+ ctx.ui.notify("No completed loops to clean", "info");
463
+ return;
464
+ }
465
+
466
+ for (const loop of completed) {
467
+ tryDelete(getPath(ctx, loop.name, ".state.json"));
468
+ if (all) tryDelete(getPath(ctx, loop.name, ".md"));
469
+ if (currentLoop === loop.name) currentLoop = null;
470
+ }
471
+
472
+ const suffix = all ? " (all files)" : " (state only)";
473
+ ctx.ui.notify(
474
+ `Cleaned ${completed.length} loop(s)${suffix}:\n${completed.map((l) => ` • ${l.name}`).join("\n")}`,
475
+ "info",
476
+ );
477
+ updateUI(ctx);
478
+ },
479
+
480
+ list(rest, ctx) {
481
+ const archived = rest.trim() === "--archived";
482
+ const loops = listLoops(ctx, archived);
483
+
484
+ if (loops.length === 0) {
485
+ ctx.ui.notify(
486
+ archived ? "No archived loops" : "No loops found. Use /ralph list --archived for archived.",
487
+ "info",
488
+ );
489
+ return;
490
+ }
491
+
492
+ const label = archived ? "Archived loops" : "Ralph loops";
493
+ ctx.ui.notify(`${label}:\n${loops.map((l) => formatLoop(l)).join("\n")}`, "info");
494
+ },
495
+
496
+ nuke(rest, ctx) {
497
+ const force = rest.trim() === "--yes";
498
+ const warning =
499
+ "This deletes all .ralph state, task, and archive files. External task files are not removed.";
500
+
501
+ const run = () => {
502
+ const dir = ralphDir(ctx);
503
+ if (!fs.existsSync(dir)) {
504
+ if (ctx.hasUI) ctx.ui.notify("No .ralph directory found.", "info");
505
+ return;
506
+ }
507
+
508
+ currentLoop = null;
509
+ const ok = tryRemoveDir(dir);
510
+ if (ctx.hasUI) {
511
+ ctx.ui.notify(ok ? "Removed .ralph directory." : "Failed to remove .ralph directory.", ok ? "info" : "error");
512
+ }
513
+ updateUI(ctx);
514
+ };
515
+
516
+ if (!force) {
517
+ if (ctx.hasUI) {
518
+ void ctx.ui.confirm("Delete all Ralph loop files?", warning).then((confirmed) => {
519
+ if (confirmed) run();
520
+ });
521
+ } else {
522
+ ctx.ui.notify(`Run /ralph nuke --yes to confirm. ${warning}`, "warning");
523
+ }
524
+ return;
525
+ }
526
+
527
+ if (ctx.hasUI) ctx.ui.notify(warning, "warning");
528
+ run();
529
+ },
530
+ };
531
+
532
+ const HELP = `Ralph Wiggum - Long-running development loops
533
+
534
+ Commands:
535
+ /ralph start <name|path> [options] Start a new loop
536
+ /ralph stop Pause current loop
537
+ /ralph resume <name> Resume a paused loop
538
+ /ralph status Show all loops
539
+ /ralph cancel <name> Delete loop state
540
+ /ralph archive <name> Move loop to archive
541
+ /ralph clean [--all] Clean completed loops
542
+ /ralph list --archived Show archived loops
543
+ /ralph nuke [--yes] Delete all .ralph data
544
+ /ralph-stop Stop active loop (idle only)
545
+
546
+ Options:
547
+ --items-per-iteration N Suggest N items per turn (prompt hint)
548
+ --reflect-every N Reflect every N iterations
549
+ --max-iterations N Stop after N iterations (default 50)
550
+
551
+ To stop: press ESC to interrupt, then run /ralph-stop when idle
552
+
553
+ Examples:
554
+ /ralph start my-feature
555
+ /ralph start review --items-per-iteration 5 --reflect-every 10`;
556
+
557
+ pi.registerCommand("ralph", {
558
+ description: "Ralph Wiggum - long-running development loops",
559
+ handler: async (args, ctx) => {
560
+ const [cmd] = args.trim().split(/\s+/);
561
+ const handler = commands[cmd];
562
+ if (handler) {
563
+ handler(args.slice(cmd.length).trim(), ctx);
564
+ } else {
565
+ ctx.ui.notify(HELP, "info");
566
+ }
567
+ },
568
+ });
569
+
570
+ pi.registerCommand("ralph-stop", {
571
+ description: "Stop active Ralph loop (idle only)",
572
+ handler: async (_args, ctx) => {
573
+ if (!ctx.isIdle()) {
574
+ if (ctx.hasUI) {
575
+ ctx.ui.notify("Agent is busy. Press ESC to interrupt, then run /ralph-stop.", "warning");
576
+ }
577
+ return;
578
+ }
579
+
580
+ let state = currentLoop ? loadState(ctx, currentLoop) : null;
581
+ if (!state) {
582
+ const active = listLoops(ctx).find((l) => l.status === "active");
583
+ if (!active) {
584
+ if (ctx.hasUI) ctx.ui.notify("No active Ralph loop", "warning");
585
+ return;
586
+ }
587
+ state = active;
588
+ }
589
+
590
+ if (state.status !== "active") {
591
+ if (ctx.hasUI) ctx.ui.notify(`Loop "${state.name}" is not active`, "warning");
592
+ return;
593
+ }
594
+
595
+ stopLoop(ctx, state, `Stopped Ralph loop: ${state.name} (iteration ${state.iteration})`);
596
+ },
597
+ });
598
+
599
+ // --- Tool for agent self-invocation ---
600
+
601
+ pi.registerTool({
602
+ name: "ralph_start",
603
+ label: "Start Ralph Loop",
604
+ description: "Start a long-running development loop. Use for complex multi-iteration tasks.",
605
+ parameters: Type.Object({
606
+ name: Type.String({ description: "Loop name (e.g., 'refactor-auth')" }),
607
+ taskContent: Type.String({ description: "Task in markdown with goals and checklist" }),
608
+ itemsPerIteration: Type.Optional(Type.Number({ description: "Suggest N items per turn (0 = no limit)" })),
609
+ reflectEvery: Type.Optional(Type.Number({ description: "Reflect every N iterations" })),
610
+ maxIterations: Type.Optional(Type.Number({ description: "Max iterations (default: 50)", default: 50 })),
611
+ }),
612
+ async execute(_toolCallId, params, _onUpdate, ctx) {
613
+ const loopName = sanitize(params.name);
614
+ const taskFile = path.join(RALPH_DIR, `${loopName}.md`);
615
+
616
+ if (loadState(ctx, loopName)?.status === "active") {
617
+ return { content: [{ type: "text", text: `Loop "${loopName}" already active.` }], details: {} };
618
+ }
619
+
620
+ const fullPath = path.resolve(ctx.cwd, taskFile);
621
+ ensureDir(fullPath);
622
+ fs.writeFileSync(fullPath, params.taskContent, "utf-8");
623
+
624
+ const state: LoopState = {
625
+ name: loopName,
626
+ taskFile,
627
+ iteration: 1,
628
+ maxIterations: params.maxIterations ?? 50,
629
+ itemsPerIteration: params.itemsPerIteration ?? 0,
630
+ reflectEvery: params.reflectEvery ?? 0,
631
+ reflectInstructions: DEFAULT_REFLECT_INSTRUCTIONS,
632
+ active: true,
633
+ status: "active",
634
+ startedAt: new Date().toISOString(),
635
+ lastReflectionAt: 0,
636
+ };
637
+
638
+ saveState(ctx, state);
639
+ currentLoop = loopName;
640
+ updateUI(ctx);
641
+
642
+ pi.sendUserMessage(buildPrompt(state, params.taskContent, false), { deliverAs: "followUp" });
643
+
644
+ return {
645
+ content: [{ type: "text", text: `Started loop "${loopName}" (max ${state.maxIterations} iterations).` }],
646
+ details: {},
647
+ };
648
+ },
649
+ });
650
+
651
+ // Tool for agent to signal iteration complete and request next
652
+ pi.registerTool({
653
+ name: "ralph_done",
654
+ label: "Ralph Iteration Done",
655
+ description: "Signal that you've completed this iteration of the Ralph loop. Call this after making progress to get the next iteration prompt. Do NOT call this if you've output the completion marker.",
656
+ parameters: Type.Object({}),
657
+ async execute(_toolCallId, _params, _onUpdate, ctx) {
658
+ if (!currentLoop) {
659
+ return { content: [{ type: "text", text: "No active Ralph loop." }], details: {} };
660
+ }
661
+
662
+ const state = loadState(ctx, currentLoop);
663
+ if (!state || state.status !== "active") {
664
+ return { content: [{ type: "text", text: "Ralph loop is not active." }], details: {} };
665
+ }
666
+
667
+ if (ctx.hasPendingMessages()) {
668
+ return {
669
+ content: [{ type: "text", text: "Pending messages already queued. Skipping ralph_done." }],
670
+ details: {},
671
+ };
672
+ }
673
+
674
+ // Increment iteration
675
+ state.iteration++;
676
+
677
+ // Check max iterations
678
+ if (state.maxIterations > 0 && state.iteration > state.maxIterations) {
679
+ completeLoop(
680
+ ctx,
681
+ state,
682
+ `───────────────────────────────────────────────────────────────────────
683
+ ⚠️ RALPH LOOP STOPPED: ${state.name} | Max iterations (${state.maxIterations}) reached
684
+ ───────────────────────────────────────────────────────────────────────`,
685
+ );
686
+ return { content: [{ type: "text", text: "Max iterations reached. Loop stopped." }], details: {} };
687
+ }
688
+
689
+ const needsReflection = state.reflectEvery > 0 && (state.iteration - 1) % state.reflectEvery === 0;
690
+ if (needsReflection) state.lastReflectionAt = state.iteration;
691
+
692
+ saveState(ctx, state);
693
+ updateUI(ctx);
694
+
695
+ const content = tryRead(path.resolve(ctx.cwd, state.taskFile));
696
+ if (!content) {
697
+ pauseLoop(ctx, state);
698
+ return { content: [{ type: "text", text: `Error: Could not read task file: ${state.taskFile}` }], details: {} };
699
+ }
700
+
701
+ // Queue next iteration - use followUp so user can still interrupt
702
+ pi.sendUserMessage(buildPrompt(state, content, needsReflection), { deliverAs: "followUp" });
703
+
704
+ return {
705
+ content: [{ type: "text", text: `Iteration ${state.iteration - 1} complete. Next iteration queued.` }],
706
+ details: {},
707
+ };
708
+ },
709
+ });
710
+
711
+ // --- Event handlers ---
712
+
713
+ pi.on("before_agent_start", async (event, ctx) => {
714
+ if (!currentLoop) return;
715
+ const state = loadState(ctx, currentLoop);
716
+ if (!state || state.status !== "active") return;
717
+
718
+ const iterStr = `${state.iteration}${state.maxIterations > 0 ? `/${state.maxIterations}` : ""}`;
719
+
720
+ let instructions = `You are in a Ralph loop working on: ${state.taskFile}\n`;
721
+ if (state.itemsPerIteration > 0) {
722
+ instructions += `- Work on ~${state.itemsPerIteration} items this iteration\n`;
723
+ }
724
+ instructions += `- Update the task file as you progress\n`;
725
+ instructions += `- When FULLY COMPLETE: ${COMPLETE_MARKER}\n`;
726
+ instructions += `- Otherwise, call ralph_done tool to proceed to next iteration`;
727
+
728
+ return {
729
+ systemPromptAppend: `\n[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`,
730
+ };
731
+ });
732
+
733
+ pi.on("agent_end", async (event, ctx) => {
734
+ if (!currentLoop) return;
735
+ const state = loadState(ctx, currentLoop);
736
+ if (!state || state.status !== "active") return;
737
+
738
+ // Check for completion marker
739
+ const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant");
740
+ const text =
741
+ lastAssistant && Array.isArray(lastAssistant.content)
742
+ ? lastAssistant.content
743
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
744
+ .map((c) => c.text)
745
+ .join("\n")
746
+ : "";
747
+
748
+ if (text.includes(COMPLETE_MARKER)) {
749
+ completeLoop(
750
+ ctx,
751
+ state,
752
+ `───────────────────────────────────────────────────────────────────────
753
+ ✅ RALPH LOOP COMPLETE: ${state.name} | ${state.iteration} iterations
754
+ ───────────────────────────────────────────────────────────────────────`,
755
+ );
756
+ return;
757
+ }
758
+
759
+ // Check max iterations
760
+ if (state.maxIterations > 0 && state.iteration >= state.maxIterations) {
761
+ completeLoop(
762
+ ctx,
763
+ state,
764
+ `───────────────────────────────────────────────────────────────────────
765
+ ⚠️ RALPH LOOP STOPPED: ${state.name} | Max iterations (${state.maxIterations}) reached
766
+ ───────────────────────────────────────────────────────────────────────`,
767
+ );
768
+ return;
769
+ }
770
+
771
+ // Don't auto-continue - let the agent call ralph_done to proceed
772
+ // This allows user's "stop" message to be processed first
773
+ });
774
+
775
+ pi.on("session_start", async (_event, ctx) => {
776
+ const active = listLoops(ctx).filter((l) => l.status === "active");
777
+ if (active.length > 0 && ctx.hasUI) {
778
+ const lines = active.map(
779
+ (l) => ` • ${l.name} (iteration ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""})`,
780
+ );
781
+ ctx.ui.notify(`Active Ralph loops:\n${lines.join("\n")}\n\nUse /ralph resume <name> to continue`, "info");
782
+ }
783
+ updateUI(ctx);
784
+ });
785
+
786
+ pi.on("session_shutdown", async (_event, ctx) => {
787
+ if (currentLoop) {
788
+ const state = loadState(ctx, currentLoop);
789
+ if (state) saveState(ctx, state);
790
+ }
791
+ });
792
+ }