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) => {