diff --git a/README.ja.md b/README.ja.md
index 94f4ad2..693a1ca 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -635,6 +635,12 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
エージェントが活躍すれば、あなたも幸せになります。ですが、私はあなた自身も助けたいのです。
+- **Ralph Loop**: タスクが完了するまで実行し続ける自己参照型開発ループ。Anthropic の Ralph Wiggum プラグインにインスパイアされています。**すべてのプログラミング言語をサポート。**
+ - `/ralph-loop "REST API を構築"` で開始するとエージェントが継続的に作業します
+ - `DONE` の出力で完了を検知
+ - 完了プロミスなしで停止すると自動再開
+ - 終了条件: 完了検知、最大反復回数到達(デフォルト 100)、または `/cancel-ralph`
+ - `oh-my-opencode.json` で設定: `{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
- **Keyword Detector**: プロンプト内のキーワードを自動検知して専門モードを有効化します:
- `ultrawork` / `ulw`: 並列エージェントオーケストレーションによる最大パフォーマンスモード
- `search` / `find` / `찾아` / `検索`: 並列 explore/librarian エージェントによる検索最大化
@@ -868,7 +874,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
}
```
-利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
+利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
**`auto-update-checker`と`startup-toast`について**: `startup-toast` フックは `auto-update-checker` のサブ機能です。アップデートチェックは有効なまま起動トースト通知のみを無効化するには、`disabled_hooks` に `"startup-toast"` を追加してください。すべてのアップデートチェック機能(トーストを含む)を無効化するには、`"auto-update-checker"` を追加してください。
diff --git a/README.ko.md b/README.ko.md
index ea5556b..e6ba300 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -628,6 +628,12 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
에이전트들이 행복해지면, 당신이 제일 행복해집니다, 그렇지만 저는 당신도 돕고싶습니다.
+- **Ralph Loop**: 작업이 완료될 때까지 계속 실행되는 자기 참조 개발 루프. Anthropic의 Ralph Wiggum 플러그인에서 영감을 받았습니다. **모든 프로그래밍 언어 지원.**
+ - `/ralph-loop "REST API 구축"`으로 시작하면 에이전트가 지속적으로 작업합니다
+ - `DONE` 출력 시 완료로 감지
+ - 완료 프라미스 없이 멈추면 자동 재시작
+ - 종료 조건: 완료 감지, 최대 반복 도달 (기본 100회), 또는 `/cancel-ralph`
+ - `oh-my-opencode.json`에서 설정: `{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
- **Keyword Detector**: 프롬프트의 키워드를 자동 감지하여 전문 모드를 활성화합니다:
- `ultrawork` / `ulw`: 병렬 에이전트 오케스트레이션으로 최대 성능 모드
- `search` / `find` / `찾아` / `検索`: 병렬 explore/librarian 에이전트로 검색 극대화
@@ -865,7 +871,7 @@ Schema 자동 완성이 지원됩니다:
}
```
-사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
+사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
**`auto-update-checker`와 `startup-toast`에 대한 참고사항**: `startup-toast` 훅은 `auto-update-checker`의 하위 기능입니다. 업데이트 확인은 유지하면서 시작 토스트 알림만 비활성화하려면 `disabled_hooks`에 `"startup-toast"`를 추가하세요. 모든 업데이트 확인 기능(토스트 포함)을 비활성화하려면 `"auto-update-checker"`를 추가하세요.
diff --git a/README.md b/README.md
index 901cb8d..5bc12b5 100644
--- a/README.md
+++ b/README.md
@@ -667,6 +667,12 @@ All toggles default to `true` (enabled). Omit the `claude_code` object for full
When agents thrive, you thrive. But I want to help you directly too.
+- **Ralph Loop**: Self-referential development loop that runs until task completion. Inspired by Anthropic's Ralph Wiggum plugin. **Supports all programming languages.**
+ - Start with `/ralph-loop "Build a REST API"` and let the agent work continuously
+ - Loop detects `DONE` to know when complete
+ - Auto-continues if agent stops without completion promise
+ - Ends when: completion detected, max iterations reached (default 100), or `/cancel-ralph`
+ - Configure in `oh-my-opencode.json`: `{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
- **Keyword Detector**: Automatically detects keywords in your prompts and activates specialized modes:
- `ultrawork` / `ulw`: Maximum performance mode with parallel agent orchestration
- `search` / `find` / `찾아` / `検索`: Maximized search effort with parallel explore and librarian agents
@@ -904,7 +910,7 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
}
```
-Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
+Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
diff --git a/README.zh-cn.md b/README.zh-cn.md
index a5034b5..6bc0587 100644
--- a/README.zh-cn.md
+++ b/README.zh-cn.md
@@ -639,6 +639,12 @@ Oh My OpenCode 会扫这些地方:
Agent 爽了,你自然也爽。但我还想直接让你爽。
+- **Ralph 循环**:干到完事才停的自参照开发循环。灵感来自 Anthropic 的 Ralph Wiggum 插件。**支持所有编程语言。**
+ - `/ralph-loop "搞个 REST API"` 开始,Agent 就一直干
+ - 检测到 `DONE` 就算完事
+ - 没输出完成标记就停了?自动续上
+ - 停止条件:检测到完成、达到最大迭代(默认 100 次)、或 `/cancel-ralph`
+ - `oh-my-opencode.json` 配置:`{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
- **关键词检测器**:看到关键词自动切模式:
- `ultrawork` / `ulw`:并行 Agent 编排,火力全开
- `search` / `find` / `찾아` / `検索`:explore/librarian 并行搜索,掘地三尺
@@ -872,7 +878,7 @@ Sisyphus Agent 也能自定义:
}
```
-可关的 hook:`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-context-window-limit-recovery`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`、`preemptive-compaction`、`compaction-context-injector`、`thinking-block-validator`、`claude-code-hooks`
+可关的 hook:`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-context-window-limit-recovery`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`、`preemptive-compaction`、`compaction-context-injector`、`thinking-block-validator`、`claude-code-hooks`、`ralph-loop`
**关于 `auto-update-checker` 和 `startup-toast`**: `startup-toast` hook 是 `auto-update-checker` 的子功能。若想保持更新检查但只禁用启动提示通知,在 `disabled_hooks` 中添加 `"startup-toast"`。若要禁用所有更新检查功能(包括提示),添加 `"auto-update-checker"`。
diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json
index 1729b7c..4adaee1 100644
--- a/assets/oh-my-opencode.schema.json
+++ b/assets/oh-my-opencode.schema.json
@@ -60,7 +60,8 @@
"non-interactive-env",
"interactive-bash-session",
"empty-message-sanitizer",
- "thinking-block-validator"
+ "thinking-block-validator",
+ "ralph-loop"
]
}
},
@@ -1511,6 +1512,24 @@
},
"auto_update": {
"type": "boolean"
+ },
+ "ralph_loop": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "default": false,
+ "type": "boolean"
+ },
+ "default_max_iterations": {
+ "default": 100,
+ "type": "number",
+ "minimum": 1,
+ "maximum": 1000
+ },
+ "state_dir": {
+ "type": "string"
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/src/config/index.ts b/src/config/index.ts
index 5d82d19..fb0f98c 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -8,6 +8,7 @@ export {
BuiltinCommandNameSchema,
SisyphusAgentConfigSchema,
ExperimentalConfigSchema,
+ RalphLoopConfigSchema,
} from "./schema"
export type {
@@ -21,4 +22,5 @@ export type {
SisyphusAgentConfig,
ExperimentalConfig,
DynamicContextPruningConfig,
+ RalphLoopConfig,
} from "./schema"
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 05f1936..1440100 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -65,6 +65,7 @@ export const HookNameSchema = z.enum([
"interactive-bash-session",
"empty-message-sanitizer",
"thinking-block-validator",
+ "ralph-loop",
])
export const BuiltinCommandNameSchema = z.enum([
@@ -213,6 +214,15 @@ export const SkillsConfigSchema = z.union([
}).partial()),
])
+export const RalphLoopConfigSchema = z.object({
+ /** Enable ralph loop functionality (default: false - opt-in feature) */
+ enabled: z.boolean().default(false),
+ /** Default max iterations if not specified in command (default: 100) */
+ default_max_iterations: z.number().min(1).max(1000).default(100),
+ /** Custom state file directory relative to project root (default: .opencode/) */
+ state_dir: z.string().optional(),
+})
+
export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
disabled_mcps: z.array(McpNameSchema).optional(),
@@ -227,6 +237,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
experimental: ExperimentalConfigSchema.optional(),
auto_update: z.boolean().optional(),
skills: SkillsConfigSchema.optional(),
+ ralph_loop: RalphLoopConfigSchema.optional(),
})
export type OhMyOpenCodeConfig = z.infer
@@ -241,5 +252,6 @@ export type ExperimentalConfig = z.infer
export type DynamicContextPruningConfig = z.infer
export type SkillsConfig = z.infer
export type SkillDefinition = z.infer
+export type RalphLoopConfig = z.infer
export { McpNameSchema, type McpName } from "../mcp/types"
diff --git a/src/features/builtin-commands/commands.ts b/src/features/builtin-commands/commands.ts
index 53a0ff3..d183a2f 100644
--- a/src/features/builtin-commands/commands.ts
+++ b/src/features/builtin-commands/commands.ts
@@ -1,6 +1,7 @@
import type { CommandDefinition } from "../claude-code-command-loader"
import type { BuiltinCommandName, BuiltinCommands } from "./types"
import { INIT_DEEP_TEMPLATE } from "./templates/init-deep"
+import { RALPH_LOOP_TEMPLATE, CANCEL_RALPH_TEMPLATE } from "./templates/ralph-loop"
const BUILTIN_COMMAND_DEFINITIONS: Record> = {
"init-deep": {
@@ -14,6 +15,23 @@ $ARGUMENTS
`,
argumentHint: "[--create-new] [--max-depth=N]",
},
+ "ralph-loop": {
+ description: "(builtin) Start self-referential development loop until completion",
+ template: `
+${RALPH_LOOP_TEMPLATE}
+
+
+
+$ARGUMENTS
+`,
+ argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]',
+ },
+ "cancel-ralph": {
+ description: "(builtin) Cancel active Ralph Loop",
+ template: `
+${CANCEL_RALPH_TEMPLATE}
+`,
+ },
}
export function loadBuiltinCommands(
diff --git a/src/features/builtin-commands/templates/ralph-loop.ts b/src/features/builtin-commands/templates/ralph-loop.ts
new file mode 100644
index 0000000..6584639
--- /dev/null
+++ b/src/features/builtin-commands/templates/ralph-loop.ts
@@ -0,0 +1,38 @@
+export const RALPH_LOOP_TEMPLATE = `You are starting a Ralph Loop - a self-referential development loop that runs until task completion.
+
+## How Ralph Loop Works
+
+1. You will work on the task continuously
+2. When you believe the task is FULLY complete, output: \`{{COMPLETION_PROMISE}}\`
+3. If you don't output the promise, the loop will automatically inject another prompt to continue
+4. Maximum iterations: Configurable (default 100)
+
+## Rules
+
+- Focus on completing the task fully, not partially
+- Don't output the completion promise until the task is truly done
+- Each iteration should make meaningful progress toward the goal
+- If stuck, try different approaches
+- Use todos to track your progress
+
+## Exit Conditions
+
+1. **Completion**: Output \`DONE\` (or custom promise text) when fully complete
+2. **Max Iterations**: Loop stops automatically at limit
+3. **Cancel**: User runs \`/cancel-ralph\` command
+
+## Your Task
+
+Parse the arguments below and begin working on the task. The format is:
+\`"task description" [--completion-promise=TEXT] [--max-iterations=N]\`
+
+Default completion promise is "DONE" and default max iterations is 100.`
+
+export const CANCEL_RALPH_TEMPLATE = `Cancel the currently active Ralph Loop.
+
+This will:
+1. Stop the loop from continuing
+2. Clear the loop state file
+3. Allow the session to end normally
+
+Check if a loop is active and cancel it. Inform the user of the result.`
diff --git a/src/features/builtin-commands/types.ts b/src/features/builtin-commands/types.ts
index 42a5b43..f121698 100644
--- a/src/features/builtin-commands/types.ts
+++ b/src/features/builtin-commands/types.ts
@@ -1,6 +1,6 @@
import type { CommandDefinition } from "../claude-code-command-loader"
-export type BuiltinCommandName = "init-deep"
+export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph"
export interface BuiltinCommandConfig {
disabled_commands?: BuiltinCommandName[]
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 7428018..9a59971 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -22,3 +22,4 @@ export { createNonInteractiveEnvHook } from "./non-interactive-env";
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
+export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
diff --git a/src/hooks/ralph-loop/constants.ts b/src/hooks/ralph-loop/constants.ts
new file mode 100644
index 0000000..20e835f
--- /dev/null
+++ b/src/hooks/ralph-loop/constants.ts
@@ -0,0 +1,5 @@
+export const HOOK_NAME = "ralph-loop"
+export const DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md"
+export const COMPLETION_TAG_PATTERN = /(.*?)<\/promise>/is
+export const DEFAULT_MAX_ITERATIONS = 100
+export const DEFAULT_COMPLETION_PROMISE = "DONE"
diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts
new file mode 100644
index 0000000..5da9f20
--- /dev/null
+++ b/src/hooks/ralph-loop/index.test.ts
@@ -0,0 +1,388 @@
+import { describe, expect, test, beforeEach, afterEach } from "bun:test"
+import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
+import { join } from "node:path"
+import { tmpdir } from "node:os"
+import { createRalphLoopHook } from "./index"
+import { readState, writeState, clearState } from "./storage"
+import type { RalphLoopState } from "./types"
+
+describe("ralph-loop", () => {
+ const TEST_DIR = join(tmpdir(), "ralph-loop-test-" + Date.now())
+ let promptCalls: Array<{ sessionID: string; text: string }>
+ let toastCalls: Array<{ title: string; message: string; variant: string }>
+
+ function createMockPluginInput() {
+ return {
+ client: {
+ session: {
+ prompt: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
+ promptCalls.push({
+ sessionID: opts.path.id,
+ text: opts.body.parts[0].text,
+ })
+ return {}
+ },
+ },
+ tui: {
+ showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
+ toastCalls.push({
+ title: opts.body.title,
+ message: opts.body.message,
+ variant: opts.body.variant,
+ })
+ return {}
+ },
+ },
+ },
+ directory: TEST_DIR,
+ } as Parameters[0]
+ }
+
+ beforeEach(() => {
+ promptCalls = []
+ toastCalls = []
+
+ if (!existsSync(TEST_DIR)) {
+ mkdirSync(TEST_DIR, { recursive: true })
+ }
+
+ clearState(TEST_DIR)
+ })
+
+ afterEach(() => {
+ clearState(TEST_DIR)
+ if (existsSync(TEST_DIR)) {
+ rmSync(TEST_DIR, { recursive: true, force: true })
+ }
+ })
+
+ describe("storage", () => {
+ test("should write and read state correctly", () => {
+ // #given - a state object
+ const state: RalphLoopState = {
+ active: true,
+ iteration: 1,
+ max_iterations: 50,
+ completion_promise: "DONE",
+ started_at: "2025-12-30T01:00:00Z",
+ prompt: "Build a REST API",
+ session_id: "test-session-123",
+ }
+
+ // #when - write and read state
+ const writeSuccess = writeState(TEST_DIR, state)
+ const readResult = readState(TEST_DIR)
+
+ // #then - state should match
+ expect(writeSuccess).toBe(true)
+ expect(readResult).not.toBeNull()
+ expect(readResult?.active).toBe(true)
+ expect(readResult?.iteration).toBe(1)
+ expect(readResult?.max_iterations).toBe(50)
+ expect(readResult?.completion_promise).toBe("DONE")
+ expect(readResult?.prompt).toBe("Build a REST API")
+ expect(readResult?.session_id).toBe("test-session-123")
+ })
+
+ test("should return null for non-existent state", () => {
+ // #given - no state file exists
+ // #when - read state
+ const result = readState(TEST_DIR)
+
+ // #then - should return null
+ expect(result).toBeNull()
+ })
+
+ test("should clear state correctly", () => {
+ // #given - existing state
+ const state: RalphLoopState = {
+ active: true,
+ iteration: 1,
+ max_iterations: 50,
+ completion_promise: "DONE",
+ started_at: "2025-12-30T01:00:00Z",
+ prompt: "Test prompt",
+ }
+ writeState(TEST_DIR, state)
+
+ // #when - clear state
+ const clearSuccess = clearState(TEST_DIR)
+ const readResult = readState(TEST_DIR)
+
+ // #then - state should be cleared
+ expect(clearSuccess).toBe(true)
+ expect(readResult).toBeNull()
+ })
+
+ test("should handle multiline prompts", () => {
+ // #given - state with multiline prompt
+ const state: RalphLoopState = {
+ active: true,
+ iteration: 1,
+ max_iterations: 10,
+ completion_promise: "FINISHED",
+ started_at: "2025-12-30T02:00:00Z",
+ prompt: "Build a feature\nwith multiple lines\nand requirements",
+ }
+
+ // #when - write and read
+ writeState(TEST_DIR, state)
+ const readResult = readState(TEST_DIR)
+
+ // #then - multiline prompt preserved
+ expect(readResult?.prompt).toBe("Build a feature\nwith multiple lines\nand requirements")
+ })
+ })
+
+ describe("hook", () => {
+ test("should start loop and write state", () => {
+ // #given - hook instance
+ const hook = createRalphLoopHook(createMockPluginInput())
+
+ // #when - start loop
+ const success = hook.startLoop("session-123", "Build something", {
+ maxIterations: 25,
+ completionPromise: "FINISHED",
+ })
+
+ // #then - state should be written
+ expect(success).toBe(true)
+ const state = hook.getState()
+ expect(state?.active).toBe(true)
+ expect(state?.iteration).toBe(1)
+ expect(state?.max_iterations).toBe(25)
+ expect(state?.completion_promise).toBe("FINISHED")
+ expect(state?.prompt).toBe("Build something")
+ expect(state?.session_id).toBe("session-123")
+ })
+
+ test("should inject continuation when loop active and no completion detected", async () => {
+ // #given - active loop state
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
+
+ // #when - session goes idle
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123" },
+ },
+ })
+
+ // #then - continuation should be injected
+ expect(promptCalls.length).toBe(1)
+ expect(promptCalls[0].sessionID).toBe("session-123")
+ expect(promptCalls[0].text).toContain("RALPH LOOP")
+ expect(promptCalls[0].text).toContain("Build a feature")
+ expect(promptCalls[0].text).toContain("2/10")
+
+ // #then - iteration should be incremented
+ const state = hook.getState()
+ expect(state?.iteration).toBe(2)
+ })
+
+ test("should stop loop when max iterations reached", async () => {
+ // #given - loop at max iteration
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Build something", { maxIterations: 2 })
+
+ const state = hook.getState()!
+ state.iteration = 2
+ writeState(TEST_DIR, state)
+
+ // #when - session goes idle
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123" },
+ },
+ })
+
+ // #then - no continuation injected
+ expect(promptCalls.length).toBe(0)
+
+ // #then - warning toast shown
+ expect(toastCalls.length).toBe(1)
+ expect(toastCalls[0].title).toBe("Ralph Loop Stopped")
+ expect(toastCalls[0].variant).toBe("warning")
+
+ // #then - state should be cleared
+ expect(hook.getState()).toBeNull()
+ })
+
+ test("should cancel loop via cancelLoop", () => {
+ // #given - active loop
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Test task")
+
+ // #when - cancel loop
+ const success = hook.cancelLoop("session-123")
+
+ // #then - loop cancelled
+ expect(success).toBe(true)
+ expect(hook.getState()).toBeNull()
+ })
+
+ test("should not cancel loop for different session", () => {
+ // #given - active loop for session-123
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Test task")
+
+ // #when - try to cancel for different session
+ const success = hook.cancelLoop("session-456")
+
+ // #then - cancel should fail
+ expect(success).toBe(false)
+ expect(hook.getState()).not.toBeNull()
+ })
+
+ test("should skip injection during recovery", async () => {
+ // #given - active loop and session in recovery
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Test task")
+
+ await hook.event({
+ event: {
+ type: "session.error",
+ properties: { sessionID: "session-123", error: new Error("test") },
+ },
+ })
+
+ // #when - session goes idle immediately
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123" },
+ },
+ })
+
+ // #then - no continuation injected
+ expect(promptCalls.length).toBe(0)
+ })
+
+ test("should clear state on session deletion", async () => {
+ // #given - active loop
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Test task")
+
+ // #when - session deleted
+ await hook.event({
+ event: {
+ type: "session.deleted",
+ properties: { info: { id: "session-123" } },
+ },
+ })
+
+ // #then - state should be cleared
+ expect(hook.getState()).toBeNull()
+ })
+
+ test("should not inject for different session than loop owner", async () => {
+ // #given - loop owned by session-123
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Test task")
+
+ // #when - different session goes idle
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-456" },
+ },
+ })
+
+ // #then - no continuation injected
+ expect(promptCalls.length).toBe(0)
+ })
+
+ test("should use default config values", () => {
+ // #given - hook with config
+ const hook = createRalphLoopHook(createMockPluginInput(), {
+ config: {
+ enabled: true,
+ default_max_iterations: 200,
+ },
+ })
+
+ // #when - start loop without options
+ hook.startLoop("session-123", "Test task")
+
+ // #then - should use config defaults
+ const state = hook.getState()
+ expect(state?.max_iterations).toBe(200)
+ })
+
+ test("should not inject when no loop is active", async () => {
+ // #given - no active loop
+ const hook = createRalphLoopHook(createMockPluginInput())
+
+ // #when - session goes idle
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123" },
+ },
+ })
+
+ // #then - no continuation injected
+ expect(promptCalls.length).toBe(0)
+ })
+
+ test("should detect completion promise and stop loop", async () => {
+ // #given - active loop with transcript containing completion
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Build something", { completionPromise: "COMPLETE" })
+
+ const transcriptPath = join(TEST_DIR, "transcript.jsonl")
+ writeFileSync(transcriptPath, JSON.stringify({ content: "Task done COMPLETE" }))
+
+ // #when - session goes idle with transcript
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123", transcriptPath },
+ },
+ })
+
+ // #then - loop completed, no continuation
+ expect(promptCalls.length).toBe(0)
+ expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
+ expect(hook.getState()).toBeNull()
+ })
+
+ test("should handle multiple iterations correctly", async () => {
+ // #given - active loop
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Build feature", { maxIterations: 5 })
+
+ // #when - multiple idle events
+ await hook.event({
+ event: { type: "session.idle", properties: { sessionID: "session-123" } },
+ })
+ await hook.event({
+ event: { type: "session.idle", properties: { sessionID: "session-123" } },
+ })
+
+ // #then - iteration incremented correctly
+ expect(hook.getState()?.iteration).toBe(3)
+ expect(promptCalls.length).toBe(2)
+ })
+
+ test("should include prompt and promise in continuation message", async () => {
+ // #given - loop with specific prompt and promise
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Create a calculator app", {
+ completionPromise: "CALCULATOR_DONE",
+ maxIterations: 10,
+ })
+
+ // #when - session goes idle
+ await hook.event({
+ event: { type: "session.idle", properties: { sessionID: "session-123" } },
+ })
+
+ // #then - continuation includes original task and promise
+ expect(promptCalls[0].text).toContain("Create a calculator app")
+ expect(promptCalls[0].text).toContain("CALCULATOR_DONE")
+ })
+ })
+})
diff --git a/src/hooks/ralph-loop/index.ts b/src/hooks/ralph-loop/index.ts
new file mode 100644
index 0000000..434a540
--- /dev/null
+++ b/src/hooks/ralph-loop/index.ts
@@ -0,0 +1,272 @@
+import { existsSync, readFileSync } from "node:fs"
+import type { PluginInput } from "@opencode-ai/plugin"
+import { log } from "../../shared/logger"
+import { readState, writeState, clearState, incrementIteration } from "./storage"
+import {
+ HOOK_NAME,
+ DEFAULT_MAX_ITERATIONS,
+ DEFAULT_COMPLETION_PROMISE,
+} from "./constants"
+import type { RalphLoopState, RalphLoopOptions } from "./types"
+
+export * from "./types"
+export * from "./constants"
+export { readState, writeState, clearState, incrementIteration } from "./storage"
+
+interface SessionState {
+ isRecovering?: boolean
+}
+
+const CONTINUATION_PROMPT = `[RALPH LOOP - ITERATION {{ITERATION}}/{{MAX}}]
+
+Your previous attempt did not output the completion promise. Continue working on the task.
+
+IMPORTANT:
+- Review your progress so far
+- Continue from where you left off
+- When FULLY complete, output: {{PROMISE}}
+- Do not stop until the task is truly done
+
+Original task:
+{{PROMPT}}`
+
+export interface RalphLoopHook {
+ event: (input: { event: { type: string; properties?: unknown } }) => Promise
+ startLoop: (
+ sessionID: string,
+ prompt: string,
+ options?: { maxIterations?: number; completionPromise?: string }
+ ) => boolean
+ cancelLoop: (sessionID: string) => boolean
+ getState: () => RalphLoopState | null
+}
+
+export function createRalphLoopHook(
+ ctx: PluginInput,
+ options?: RalphLoopOptions
+): RalphLoopHook {
+ const sessions = new Map()
+ const config = options?.config
+ const stateDir = config?.state_dir
+
+ function getSessionState(sessionID: string): SessionState {
+ let state = sessions.get(sessionID)
+ if (!state) {
+ state = {}
+ sessions.set(sessionID, state)
+ }
+ return state
+ }
+
+ function detectCompletionPromise(
+ transcriptPath: string | undefined,
+ promise: string
+ ): boolean {
+ if (!transcriptPath) return false
+
+ try {
+ if (!existsSync(transcriptPath)) return false
+
+ const content = readFileSync(transcriptPath, "utf-8")
+ const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is")
+ return pattern.test(content)
+ } catch {
+ return false
+ }
+ }
+
+ function escapeRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+ }
+
+ const startLoop = (
+ sessionID: string,
+ prompt: string,
+ loopOptions?: { maxIterations?: number; completionPromise?: string }
+ ): boolean => {
+ const state: RalphLoopState = {
+ active: true,
+ iteration: 1,
+ max_iterations:
+ loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS,
+ completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE,
+ started_at: new Date().toISOString(),
+ prompt,
+ session_id: sessionID,
+ }
+
+ const success = writeState(ctx.directory, state, stateDir)
+ if (success) {
+ log(`[${HOOK_NAME}] Loop started`, {
+ sessionID,
+ maxIterations: state.max_iterations,
+ completionPromise: state.completion_promise,
+ })
+ }
+ return success
+ }
+
+ const cancelLoop = (sessionID: string): boolean => {
+ const state = readState(ctx.directory, stateDir)
+ if (!state || state.session_id !== sessionID) {
+ return false
+ }
+
+ const success = clearState(ctx.directory, stateDir)
+ if (success) {
+ log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration })
+ }
+ return success
+ }
+
+ const getState = (): RalphLoopState | null => {
+ return readState(ctx.directory, stateDir)
+ }
+
+ const event = async ({
+ event,
+ }: {
+ event: { type: string; properties?: unknown }
+ }): Promise => {
+ const props = event.properties as Record | undefined
+
+ if (event.type === "session.idle") {
+ const sessionID = props?.sessionID as string | undefined
+ if (!sessionID) return
+
+ const sessionState = getSessionState(sessionID)
+ if (sessionState.isRecovering) {
+ log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
+ return
+ }
+
+ const state = readState(ctx.directory, stateDir)
+ if (!state || !state.active) {
+ return
+ }
+
+ if (state.session_id && state.session_id !== sessionID) {
+ return
+ }
+
+ const transcriptPath = props?.transcriptPath as string | undefined
+
+ if (detectCompletionPromise(transcriptPath, state.completion_promise)) {
+ log(`[${HOOK_NAME}] Completion detected!`, {
+ sessionID,
+ iteration: state.iteration,
+ promise: state.completion_promise,
+ })
+ clearState(ctx.directory, stateDir)
+
+ await ctx.client.tui
+ .showToast({
+ body: {
+ title: "Ralph Loop Complete!",
+ message: `Task completed after ${state.iteration} iteration(s)`,
+ variant: "success",
+ duration: 5000,
+ },
+ })
+ .catch(() => {})
+
+ return
+ }
+
+ if (state.iteration >= state.max_iterations) {
+ log(`[${HOOK_NAME}] Max iterations reached`, {
+ sessionID,
+ iteration: state.iteration,
+ max: state.max_iterations,
+ })
+ clearState(ctx.directory, stateDir)
+
+ await ctx.client.tui
+ .showToast({
+ body: {
+ title: "Ralph Loop Stopped",
+ message: `Max iterations (${state.max_iterations}) reached without completion`,
+ variant: "warning",
+ duration: 5000,
+ },
+ })
+ .catch(() => {})
+
+ return
+ }
+
+ const newState = incrementIteration(ctx.directory, stateDir)
+ if (!newState) {
+ log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
+ return
+ }
+
+ log(`[${HOOK_NAME}] Continuing loop`, {
+ sessionID,
+ iteration: newState.iteration,
+ max: newState.max_iterations,
+ })
+
+ const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(newState.iteration))
+ .replace("{{MAX}}", String(newState.max_iterations))
+ .replace("{{PROMISE}}", newState.completion_promise)
+ .replace("{{PROMPT}}", newState.prompt)
+
+ await ctx.client.tui
+ .showToast({
+ body: {
+ title: "Ralph Loop",
+ message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
+ variant: "info",
+ duration: 2000,
+ },
+ })
+ .catch(() => {})
+
+ try {
+ await ctx.client.session.prompt({
+ path: { id: sessionID },
+ body: {
+ parts: [{ type: "text", text: continuationPrompt }],
+ },
+ query: { directory: ctx.directory },
+ })
+ } catch (err) {
+ log(`[${HOOK_NAME}] Failed to inject continuation`, {
+ sessionID,
+ error: String(err),
+ })
+ }
+ }
+
+ if (event.type === "session.deleted") {
+ const sessionInfo = props?.info as { id?: string } | undefined
+ if (sessionInfo?.id) {
+ const state = readState(ctx.directory, stateDir)
+ if (state?.session_id === sessionInfo.id) {
+ clearState(ctx.directory, stateDir)
+ log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id })
+ }
+ sessions.delete(sessionInfo.id)
+ }
+ }
+
+ if (event.type === "session.error") {
+ const sessionID = props?.sessionID as string | undefined
+ if (sessionID) {
+ const sessionState = getSessionState(sessionID)
+ sessionState.isRecovering = true
+ setTimeout(() => {
+ sessionState.isRecovering = false
+ }, 5000)
+ }
+ }
+ }
+
+ return {
+ event,
+ startLoop,
+ cancelLoop,
+ getState,
+ }
+}
diff --git a/src/hooks/ralph-loop/storage.ts b/src/hooks/ralph-loop/storage.ts
new file mode 100644
index 0000000..86d4725
--- /dev/null
+++ b/src/hooks/ralph-loop/storage.ts
@@ -0,0 +1,113 @@
+import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"
+import { dirname, join } from "node:path"
+import { parseFrontmatter } from "../../shared/frontmatter"
+import type { RalphLoopState } from "./types"
+import { DEFAULT_STATE_FILE, DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS } from "./constants"
+
+export function getStateFilePath(directory: string, customPath?: string): string {
+ return customPath
+ ? join(directory, customPath)
+ : join(directory, DEFAULT_STATE_FILE)
+}
+
+export function readState(directory: string, customPath?: string): RalphLoopState | null {
+ const filePath = getStateFilePath(directory, customPath)
+
+ if (!existsSync(filePath)) {
+ return null
+ }
+
+ try {
+ const content = readFileSync(filePath, "utf-8")
+ const { data, body } = parseFrontmatter>(content)
+
+ const active = data.active
+ const iteration = data.iteration
+
+ if (active === undefined || iteration === undefined) {
+ return null
+ }
+
+ const isActive = active === true || active === "true"
+ const iterationNum = typeof iteration === "number" ? iteration : Number(iteration)
+
+ if (isNaN(iterationNum)) {
+ return null
+ }
+
+ const stripQuotes = (val: unknown): string => {
+ const str = String(val ?? "")
+ return str.replace(/^["']|["']$/g, "")
+ }
+
+ return {
+ active: isActive,
+ iteration: iterationNum,
+ max_iterations: Number(data.max_iterations) || DEFAULT_MAX_ITERATIONS,
+ completion_promise: stripQuotes(data.completion_promise) || DEFAULT_COMPLETION_PROMISE,
+ started_at: stripQuotes(data.started_at) || new Date().toISOString(),
+ prompt: body.trim(),
+ session_id: data.session_id ? stripQuotes(data.session_id) : undefined,
+ }
+ } catch {
+ return null
+ }
+}
+
+export function writeState(
+ directory: string,
+ state: RalphLoopState,
+ customPath?: string
+): boolean {
+ const filePath = getStateFilePath(directory, customPath)
+
+ try {
+ const dir = dirname(filePath)
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true })
+ }
+
+ const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : ""
+ const content = `---
+active: ${state.active}
+iteration: ${state.iteration}
+max_iterations: ${state.max_iterations}
+completion_promise: "${state.completion_promise}"
+started_at: "${state.started_at}"
+${sessionIdLine}---
+${state.prompt}
+`
+
+ writeFileSync(filePath, content, "utf-8")
+ return true
+ } catch {
+ return false
+ }
+}
+
+export function clearState(directory: string, customPath?: string): boolean {
+ const filePath = getStateFilePath(directory, customPath)
+
+ try {
+ if (existsSync(filePath)) {
+ unlinkSync(filePath)
+ }
+ return true
+ } catch {
+ return false
+ }
+}
+
+export function incrementIteration(
+ directory: string,
+ customPath?: string
+): RalphLoopState | null {
+ const state = readState(directory, customPath)
+ if (!state) return null
+
+ state.iteration += 1
+ if (writeState(directory, state, customPath)) {
+ return state
+ }
+ return null
+}
diff --git a/src/hooks/ralph-loop/types.ts b/src/hooks/ralph-loop/types.ts
new file mode 100644
index 0000000..5790efb
--- /dev/null
+++ b/src/hooks/ralph-loop/types.ts
@@ -0,0 +1,15 @@
+import type { RalphLoopConfig } from "../../config"
+
+export interface RalphLoopState {
+ active: boolean
+ iteration: number
+ max_iterations: number
+ completion_promise: string
+ started_at: string
+ prompt: string
+ session_id?: string
+}
+
+export interface RalphLoopOptions {
+ config?: RalphLoopConfig
+}
diff --git a/src/index.ts b/src/index.ts
index d16ee7c..75e00bf 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -24,6 +24,7 @@ import {
createInteractiveBashSessionHook,
createEmptyMessageSanitizerHook,
createThinkingBlockValidatorHook,
+ createRalphLoopHook,
} from "./hooks";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import {
@@ -310,6 +311,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createThinkingBlockValidatorHook()
: null;
+ const ralphLoop = isHookEnabled("ralph-loop")
+ ? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
+ : null;
+
const backgroundManager = new BackgroundManager(ctx);
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
@@ -361,6 +366,39 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
"chat.message": async (input, output) => {
await claudeCodeHooks["chat.message"]?.(input, output);
await keywordDetector?.["chat.message"]?.(input, output);
+
+ if (ralphLoop) {
+ const parts = (output as { parts?: Array<{ type: string; text?: string }> }).parts;
+ const promptText = parts
+ ?.filter((p) => p.type === "text" && p.text)
+ .map((p) => p.text)
+ .join("\n")
+ .trim() || "";
+
+ const isRalphLoopTemplate = promptText.includes("You are starting a Ralph Loop") &&
+ promptText.includes("");
+ const isCancelRalphTemplate = promptText.includes("Cancel the currently active Ralph Loop");
+
+ if (isRalphLoopTemplate) {
+ const taskMatch = promptText.match(/\s*([\s\S]*?)\s*<\/user-task>/i);
+ const rawTask = taskMatch?.[1]?.trim() || "";
+
+ const quotedMatch = rawTask.match(/^["'](.+?)["']/);
+ const prompt = quotedMatch?.[1] || rawTask.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
+
+ const maxIterMatch = rawTask.match(/--max-iterations=(\d+)/i);
+ const promiseMatch = rawTask.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
+
+ log("[ralph-loop] Starting loop from chat.message", { sessionID: input.sessionID, prompt });
+ ralphLoop.startLoop(input.sessionID, prompt, {
+ maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
+ completionPromise: promiseMatch?.[1],
+ });
+ } else if (isCancelRalphTemplate) {
+ log("[ralph-loop] Cancelling loop from chat.message", { sessionID: input.sessionID });
+ ralphLoop.cancelLoop(input.sessionID);
+ }
+ }
},
"experimental.chat.messages.transform": async (
@@ -584,6 +622,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await preemptiveCompaction?.event(input);
await agentUsageReminder?.event(input);
await interactiveBashSession?.event(input);
+ await ralphLoop?.event(input);
const { event } = input;
const props = event.properties as Record | undefined;
@@ -650,6 +689,28 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
...(isExploreOrLibrarian ? { call_omo_agent: false } : {}),
};
}
+
+ if (ralphLoop && input.tool === "slashcommand") {
+ const args = output.args as { command?: string } | undefined;
+ const command = args?.command?.replace(/^\//, "").toLowerCase();
+ const sessionID = input.sessionID || getMainSessionID();
+
+ if (command === "ralph-loop" && sessionID) {
+ const rawArgs = args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || "";
+ const taskMatch = rawArgs.match(/^["'](.+?)["']/);
+ const prompt = taskMatch?.[1] || rawArgs.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
+
+ const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i);
+ const promiseMatch = rawArgs.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
+
+ ralphLoop.startLoop(sessionID, prompt, {
+ maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
+ completionPromise: promiseMatch?.[1],
+ });
+ } else if (command === "cancel-ralph" && sessionID) {
+ ralphLoop.cancelLoop(sessionID);
+ }
+ }
},
"tool.execute.after": async (input, output) => {