{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "ralph-loop-opencode", "title": "OpenCode Ralph Loop", "description": "Ralph loop agent, command, and plugin for OpenCode.", "files": [ { "path": "registry/ralph-loop/opencode/files/agent/ralph.md", "content": "---\ndescription: Ralph loop agent (autonomous iterations)\nmode: primary\npermission:\n bash:\n 'git push': deny\n '*': allow\n external_directory: deny\n doom_loop: allow\n---\n\nYou are the Ralph Loop Code agent.\n\nControl messages:\n\n- If you receive a message containing `RALPH_CONTROL:`, treat it as plugin control. Reply with a brief acknowledgement only (no tool calls) and wait for the next iteration prompt. Outside of that you are expected to begin coding.\n\nRalph loop mechanics:\n\n- Each iteration will include a `` block containing the user-authored prompt file. Treat that as the source of truth for WHAT to do.\n- In each iteration, pick the single most useful next step you can complete now FROM the mentioned prompt file:\n - CODE THAT TASK in the code base. Keep going until you hit a blocker or the task is done. You may not ask the user questions, it's assumed all necessary context is in the prompt file.\n - Once done, update the prompt file with your progress so the next iteration can continue.\n- The prompt file is durable memory. Keep it concise and current with progress, decisions, and next steps (chat history may be compacted).\n- Once your turn is over, you will be summoned again automatically with the same prompt file. Continue working where you left off. Do not wait for the user.\n\nRules:\n\n- Stay inside this repo/worktree. Do not read/write/edit files outside the repo.\n- Never delete or rename the prompt file.\n- Never run `git push`.\n- Avoid catastrophic commands (e.g. repo wipes like `rm -rf .` / `rm -rf *`, disk wipes like `rm -rf /` or `rm -rf ~`).\n\nWorking style:\n\n- Use `## Acceptance Criteria` to define \"done\"; use `## Verification` to prove it.\n- Keep changes small and verify frequently.\n- Update `## Progress` every iteration with: what changed, current status, next step, and any verification results/errors.\n- If a command is blocked/denied, do not retry it. Choose a safer alternative and note it in `## Progress`.\n- If stuck, log the blocker and what you tried in `## Progress`, then attempt the next best approach.\n\nCompletion:\n\n- Only when the task is fully complete AND verification passes, end your final message with the exact token `RALPH_DONE` on its own line.\n", "type": "registry:file", "target": "~/.opencode/agent/ralph.md" }, { "path": "registry/ralph-loop/opencode/files/command/ralph.md", "content": "---\ndescription: Start/stop a Ralph loop on a prompt file\nagent: ralph\n---\n\nRALPH_CONTROL: {\"arg1\":\"$1\",\"arg2\":\"$2\",\"arg3\":\"$3\"}\n\nThis command sends a control message that the Ralph loop plugin listens for.\n\n- If `$1` is `stop`, stop the Ralph loop.\n- Otherwise, treat `$1` as the prompt file reference and `$2` as an optional max-iterations number.\n\nRespond with a brief acknowledgement only. Do not run tools in response to this control message.\n", "type": "registry:file", "target": "~/.opencode/command/ralph.md" }, { "path": "registry/ralph-loop/opencode/files/plugin/ralph-loop.ts", "content": "/// \n\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport type { Plugin } from \"@opencode-ai/plugin\";\n\nimport {\n CONTROL_PREFIX,\n DEFAULT_MAX_ITERATIONS,\n DONE_TOKEN,\n hasDone,\n isPathInside,\n lintPrompt,\n loadJson,\n normalizeRelToken,\n parseControl,\n rmTargetsFromArgs,\n saveJson,\n splitShellSegments,\n stripArg,\n stripEnvPrefixes,\n textFromParts,\n tokenize,\n} from \"../ralph/ralph-utils\";\n\ntype RalphSession = {\n enabled: boolean;\n promptPath: string; // repo-relative\n maxIterations: number;\n iteration: number;\n};\n\ntype RalphState = {\n version: 1;\n sessions: Record;\n};\n\ntype ChatMessageInput = {\n sessionID: string;\n};\n\ntype ChatMessageOutput = {\n parts: unknown[];\n};\n\ntype SessionMessage = {\n info?: {\n role?: string;\n };\n parts?: unknown[];\n};\n\ntype SessionMessagesResponse = {\n data?: SessionMessage[];\n};\n\ntype IdleEvent = {\n type: string;\n properties: {\n sessionID: string;\n };\n};\n\ntype IdleEventInput = {\n event: IdleEvent;\n};\n\ntype ToolExecuteInput = {\n sessionID: string;\n tool: string;\n};\n\ntype ToolExecuteOutput = {\n args?: {\n command?: string;\n filePath?: string;\n };\n};\n\nexport const RalphLoopPlugin: Plugin = async ({ client, worktree }) => {\n const stateFile = path.join(worktree, \".opencode\", \"ralph\", \"state.json\");\n const state = await loadJson(stateFile, { version: 1, sessions: {} });\n\n const toast = async (\n message: string,\n variant: \"info\" | \"success\" | \"warning\" | \"error\" = \"info\",\n ): Promise => {\n await client.tui.showToast({ body: { title: \"Ralph\", message, variant } });\n };\n\n const persist = () => saveJson(stateFile, state);\n\n const stop = async (sessionID: string, reason: string) => {\n const s = state.sessions?.[sessionID];\n if (!s?.enabled) return;\n s.enabled = false;\n await persist();\n await toast(`Stopped: ${reason}`, \"warning\");\n };\n\n const start = async (sessionID: string, promptArg: string, maxArg?: string) => {\n const raw = stripArg(promptArg);\n const abs = path.isAbsolute(raw) ? raw : path.resolve(worktree, raw);\n\n let real = abs;\n try {\n real = await fs.realpath(abs);\n } catch {\n // ignore\n }\n\n if (!isPathInside(worktree, real)) {\n await toast(`Refusing to start: prompt file is outside repo (${promptArg})`, \"error\");\n return;\n }\n\n try {\n if (!(await fs.stat(real)).isFile()) throw new Error(\"not file\");\n } catch {\n await toast(`Refusing to start: prompt file not found (${promptArg})`, \"error\");\n return;\n }\n\n const md = await fs.readFile(real, \"utf8\");\n const lintErrors = lintPrompt(md);\n if (lintErrors.length) {\n await toast(`Prompt file lint failed:\\n- ${lintErrors.join(\"\\n- \")}`, \"error\");\n return;\n }\n\n const maxIterations =\n maxArg && /^[0-9]+$/.test(String(maxArg)) ? Number(maxArg) : DEFAULT_MAX_ITERATIONS;\n state.sessions[sessionID] = {\n enabled: true,\n promptPath: path.relative(worktree, real),\n maxIterations,\n iteration: 0,\n };\n\n await persist();\n await toast(\n `Enabled (max ${maxIterations}) using ${state.sessions[sessionID].promptPath}`,\n \"success\",\n );\n };\n\n const guardBash = (command: string, session: RalphSession) => {\n const cmd = String(command ?? \"\").trim();\n if (!cmd) return;\n\n const worktreeAbs = path.resolve(worktree);\n const promptAbs = path.resolve(worktreeAbs, session.promptPath);\n const promptRel = normalizeRelToken(session.promptPath);\n\n let simulatedCwd = worktreeAbs;\n\n const assertInside = (absPath: string, operation: string) => {\n if (!isPathInside(worktreeAbs, absPath)) {\n throw new Error(`Ralph safety: refusing to ${operation} outside repo`);\n }\n };\n\n const resolveFromCwd = (maybePath: string) =>\n path.isAbsolute(maybePath) ? path.resolve(maybePath) : path.resolve(simulatedCwd, maybePath);\n\n for (const seg of splitShellSegments(cmd)) {\n const tokens = stripEnvPrefixes(tokenize(seg));\n const a = tokens[0] ?? \"\";\n const b = tokens[1] ?? \"\";\n if (!a) continue;\n\n // Track `cd` so relative destructive ops resolve correctly.\n if (a === \"cd\") {\n const dest = tokens[1];\n if (!dest || dest === \"-\") throw new Error(\"Ralph safety: refusing ambiguous cd\");\n\n if (/[`$]/.test(String(dest))) {\n throw new Error(\"Ralph safety: refusing non-literal cd target\");\n }\n\n const normalized = normalizeRelToken(String(dest));\n if (\n normalized.startsWith(\"~\") ||\n normalized.startsWith(\"$HOME\") ||\n normalized.startsWith(\"${HOME}\")\n ) {\n throw new Error(\"Ralph safety: refusing cd to home paths\");\n }\n\n const next = resolveFromCwd(String(dest));\n assertInside(next, \"cd\");\n simulatedCwd = next;\n continue;\n }\n\n if (a === \"sudo\") throw new Error(\"Ralph safety: sudo is disabled\");\n if (a === \"git\" && b === \"push\") throw new Error(\"Ralph safety: git push is disabled\");\n if (a === \"git\" && b === \"clean\" && tokens.some((t) => String(t).includes(\"-f\"))) {\n throw new Error(\"Ralph safety: git clean with -f is disabled\");\n }\n\n const isRm = a === \"rm\" || a === \"rmdir\";\n const isGitRm = a === \"git\" && b === \"rm\";\n const isMv = a === \"mv\";\n const isGitMv = a === \"git\" && b === \"mv\";\n\n // Prevent prompt file moves/renames (and prevent moving files outside repo).\n if (isMv || isGitMv) {\n const offset = isGitMv ? 2 : 1;\n const paths = rmTargetsFromArgs(tokens.slice(offset));\n for (const rawTok of paths) {\n const raw = String(rawTok);\n if (/[`$]/.test(raw)) throw new Error(\"Ralph safety: refusing non-literal mv path\");\n const abs = resolveFromCwd(raw);\n if (abs === promptAbs)\n throw new Error(\"Ralph safety: cannot move/rename the prompt file\");\n assertInside(abs, \"move files\");\n }\n continue;\n }\n\n if (!isRm && !isGitRm) continue;\n\n const offset = isGitRm ? 2 : 1;\n const targets = rmTargetsFromArgs(tokens.slice(offset));\n\n for (const rawTok of targets) {\n const raw = String(rawTok);\n if (/[`$]/.test(raw)) throw new Error(\"Ralph safety: refusing non-literal rm target\");\n const normalizedForWipe = normalizeRelToken(raw).replace(/\\/+$/, \"\");\n\n // Block the classic \"oops\" wipes.\n if (normalizedForWipe === \"\" || normalizedForWipe === \".\" || normalizedForWipe === \"*\") {\n throw new Error(\"Ralph safety: refusing repo wipe\");\n }\n\n if (raw === \"/\" || raw === \"/*\") throw new Error(\"Ralph safety: refusing disk wipe\");\n\n if (\n normalizedForWipe.startsWith(\"~\") ||\n normalizedForWipe.startsWith(\"$HOME\") ||\n normalizedForWipe.startsWith(\"${HOME}\")\n ) {\n throw new Error(\"Ralph safety: refusing home paths\");\n }\n\n // Protect the prompt file.\n if (normalizedForWipe === promptRel) {\n throw new Error(\"Ralph safety: cannot delete the prompt file\");\n }\n\n const abs = resolveFromCwd(raw);\n if (abs === promptAbs) throw new Error(\"Ralph safety: cannot delete the prompt file\");\n if (abs === worktreeAbs) throw new Error(\"Ralph safety: refusing to delete repo root\");\n\n assertInside(abs, \"delete files\");\n }\n }\n };\n\n const runIteration = async (sessionID: string, session: RalphSession) => {\n if (session.iteration >= session.maxIterations) {\n await stop(sessionID, `max iterations reached (${session.maxIterations})`);\n return;\n }\n\n const promptReal = path.join(worktree, session.promptPath);\n const promptContents = await fs.readFile(promptReal, \"utf8\");\n\n session.iteration += 1;\n await persist();\n await toast(`Iteration ${session.iteration}/${session.maxIterations}`, \"info\");\n\n const msg = [\n ``,\n \"\",\n \"- Task spec: follow the content in .\",\n \"- Read `## Progress` as you may have already started coding and done some work.\",\n \"- Focus: pick the single most useful next step you can complete now.\",\n \"- Make concrete progress in implementing the features/functionality described in that doc. Do this by coding in this repo/working directory.\",\n \"- Once you are done, update the prompt file, especially `## Progress`, to be accurate with your progress so far.\",\n \"- In `## Progress`, record: what you changed, current status, next step, and any verification results/errors.\",\n \"- Keep the prompt file concise and current; it is durable memory (chat may be compacted).\",\n \"- Use ## Acceptance Criteria to define done; use ## Verification to prove it.\",\n \"- If a command is blocked/denied, do not retry it; choose a safer alternative and note it in ## Progress.\",\n \"- If stuck, log the blocker and what you tried in ## Progress, then attempt the next best approach.\",\n `- Completion: only when fully complete and verified, end your final message with a line containing only: ${DONE_TOKEN}`,\n \"\",\n ``,\n promptContents,\n \"\",\n \"\",\n ].join(\"\\n\");\n\n await client.session.prompt({\n path: { id: sessionID },\n body: { agent: \"ralph\", parts: [{ type: \"text\", text: msg }] },\n });\n };\n\n return {\n \"chat.message\": async (input: ChatMessageInput, output: ChatMessageOutput) => {\n const text = textFromParts(output.parts);\n if (!text.includes(CONTROL_PREFIX)) return;\n\n const control = parseControl(text);\n if (!control) return toast(\"Could not parse RALPH_CONTROL JSON\", \"error\");\n\n const arg1 = String(control.arg1 ?? \"\").trim();\n const arg2 = String(control.arg2 ?? \"\").trim();\n\n if (!arg1) return toast(\"Usage: /ralph @prompt.md [max] OR /ralph stop\", \"error\");\n if (arg1 === \"stop\") return stop(input.sessionID, \"manual stop\");\n\n return start(input.sessionID, arg1, arg2);\n },\n\n event: async ({ event }: IdleEventInput) => {\n if (event.type !== \"session.idle\") return;\n const sessionID = event.properties.sessionID;\n const session = state.sessions?.[sessionID];\n if (!session?.enabled) return;\n\n try {\n const messagesResult = (await client.session.messages({\n path: { id: sessionID },\n query: { limit: 25 },\n })) as SessionMessagesResponse;\n const messages = messagesResult.data ?? [];\n const lastAssistant = [...messages].reverse().find((m) => m.info?.role === \"assistant\");\n if (lastAssistant && hasDone(textFromParts(lastAssistant.parts ?? [])))\n return stop(sessionID, DONE_TOKEN);\n } catch {\n // ignore\n }\n\n return runIteration(sessionID, session);\n },\n\n \"tool.execute.before\": async (input: ToolExecuteInput, output: ToolExecuteOutput) => {\n const session = state.sessions?.[input.sessionID];\n if (!session?.enabled) return;\n\n if (input.tool === \"bash\") {\n guardBash(output.args?.command, session);\n return;\n }\n\n if (input.tool === \"read\" || input.tool === \"write\" || input.tool === \"edit\") {\n const filePath = output.args?.filePath;\n if (typeof filePath !== \"string\" || !filePath) return;\n const abs = path.isAbsolute(filePath) ? filePath : path.resolve(worktree, filePath);\n if (!isPathInside(worktree, abs))\n throw new Error(\"Ralph safety: file access outside repo is disabled\");\n }\n },\n };\n};\n", "type": "registry:file", "target": "~/.opencode/plugin/ralph-loop.ts" }, { "path": "registry/ralph-loop/opencode/files/ralph/ralph-utils.ts", "content": "/// \n\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\n\nexport const CONTROL_PREFIX = \"RALPH_CONTROL:\";\nexport const DONE_TOKEN = \"RALPH_DONE\";\nexport const DEFAULT_MAX_ITERATIONS = 25;\n\nexport const REQUIRED_SECTIONS = [\n \"Goal\",\n \"Acceptance Criteria\",\n \"Verification\",\n \"Progress\",\n] as const;\nexport const REQUIRED_NONEMPTY = [\"Goal\", \"Acceptance Criteria\", \"Verification\"] as const;\n\nexport const isPathInside = (root: string, target: string) => {\n const rel = path.relative(root, target);\n return rel === \"\" || (!rel.startsWith(`..${path.sep}`) && rel !== \"..\");\n};\n\nexport const stripArg = (value: unknown) => {\n const s = String(value ?? \"\").trim();\n const unquoted =\n (s.startsWith('\"') && s.endsWith('\"')) || (s.startsWith(\"'\") && s.endsWith(\"'\"))\n ? s.slice(1, -1)\n : s;\n return unquoted.startsWith(\"@\") ? unquoted.slice(1) : unquoted;\n};\n\nconst escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\nconst sectionBody = (md: string, title: string) => {\n const re = new RegExp(\n `^#{1,6}\\\\s+${escapeRe(title)}\\\\s*$\\\\r?\\\\n([\\\\s\\\\S]*?)(?=^#{1,6}\\\\s+|\\\\Z)`,\n \"im\",\n );\n const m = re.exec(md);\n return m ? m[1].trim() : null;\n};\n\nexport const lintPrompt = (md: string) => {\n const errors: string[] = [];\n\n for (const s of REQUIRED_SECTIONS) {\n if (sectionBody(md, s) === null) errors.push(`Missing section heading: ${s}`);\n }\n\n for (const s of REQUIRED_NONEMPTY) {\n const body = sectionBody(md, s);\n if (body !== null && !body) errors.push(`Section must not be empty: ${s}`);\n }\n\n return errors;\n};\n\nexport const loadJson = async (filePath: string, fallback: T): Promise => {\n try {\n return JSON.parse(await fs.readFile(filePath, \"utf8\")) as T;\n } catch {\n return fallback;\n }\n};\n\nexport const saveJson = async (filePath: string, value: unknown) => {\n await fs.mkdir(path.dirname(filePath), { recursive: true });\n await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\\n`, \"utf8\");\n};\n\ntype TextPartLike = { type: \"text\"; text: string };\n\nexport const textFromParts = (parts: unknown[]) =>\n (Array.isArray(parts) ? parts : [])\n .filter((p): p is TextPartLike => {\n if (typeof p !== \"object\" || p === null) return false;\n const candidate = p as { type?: unknown; text?: unknown };\n return candidate.type === \"text\" && typeof candidate.text === \"string\";\n })\n .map((p) => p.text)\n .join(\"\\n\");\n\nexport const hasDone = (text: string) =>\n String(text)\n .split(/\\r?\\n/)\n .some((l) => l.trim() === DONE_TOKEN);\n\nexport const parseControl = (text: string) => {\n const line = String(text)\n .split(/\\r?\\n/)\n .map((l) => l.trim())\n .find((l) => l.startsWith(CONTROL_PREFIX));\n\n if (!line) return null;\n\n try {\n return JSON.parse(line.slice(CONTROL_PREFIX.length).trim()) as {\n arg1?: unknown;\n arg2?: unknown;\n arg3?: unknown;\n };\n } catch {\n return null;\n }\n};\n\nexport const tokenize = (cmd: string) => {\n const out: string[] = [];\n let cur = \"\";\n let q: '\"' | \"'\" | null = null;\n\n for (let i = 0; i < cmd.length; i++) {\n const c = cmd[i];\n\n if (q) {\n if (c === q) q = null;\n else cur += c;\n continue;\n }\n\n if (c === '\"' || c === \"'\") {\n q = c;\n continue;\n }\n\n if (/\\s/.test(c)) {\n if (cur) out.push(cur);\n cur = \"\";\n continue;\n }\n\n cur += c;\n }\n\n if (cur) out.push(cur);\n return out;\n};\n\nexport const normalizeRelToken = (t: string) =>\n String(t).replace(/\\\\/g, \"/\").replace(/^\\.\\//, \"\").trim();\n\nexport const splitShellSegments = (command: string) => {\n const segments: string[] = [];\n let cur = \"\";\n let q: '\"' | \"'\" | null = null;\n\n const flush = () => {\n const trimmed = cur.trim();\n if (trimmed) segments.push(trimmed);\n cur = \"\";\n };\n\n for (let i = 0; i < command.length; i++) {\n const c = command[i];\n\n if (q) {\n if (c === q) q = null;\n cur += c;\n continue;\n }\n\n if (c === '\"' || c === \"'\") {\n q = c;\n cur += c;\n continue;\n }\n\n // Split on unquoted ;, &&, ||, and newlines.\n if (c === \";\" || c === \"\\n\") {\n flush();\n continue;\n }\n\n if (c === \"&\" && command[i + 1] === \"&\") {\n flush();\n i++;\n continue;\n }\n\n if (c === \"|\" && command[i + 1] === \"|\") {\n flush();\n i++;\n continue;\n }\n\n cur += c;\n }\n\n flush();\n return segments;\n};\n\nconst envAssignmentRe = /^[A-Za-z_][A-Za-z0-9_]*=.*/;\n\nexport const stripEnvPrefixes = (tokens: string[]) => {\n let i = 0;\n if (tokens[i] === \"env\") i++;\n while (i < tokens.length && envAssignmentRe.test(tokens[i] ?? \"\")) i++;\n return tokens.slice(i);\n};\n\nexport const rmTargetsFromArgs = (args: string[]) => {\n const targets: string[] = [];\n let passthrough = false;\n\n for (const a of args) {\n if (!passthrough && a === \"--\") {\n passthrough = true;\n continue;\n }\n\n if (!passthrough && a.startsWith(\"-\")) continue;\n targets.push(a);\n }\n\n return targets;\n};\n", "type": "registry:file", "target": "~/.opencode/ralph/ralph-utils.ts" }, { "path": "registry/ralph-loop/opencode/files/types/shims.d.ts", "content": "// Minimal ambient types to keep .opencode TypeScript self-contained.\n// This repo may not have Node/Bun type packages installed.\n\ndeclare type Buffer = Uint8Array;\ndeclare type BufferEncoding =\n | \"ascii\"\n | \"utf8\"\n | \"utf-8\"\n | \"utf16le\"\n | \"ucs2\"\n | \"ucs-2\"\n | \"base64\"\n | \"base64url\"\n | \"latin1\"\n | \"binary\"\n | \"hex\";\n\ntype FsStats = {\n isFile(): boolean;\n};\n\ntype FsPromises = {\n realpath(path: string): Promise;\n stat(path: string): Promise;\n readFile(path: string, encoding: BufferEncoding): Promise;\n mkdir(path: string, options?: { recursive?: boolean }): Promise;\n writeFile(path: string, data: string, encoding: BufferEncoding): Promise;\n};\n\ndeclare module \"fs/promises\" {\n const fs: FsPromises;\n export default fs;\n}\n\ndeclare module \"node:fs/promises\" {\n const fs: FsPromises;\n export default fs;\n}\n\ntype PathModule = {\n join(...paths: string[]): string;\n isAbsolute(path: string): boolean;\n resolve(...paths: string[]): string;\n relative(from: string, to: string): string;\n dirname(path: string): string;\n sep: string;\n};\n\ndeclare module \"path\" {\n const path: PathModule;\n export default path;\n}\n\ndeclare module \"node:path\" {\n const path: PathModule;\n export default path;\n}\n", "type": "registry:file", "target": "~/.opencode/types/shims.d.ts" } ], "meta": { "concept": "ralph-loop", "platform": "opencode", "kind": [ "agent", "command", "plugin" ] }, "docs": "Restart OpenCode after installing to register the /ralph command. Targets prefixed with ~ are repo-scoped (project-root) paths.", "type": "registry:item" }