@pi-agents/orchid 0.1.0-beta.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.
Files changed (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,825 @@
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 "@earendil-works/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 safeMtimeMs(filePath: string): number {
96
+ try {
97
+ return fs.statSync(filePath).mtimeMs;
98
+ } catch {
99
+ return 0;
100
+ }
101
+ }
102
+
103
+ function tryRemoveDir(dirPath: string): boolean {
104
+ try {
105
+ if (fs.existsSync(dirPath)) {
106
+ fs.rmSync(dirPath, { recursive: true, force: true });
107
+ }
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ // --- State management ---
115
+
116
+ function migrateState(raw: Partial<LoopState> & { name: string }): LoopState {
117
+ if (!raw.status) raw.status = raw.active ? "active" : "paused";
118
+ raw.active = raw.status === "active";
119
+ // Migrate old field names
120
+ if ("reflectEveryItems" in raw && !raw.reflectEvery) {
121
+ raw.reflectEvery = (raw as any).reflectEveryItems;
122
+ }
123
+ if ("lastReflectionAtItems" in raw && raw.lastReflectionAt === undefined) {
124
+ raw.lastReflectionAt = (raw as any).lastReflectionAtItems;
125
+ }
126
+ return raw as LoopState;
127
+ }
128
+
129
+ function loadState(ctx: ExtensionContext, name: string, archived = false): LoopState | null {
130
+ const content = tryRead(getPath(ctx, name, ".state.json", archived));
131
+ return content ? migrateState(JSON.parse(content)) : null;
132
+ }
133
+
134
+ function saveState(ctx: ExtensionContext, state: LoopState, archived = false): void {
135
+ state.active = state.status === "active";
136
+ const filePath = getPath(ctx, state.name, ".state.json", archived);
137
+ ensureDir(filePath);
138
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8");
139
+ }
140
+
141
+ function listLoops(ctx: ExtensionContext, archived = false): LoopState[] {
142
+ const dir = archived ? archiveDir(ctx) : ralphDir(ctx);
143
+ if (!fs.existsSync(dir)) return [];
144
+ return fs
145
+ .readdirSync(dir)
146
+ .filter((f) => f.endsWith(".state.json"))
147
+ .map((f) => {
148
+ const content = tryRead(path.join(dir, f));
149
+ return content ? migrateState(JSON.parse(content)) : null;
150
+ })
151
+ .filter((s): s is LoopState => s !== null);
152
+ }
153
+
154
+ // --- Loop state transitions ---
155
+
156
+ function pauseLoop(ctx: ExtensionContext, state: LoopState, message?: string): void {
157
+ state.status = "paused";
158
+ state.active = false;
159
+ saveState(ctx, state);
160
+ currentLoop = null;
161
+ updateUI(ctx);
162
+ if (message && ctx.hasUI) ctx.ui.notify(message, "info");
163
+ }
164
+
165
+ function completeLoop(ctx: ExtensionContext, state: LoopState, banner: string): void {
166
+ state.status = "completed";
167
+ state.completedAt = new Date().toISOString();
168
+ state.active = false;
169
+ saveState(ctx, state);
170
+ currentLoop = null;
171
+ updateUI(ctx);
172
+ pi.sendUserMessage(banner);
173
+ }
174
+
175
+ function stopLoop(ctx: ExtensionContext, state: LoopState, message?: string): void {
176
+ state.status = "completed";
177
+ state.completedAt = new Date().toISOString();
178
+ state.active = false;
179
+ saveState(ctx, state);
180
+ currentLoop = null;
181
+ updateUI(ctx);
182
+ if (message && ctx.hasUI) ctx.ui.notify(message, "info");
183
+ }
184
+
185
+ // --- UI ---
186
+
187
+ function formatLoop(l: LoopState): string {
188
+ const status = `${STATUS_ICONS[l.status]} ${l.status}`;
189
+ const iter = l.maxIterations > 0 ? `${l.iteration}/${l.maxIterations}` : `${l.iteration}`;
190
+ return `${l.name}: ${status} (iteration ${iter})`;
191
+ }
192
+
193
+ function updateUI(ctx: ExtensionContext): void {
194
+ if (!ctx.hasUI) return;
195
+
196
+ const state = currentLoop ? loadState(ctx, currentLoop) : null;
197
+ if (!state) {
198
+ ctx.ui.setStatus("ralph", undefined);
199
+ ctx.ui.setWidget("ralph", undefined);
200
+ return;
201
+ }
202
+
203
+ const { theme } = ctx.ui;
204
+ const maxStr = state.maxIterations > 0 ? `/${state.maxIterations}` : "";
205
+
206
+ ctx.ui.setStatus("ralph", theme.fg("accent", `🔄 ${state.name} (${state.iteration}${maxStr})`));
207
+
208
+ const lines = [
209
+ theme.fg("accent", theme.bold("Ralph Wiggum")),
210
+ theme.fg("muted", `Loop: ${state.name}`),
211
+ theme.fg("dim", `Status: ${STATUS_ICONS[state.status]} ${state.status}`),
212
+ theme.fg("dim", `Iteration: ${state.iteration}${maxStr}`),
213
+ theme.fg("dim", `Task: ${state.taskFile}`),
214
+ ];
215
+ if (state.reflectEvery > 0) {
216
+ const next = state.reflectEvery - ((state.iteration - 1) % state.reflectEvery);
217
+ lines.push(theme.fg("dim", `Next reflection in: ${next} iterations`));
218
+ }
219
+ // Warning about stopping
220
+ lines.push("");
221
+ lines.push(theme.fg("warning", "ESC pauses the assistant"));
222
+ lines.push(theme.fg("warning", "Send a message to resume; /ralph-stop ends the loop"));
223
+ ctx.ui.setWidget("ralph", lines);
224
+ }
225
+
226
+ // --- Prompt building ---
227
+
228
+ function buildPrompt(state: LoopState, taskContent: string, isReflection: boolean): string {
229
+ const maxStr = state.maxIterations > 0 ? `/${state.maxIterations}` : "";
230
+ const header = `───────────────────────────────────────────────────────────────────────
231
+ 🔄 RALPH LOOP: ${state.name} | Iteration ${state.iteration}${maxStr}${isReflection ? " | 🪞 REFLECTION" : ""}
232
+ ───────────────────────────────────────────────────────────────────────`;
233
+
234
+ const parts = [header, ""];
235
+ if (isReflection) parts.push(state.reflectInstructions, "\n---\n");
236
+
237
+ parts.push(`## Current Task (from ${state.taskFile})\n\n${taskContent}\n\n---`);
238
+ parts.push(`\n## Instructions\n`);
239
+ parts.push("User controls: ESC pauses the assistant. Send a message to resume. Run /ralph-stop when idle to stop the loop.\n");
240
+ parts.push(
241
+ `You are in a Ralph loop (iteration ${state.iteration}${state.maxIterations > 0 ? ` of ${state.maxIterations}` : ""}).\n`,
242
+ );
243
+
244
+ if (state.itemsPerIteration > 0) {
245
+ parts.push(`**THIS ITERATION: Process approximately ${state.itemsPerIteration} items, then call ralph_done.**\n`);
246
+ parts.push(`1. Work on the next ~${state.itemsPerIteration} items from your checklist`);
247
+ } else {
248
+ parts.push(`1. Continue working on the task`);
249
+ }
250
+ parts.push(`2. Update the task file (${state.taskFile}) with your progress`);
251
+ parts.push(`3. When FULLY COMPLETE, respond with: ${COMPLETE_MARKER}`);
252
+ parts.push(`4. Otherwise, call the ralph_done tool to proceed to next iteration`);
253
+
254
+ return parts.join("\n");
255
+ }
256
+
257
+ // --- Arg parsing ---
258
+
259
+ function parseArgs(argsStr: string) {
260
+ const tokens = argsStr.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
261
+ const result = {
262
+ name: "",
263
+ maxIterations: 50,
264
+ itemsPerIteration: 0,
265
+ reflectEvery: 0,
266
+ reflectInstructions: DEFAULT_REFLECT_INSTRUCTIONS,
267
+ };
268
+
269
+ for (let i = 0; i < tokens.length; i++) {
270
+ const tok = tokens[i];
271
+ const next = tokens[i + 1];
272
+ if (tok === "--max-iterations" && next) {
273
+ result.maxIterations = parseInt(next, 10) || 0;
274
+ i++;
275
+ } else if (tok === "--items-per-iteration" && next) {
276
+ result.itemsPerIteration = parseInt(next, 10) || 0;
277
+ i++;
278
+ } else if (tok === "--reflect-every" && next) {
279
+ result.reflectEvery = parseInt(next, 10) || 0;
280
+ i++;
281
+ } else if (tok === "--reflect-instructions" && next) {
282
+ result.reflectInstructions = next.replace(/^"|"$/g, "");
283
+ i++;
284
+ } else if (!tok.startsWith("--")) {
285
+ result.name = tok;
286
+ }
287
+ }
288
+ return result;
289
+ }
290
+
291
+ // --- Commands ---
292
+
293
+ const commands: Record<string, (rest: string, ctx: ExtensionContext) => void> = {
294
+ start(rest, ctx) {
295
+ const args = parseArgs(rest);
296
+ if (!args.name) {
297
+ ctx.ui.notify(
298
+ "Usage: /ralph start <name|path> [--items-per-iteration N] [--reflect-every N] [--max-iterations N]",
299
+ "warning",
300
+ );
301
+ return;
302
+ }
303
+
304
+ const isPath = args.name.includes("/") || args.name.includes("\\");
305
+ const loopName = isPath ? sanitize(path.basename(args.name, path.extname(args.name))) : args.name;
306
+ const taskFile = isPath ? args.name : path.join(RALPH_DIR, `${loopName}.md`);
307
+
308
+ const existing = loadState(ctx, loopName);
309
+ if (existing?.status === "active") {
310
+ ctx.ui.notify(`Loop "${loopName}" is already active. Use /ralph resume ${loopName}`, "warning");
311
+ return;
312
+ }
313
+
314
+ const fullPath = path.resolve(ctx.cwd, taskFile);
315
+ if (!fs.existsSync(fullPath)) {
316
+ ensureDir(fullPath);
317
+ fs.writeFileSync(fullPath, DEFAULT_TEMPLATE, "utf-8");
318
+ ctx.ui.notify(`Created task file: ${taskFile}`, "info");
319
+ }
320
+
321
+ const state: LoopState = {
322
+ name: loopName,
323
+ taskFile,
324
+ iteration: 1,
325
+ maxIterations: args.maxIterations,
326
+ itemsPerIteration: args.itemsPerIteration,
327
+ reflectEvery: args.reflectEvery,
328
+ reflectInstructions: args.reflectInstructions,
329
+ active: true,
330
+ status: "active",
331
+ startedAt: existing?.startedAt || new Date().toISOString(),
332
+ lastReflectionAt: 0,
333
+ };
334
+
335
+ saveState(ctx, state);
336
+ currentLoop = loopName;
337
+ updateUI(ctx);
338
+
339
+ const content = tryRead(fullPath);
340
+ if (!content) {
341
+ ctx.ui.notify(`Could not read task file: ${taskFile}`, "error");
342
+ return;
343
+ }
344
+ pi.sendUserMessage(buildPrompt(state, content, false));
345
+ },
346
+
347
+ stop(_rest, ctx) {
348
+ if (!currentLoop) {
349
+ // Check persisted state for any active loop
350
+ const active = listLoops(ctx).find((l) => l.status === "active");
351
+ if (active) {
352
+ pauseLoop(ctx, active, `Paused Ralph loop: ${active.name} (iteration ${active.iteration})`);
353
+ } else {
354
+ ctx.ui.notify("No active Ralph loop", "warning");
355
+ }
356
+ return;
357
+ }
358
+ const state = loadState(ctx, currentLoop);
359
+ if (state) {
360
+ pauseLoop(ctx, state, `Paused Ralph loop: ${currentLoop} (iteration ${state.iteration})`);
361
+ }
362
+ },
363
+
364
+ resume(rest, ctx) {
365
+ const loopName = rest.trim();
366
+ if (!loopName) {
367
+ ctx.ui.notify("Usage: /ralph resume <name>", "warning");
368
+ return;
369
+ }
370
+
371
+ const state = loadState(ctx, loopName);
372
+ if (!state) {
373
+ ctx.ui.notify(`Loop "${loopName}" not found`, "error");
374
+ return;
375
+ }
376
+ if (state.status === "completed") {
377
+ ctx.ui.notify(`Loop "${loopName}" is completed. Use /ralph start ${loopName} to restart`, "warning");
378
+ return;
379
+ }
380
+
381
+ // Pause current loop if different
382
+ if (currentLoop && currentLoop !== loopName) {
383
+ const curr = loadState(ctx, currentLoop);
384
+ if (curr) pauseLoop(ctx, curr);
385
+ }
386
+
387
+ state.status = "active";
388
+ state.active = true;
389
+ state.iteration++;
390
+ saveState(ctx, state);
391
+ currentLoop = loopName;
392
+ updateUI(ctx);
393
+
394
+ ctx.ui.notify(`Resumed: ${loopName} (iteration ${state.iteration})`, "info");
395
+
396
+ const content = tryRead(path.resolve(ctx.cwd, state.taskFile));
397
+ if (!content) {
398
+ ctx.ui.notify(`Could not read task file: ${state.taskFile}`, "error");
399
+ return;
400
+ }
401
+
402
+ const needsReflection =
403
+ state.reflectEvery > 0 && state.iteration > 1 && (state.iteration - 1) % state.reflectEvery === 0;
404
+ pi.sendUserMessage(buildPrompt(state, content, needsReflection));
405
+ },
406
+
407
+ status(_rest, ctx) {
408
+ const loops = listLoops(ctx);
409
+ if (loops.length === 0) {
410
+ ctx.ui.notify("No Ralph loops found.", "info");
411
+ return;
412
+ }
413
+ ctx.ui.notify(`Ralph loops:\n${loops.map((l) => formatLoop(l)).join("\n")}`, "info");
414
+ },
415
+
416
+ cancel(rest, ctx) {
417
+ const loopName = rest.trim();
418
+ if (!loopName) {
419
+ ctx.ui.notify("Usage: /ralph cancel <name>", "warning");
420
+ return;
421
+ }
422
+ if (!loadState(ctx, loopName)) {
423
+ ctx.ui.notify(`Loop "${loopName}" not found`, "error");
424
+ return;
425
+ }
426
+ if (currentLoop === loopName) currentLoop = null;
427
+ tryDelete(getPath(ctx, loopName, ".state.json"));
428
+ ctx.ui.notify(`Cancelled: ${loopName}`, "info");
429
+ updateUI(ctx);
430
+ },
431
+
432
+ archive(rest, ctx) {
433
+ const loopName = rest.trim();
434
+ if (!loopName) {
435
+ ctx.ui.notify("Usage: /ralph archive <name>", "warning");
436
+ return;
437
+ }
438
+ const state = loadState(ctx, loopName);
439
+ if (!state) {
440
+ ctx.ui.notify(`Loop "${loopName}" not found`, "error");
441
+ return;
442
+ }
443
+ if (state.status === "active") {
444
+ ctx.ui.notify("Cannot archive active loop. Stop it first.", "warning");
445
+ return;
446
+ }
447
+
448
+ if (currentLoop === loopName) currentLoop = null;
449
+
450
+ const srcState = getPath(ctx, loopName, ".state.json");
451
+ const dstState = getPath(ctx, loopName, ".state.json", true);
452
+ ensureDir(dstState);
453
+ if (fs.existsSync(srcState)) fs.renameSync(srcState, dstState);
454
+
455
+ const srcTask = path.resolve(ctx.cwd, state.taskFile);
456
+ if (srcTask.startsWith(ralphDir(ctx)) && !srcTask.startsWith(archiveDir(ctx))) {
457
+ const dstTask = getPath(ctx, loopName, ".md", true);
458
+ if (fs.existsSync(srcTask)) fs.renameSync(srcTask, dstTask);
459
+ }
460
+
461
+ ctx.ui.notify(`Archived: ${loopName}`, "info");
462
+ updateUI(ctx);
463
+ },
464
+
465
+ clean(rest, ctx) {
466
+ const all = rest.trim() === "--all";
467
+ const completed = listLoops(ctx).filter((l) => l.status === "completed");
468
+
469
+ if (completed.length === 0) {
470
+ ctx.ui.notify("No completed loops to clean", "info");
471
+ return;
472
+ }
473
+
474
+ for (const loop of completed) {
475
+ tryDelete(getPath(ctx, loop.name, ".state.json"));
476
+ if (all) tryDelete(getPath(ctx, loop.name, ".md"));
477
+ if (currentLoop === loop.name) currentLoop = null;
478
+ }
479
+
480
+ const suffix = all ? " (all files)" : " (state only)";
481
+ ctx.ui.notify(
482
+ `Cleaned ${completed.length} loop(s)${suffix}:\n${completed.map((l) => ` • ${l.name}`).join("\n")}`,
483
+ "info",
484
+ );
485
+ updateUI(ctx);
486
+ },
487
+
488
+ list(rest, ctx) {
489
+ const archived = rest.trim() === "--archived";
490
+ const loops = listLoops(ctx, archived);
491
+
492
+ if (loops.length === 0) {
493
+ ctx.ui.notify(
494
+ archived ? "No archived loops" : "No loops found. Use /ralph list --archived for archived.",
495
+ "info",
496
+ );
497
+ return;
498
+ }
499
+
500
+ const label = archived ? "Archived loops" : "Ralph loops";
501
+ ctx.ui.notify(`${label}:\n${loops.map((l) => formatLoop(l)).join("\n")}`, "info");
502
+ },
503
+
504
+ nuke(rest, ctx) {
505
+ const force = rest.trim() === "--yes";
506
+ const warning =
507
+ "This deletes all .ralph state, task, and archive files. External task files are not removed.";
508
+
509
+ const run = () => {
510
+ const dir = ralphDir(ctx);
511
+ if (!fs.existsSync(dir)) {
512
+ if (ctx.hasUI) ctx.ui.notify("No .ralph directory found.", "info");
513
+ return;
514
+ }
515
+
516
+ currentLoop = null;
517
+ const ok = tryRemoveDir(dir);
518
+ if (ctx.hasUI) {
519
+ ctx.ui.notify(ok ? "Removed .ralph directory." : "Failed to remove .ralph directory.", ok ? "info" : "error");
520
+ }
521
+ updateUI(ctx);
522
+ };
523
+
524
+ if (!force) {
525
+ if (ctx.hasUI) {
526
+ void ctx.ui.confirm("Delete all Ralph loop files?", warning).then((confirmed) => {
527
+ if (confirmed) run();
528
+ });
529
+ } else {
530
+ ctx.ui.notify(`Run /ralph nuke --yes to confirm. ${warning}`, "warning");
531
+ }
532
+ return;
533
+ }
534
+
535
+ if (ctx.hasUI) ctx.ui.notify(warning, "warning");
536
+ run();
537
+ },
538
+ };
539
+
540
+ const HELP = `Ralph Wiggum - Long-running development loops
541
+
542
+ Commands:
543
+ /ralph start <name|path> [options] Start a new loop
544
+ /ralph stop Pause current loop
545
+ /ralph resume <name> Resume a paused loop
546
+ /ralph status Show all loops
547
+ /ralph cancel <name> Delete loop state
548
+ /ralph archive <name> Move loop to archive
549
+ /ralph clean [--all] Clean completed loops
550
+ /ralph list --archived Show archived loops
551
+ /ralph nuke [--yes] Delete all .ralph data
552
+ /ralph-stop Stop active loop (idle only)
553
+
554
+ Options:
555
+ --items-per-iteration N Suggest N items per turn (prompt hint)
556
+ --reflect-every N Reflect every N iterations
557
+ --max-iterations N Stop after N iterations (default 50)
558
+
559
+ To stop: press ESC to interrupt, then run /ralph-stop when idle
560
+
561
+ Examples:
562
+ /ralph start my-feature
563
+ /ralph start review --items-per-iteration 5 --reflect-every 10`;
564
+
565
+ pi.registerCommand("ralph", {
566
+ description: "Ralph Wiggum - long-running development loops",
567
+ handler: async (args, ctx) => {
568
+ const [cmd] = args.trim().split(/\s+/);
569
+ const handler = commands[cmd];
570
+ if (handler) {
571
+ handler(args.slice(cmd.length).trim(), ctx);
572
+ } else {
573
+ ctx.ui.notify(HELP, "info");
574
+ }
575
+ },
576
+ });
577
+
578
+ pi.registerCommand("ralph-stop", {
579
+ description: "Stop active Ralph loop (idle only)",
580
+ handler: async (_args, ctx) => {
581
+ if (!ctx.isIdle()) {
582
+ if (ctx.hasUI) {
583
+ ctx.ui.notify("Agent is busy. Press ESC to interrupt, then run /ralph-stop.", "warning");
584
+ }
585
+ return;
586
+ }
587
+
588
+ let state = currentLoop ? loadState(ctx, currentLoop) : null;
589
+ if (!state) {
590
+ const active = listLoops(ctx).find((l) => l.status === "active");
591
+ if (!active) {
592
+ if (ctx.hasUI) ctx.ui.notify("No active Ralph loop", "warning");
593
+ return;
594
+ }
595
+ state = active;
596
+ }
597
+
598
+ if (state.status !== "active") {
599
+ if (ctx.hasUI) ctx.ui.notify(`Loop "${state.name}" is not active`, "warning");
600
+ return;
601
+ }
602
+
603
+ stopLoop(ctx, state, `Stopped Ralph loop: ${state.name} (iteration ${state.iteration})`);
604
+ },
605
+ });
606
+
607
+ // --- Tool for agent self-invocation ---
608
+
609
+ pi.registerTool({
610
+ name: "ralph_start",
611
+ label: "Start Ralph Loop",
612
+ description: "Start a long-running development loop. Use for complex multi-iteration tasks.",
613
+ promptSnippet: "Start a persistent multi-iteration development loop with pacing and reflection controls.",
614
+ promptGuidelines: [
615
+ "Use this tool when the user explicitly wants an iterative loop, autonomous repeated passes, or paced multi-step execution.",
616
+ "After starting a loop, continue each finished iteration with ralph_done unless the completion marker has already been emitted.",
617
+ ],
618
+ parameters: Type.Object({
619
+ name: Type.String({ description: "Loop name (e.g., 'refactor-auth')" }),
620
+ taskContent: Type.String({ description: "Task in markdown with goals and checklist" }),
621
+ itemsPerIteration: Type.Optional(Type.Number({ description: "Suggest N items per turn (0 = no limit)" })),
622
+ reflectEvery: Type.Optional(Type.Number({ description: "Reflect every N iterations" })),
623
+ maxIterations: Type.Optional(Type.Number({ description: "Max iterations (default: 50)", default: 50 })),
624
+ }),
625
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
626
+ const loopName = sanitize(params.name);
627
+ const taskFile = path.join(RALPH_DIR, `${loopName}.md`);
628
+
629
+ if (loadState(ctx, loopName)?.status === "active") {
630
+ return { content: [{ type: "text", text: `Loop "${loopName}" already active.` }], details: {} };
631
+ }
632
+
633
+ const fullPath = path.resolve(ctx.cwd, taskFile);
634
+ ensureDir(fullPath);
635
+ fs.writeFileSync(fullPath, params.taskContent, "utf-8");
636
+
637
+ const state: LoopState = {
638
+ name: loopName,
639
+ taskFile,
640
+ iteration: 1,
641
+ maxIterations: params.maxIterations ?? 50,
642
+ itemsPerIteration: params.itemsPerIteration ?? 0,
643
+ reflectEvery: params.reflectEvery ?? 0,
644
+ reflectInstructions: DEFAULT_REFLECT_INSTRUCTIONS,
645
+ active: true,
646
+ status: "active",
647
+ startedAt: new Date().toISOString(),
648
+ lastReflectionAt: 0,
649
+ };
650
+
651
+ saveState(ctx, state);
652
+ currentLoop = loopName;
653
+ updateUI(ctx);
654
+
655
+ pi.sendUserMessage(buildPrompt(state, params.taskContent, false), { deliverAs: "followUp" });
656
+
657
+ return {
658
+ content: [{ type: "text", text: `Started loop "${loopName}" (max ${state.maxIterations} iterations).` }],
659
+ details: {},
660
+ };
661
+ },
662
+ });
663
+
664
+ // Tool for agent to signal iteration complete and request next
665
+ pi.registerTool({
666
+ name: "ralph_done",
667
+ label: "Ralph Iteration Done",
668
+ 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.",
669
+ promptSnippet: "Advance an active Ralph loop after completing the current iteration.",
670
+ promptGuidelines: [
671
+ "Call this after making real iteration progress so Ralph can queue the next prompt.",
672
+ "Do not call this if there is no active loop, if pending messages are already queued, or if the completion marker has already been emitted.",
673
+ ],
674
+ parameters: Type.Object({}),
675
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
676
+ if (!currentLoop) {
677
+ return { content: [{ type: "text", text: "No active Ralph loop." }], details: {} };
678
+ }
679
+
680
+ const state = loadState(ctx, currentLoop);
681
+ if (!state || state.status !== "active") {
682
+ return { content: [{ type: "text", text: "Ralph loop is not active." }], details: {} };
683
+ }
684
+
685
+ if (ctx.hasPendingMessages()) {
686
+ return {
687
+ content: [{ type: "text", text: "Pending messages already queued. Skipping ralph_done." }],
688
+ details: {},
689
+ };
690
+ }
691
+
692
+ // Increment iteration
693
+ state.iteration++;
694
+
695
+ // Check max iterations
696
+ if (state.maxIterations > 0 && state.iteration > state.maxIterations) {
697
+ completeLoop(
698
+ ctx,
699
+ state,
700
+ `───────────────────────────────────────────────────────────────────────
701
+ ⚠️ RALPH LOOP STOPPED: ${state.name} | Max iterations (${state.maxIterations}) reached
702
+ ───────────────────────────────────────────────────────────────────────`,
703
+ );
704
+ return { content: [{ type: "text", text: "Max iterations reached. Loop stopped." }], details: {} };
705
+ }
706
+
707
+ const needsReflection = state.reflectEvery > 0 && (state.iteration - 1) % state.reflectEvery === 0;
708
+ if (needsReflection) state.lastReflectionAt = state.iteration;
709
+
710
+ saveState(ctx, state);
711
+ updateUI(ctx);
712
+
713
+ const content = tryRead(path.resolve(ctx.cwd, state.taskFile));
714
+ if (!content) {
715
+ pauseLoop(ctx, state);
716
+ return { content: [{ type: "text", text: `Error: Could not read task file: ${state.taskFile}` }], details: {} };
717
+ }
718
+
719
+ // Queue next iteration - use followUp so user can still interrupt
720
+ pi.sendUserMessage(buildPrompt(state, content, needsReflection), { deliverAs: "followUp" });
721
+
722
+ return {
723
+ content: [{ type: "text", text: `Iteration ${state.iteration - 1} complete. Next iteration queued.` }],
724
+ details: {},
725
+ };
726
+ },
727
+ });
728
+
729
+ // --- Event handlers ---
730
+
731
+ pi.on("before_agent_start", async (event, ctx) => {
732
+ if (!currentLoop) return;
733
+ const state = loadState(ctx, currentLoop);
734
+ if (!state || state.status !== "active") return;
735
+
736
+ const iterStr = `${state.iteration}${state.maxIterations > 0 ? `/${state.maxIterations}` : ""}`;
737
+
738
+ let instructions = `You are in a Ralph loop working on: ${state.taskFile}\n`;
739
+ if (state.itemsPerIteration > 0) {
740
+ instructions += `- Work on ~${state.itemsPerIteration} items this iteration\n`;
741
+ }
742
+ instructions += `- Update the task file as you progress\n`;
743
+ instructions += `- When FULLY COMPLETE: ${COMPLETE_MARKER}\n`;
744
+ instructions += `- Otherwise, call ralph_done tool to proceed to next iteration`;
745
+
746
+ return {
747
+ systemPrompt: event.systemPrompt + `\n[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`,
748
+ };
749
+ });
750
+
751
+ pi.on("agent_end", async (event, ctx) => {
752
+ if (!currentLoop) return;
753
+ const state = loadState(ctx, currentLoop);
754
+ if (!state || state.status !== "active") return;
755
+
756
+ // Check for completion marker
757
+ const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant");
758
+ const text =
759
+ lastAssistant && Array.isArray(lastAssistant.content)
760
+ ? lastAssistant.content
761
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
762
+ .map((c) => c.text)
763
+ .join("\n")
764
+ : "";
765
+
766
+ if (text.includes(COMPLETE_MARKER)) {
767
+ completeLoop(
768
+ ctx,
769
+ state,
770
+ `───────────────────────────────────────────────────────────────────────
771
+ ✅ RALPH LOOP COMPLETE: ${state.name} | ${state.iteration} iterations
772
+ ───────────────────────────────────────────────────────────────────────`,
773
+ );
774
+ return;
775
+ }
776
+
777
+ // Check max iterations
778
+ if (state.maxIterations > 0 && state.iteration >= state.maxIterations) {
779
+ completeLoop(
780
+ ctx,
781
+ state,
782
+ `───────────────────────────────────────────────────────────────────────
783
+ ⚠️ RALPH LOOP STOPPED: ${state.name} | Max iterations (${state.maxIterations}) reached
784
+ ───────────────────────────────────────────────────────────────────────`,
785
+ );
786
+ return;
787
+ }
788
+
789
+ // Don't auto-continue - let the agent call ralph_done to proceed
790
+ // This allows user's "stop" message to be processed first
791
+ });
792
+
793
+ pi.on("session_start", async (_event, ctx) => {
794
+ const active = listLoops(ctx).filter((l) => l.status === "active");
795
+
796
+ // Rehydrate currentLoop from disk. The module is re-initialized on
797
+ // session reload (including auto-compaction and /compact), which would
798
+ // otherwise leave `currentLoop` null and silently break ralph_done,
799
+ // agent_end, and before_agent_start. Pick the most-recently-updated
800
+ // active loop when there are multiple, using the state file mtime.
801
+ if (!currentLoop && active.length > 0) {
802
+ const mostRecent = active.reduce((best, candidate) => {
803
+ const bestMtime = safeMtimeMs(getPath(ctx, best.name, ".state.json"));
804
+ const candidateMtime = safeMtimeMs(getPath(ctx, candidate.name, ".state.json"));
805
+ return candidateMtime > bestMtime ? candidate : best;
806
+ });
807
+ currentLoop = mostRecent.name;
808
+ }
809
+
810
+ if (active.length > 0 && ctx.hasUI) {
811
+ const lines = active.map(
812
+ (l) => ` • ${l.name} (iteration ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""})`,
813
+ );
814
+ ctx.ui.notify(`Active Ralph loops:\n${lines.join("\n")}\n\nUse /ralph resume <name> to continue`, "info");
815
+ }
816
+ updateUI(ctx);
817
+ });
818
+
819
+ pi.on("session_shutdown", async (_event, ctx) => {
820
+ if (currentLoop) {
821
+ const state = loadState(ctx, currentLoop);
822
+ if (state) saveState(ctx, state);
823
+ }
824
+ });
825
+ }