Signed-off-by: Dmytro Stanchiev <git@dmytros.dev>
This commit is contained in:
2026-04-19 17:50:34 -04:00
parent 64088cb9bc
commit 07e1c0dd5e
31 changed files with 1573 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
dist/
node_modules/
.DS_Store

80
LICENSE.md Normal file
View File

@@ -0,0 +1,80 @@
# License
Portions of this software are licensed as follows:
- All third party components incorporated into the Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensor's trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The "licensor" is the entity offering these terms.
The "software" is the software the licensor makes available under these terms, including any portion of it.
"You" refers to the individual or entity agreeing to these terms.
"Your company" is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
"Your license" is the license granted to you for the software under these terms.
"Use" means anything you do with the software requiring your license.

10
README.md Normal file
View File

@@ -0,0 +1,10 @@
# opencode-plugin-ralph-loop
Standalone implementation of the **Ralph Loop** (self-referential dev loop) extracted from `oh-my-opencode`.
Provides:
- `/ralph-loop` command
- `/ulw-loop` command (ultrawork mode)
- `/cancel-ralph` command
State is persisted to `.sisyphus/ralph-loop.local.md` in the current project directory.

31
bun.lock Normal file
View File

@@ -0,0 +1,31 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "opencode-plugin-ralph-loop",
"dependencies": {
"@opencode-ai/plugin": "^1.1.19",
},
"devDependencies": {
"bun-types": "1.3.6",
"typescript": "^5.7.3",
},
},
},
"packages": {
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.15", "", { "dependencies": { "@opencode-ai/sdk": "1.2.15", "zod": "4.1.8" } }, "sha512-mh9S05W+CZZmo6q3uIEBubS66QVgiev7fRafX7vemrCfz+3pEIkSwipLjU/sxIewC9yLiDWLqS73DH/iEQzVDw=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.15", "", {}, "sha512-NUJNlyBCdZ4R0EBLjJziEQOp2XbRPJosaMcTcWSWO5XJPKGUpz0u8ql+5cR8K+v2RJ+hp2NobtNwpjEYfe6BRQ=="],
"@types/node": ["@types/node@25.3.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
}
}

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "opencode-plugin-ralph-loop",
"version": "0.1.0",
"description": "Standalone Ralph Loop plugin for OpenCode (extracted from oh-my-opencode)",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"files": [
"dist",
"README.md",
"LICENSE.md"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --target bun --format esm && tsc --emitDeclarationOnly",
"clean": "rm -rf dist",
"prepublishOnly": "bun run clean && bun run build",
"typecheck": "tsc --noEmit"
},
"keywords": [
"opencode",
"plugin",
"ralph-loop",
"continuation"
],
"license": "SUL-1.0",
"dependencies": {
"@opencode-ai/plugin": "^1.1.19"
},
"devDependencies": {
"bun-types": "1.3.6",
"typescript": "^5.7.3"
}
}

134
src/index.ts Normal file
View File

@@ -0,0 +1,134 @@
import type { Plugin } from "@opencode-ai/plugin"
import { loadRalphLoopConfig } from "./ralph-loop/config"
import { RALPH_LOOP_COMMANDS } from "./ralph-loop/commands"
import { parseRalphLoopArguments } from "./ralph-loop/command-arguments"
import { createRalphLoopHook } from "./ralph-loop/ralph-loop-hook"
type ToolExecuteBeforeInput = { tool: string; sessionID: string; callID: string }
type ToolExecuteBeforeOutput = { args: Record<string, unknown> }
type ChatMessagePart = { type: string; text?: string; [key: string]: unknown }
type ChatMessageInput = { sessionID: string; agent?: string; model?: unknown }
type ChatMessageOutput = { message: Record<string, unknown>; parts: ChatMessagePart[] }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
const RalphLoopPlugin: Plugin = async (ctx) => {
const ralphConfig = loadRalphLoopConfig(ctx.directory)
const ralphLoop = createRalphLoopHook(ctx, {
config: ralphConfig,
checkSessionExists: async (sessionID) => {
try {
const sessionApi = ctx.client.session as unknown as {
get?: (args: {
path: { id: string }
query?: { directory: string }
}) => Promise<unknown>
}
if (typeof sessionApi.get !== "function") return false
// Some OpenCode SDK versions require `query.directory`, some ignore it.
const response = await sessionApi.get({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const data =
isRecord(response) && "data" in response
? response.data
: isRecord(response) && "200" in response
? response["200"]
: response
const id = isRecord(data) ? data.id : undefined
return typeof id === "string" && id.length > 0
} catch {
return false
}
},
})
return {
config: async (config: Record<string, unknown>) => {
const existing = (config.command as Record<string, unknown>) ?? {}
// Builtins first; allow user/system overrides to win.
config.command = { ...RALPH_LOOP_COMMANDS, ...existing }
},
"tool.execute.before": async (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => {
if (!ralphLoop) return
if (input.tool !== "skill") return
const rawName = typeof output.args.name === "string" ? output.args.name : undefined
if (!rawName) return
const command = rawName.replace(/^\//, "").toLowerCase()
const sessionID = input.sessionID
if (!sessionID) return
if (command.startsWith("ralph-loop")) {
const rawArgs = rawName.replace(/^\/?(ralph-loop)\s*/i, "")
const parsed = parseRalphLoopArguments(rawArgs)
ralphLoop.startLoop(sessionID, parsed.prompt, {
maxIterations: parsed.maxIterations,
completionPromise: parsed.completionPromise,
strategy: parsed.strategy,
})
} else if (command.startsWith("ulw-loop")) {
const rawArgs = rawName.replace(/^\/?(ulw-loop)\s*/i, "")
const parsed = parseRalphLoopArguments(rawArgs)
ralphLoop.startLoop(sessionID, parsed.prompt, {
ultrawork: true,
maxIterations: parsed.maxIterations,
completionPromise: parsed.completionPromise,
strategy: parsed.strategy,
})
} else if (command.startsWith("cancel-ralph")) {
ralphLoop.cancelLoop(sessionID)
}
},
"chat.message": async (input: ChatMessageInput, output: ChatMessageOutput) => {
if (!ralphLoop) return
const parts = output.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("<user-task>")
const isCancelRalphTemplate = promptText.includes(
"Cancel the currently active Ralph Loop",
)
if (isRalphLoopTemplate) {
const taskMatch = promptText.match(/<user-task>\s*([\s\S]*?)\s*<\/user-task>/i)
const rawTask = taskMatch?.[1]?.trim() || ""
const parsed = parseRalphLoopArguments(rawTask)
ralphLoop.startLoop(input.sessionID, parsed.prompt, {
maxIterations: parsed.maxIterations,
completionPromise: parsed.completionPromise,
strategy: parsed.strategy,
})
} else if (isCancelRalphTemplate) {
ralphLoop.cancelLoop(input.sessionID)
}
},
event: async (input: { event: { type: string; properties?: unknown } }) => {
await ralphLoop.event(input)
},
}
}
export default RalphLoopPlugin

View File

@@ -0,0 +1,8 @@
import { homedir } from "node:os"
import { join } from "node:path"
export function getClaudeConfigDir(): string {
const envConfigDir = process.env.CLAUDE_CONFIG_DIR
if (envConfigDir) return envConfigDir
return join(homedir(), ".claude")
}

View File

@@ -0,0 +1,38 @@
export type RalphLoopStrategy = "reset" | "continue"
export type ParsedRalphLoopArguments = {
prompt: string
maxIterations?: number
completionPromise?: string
strategy?: RalphLoopStrategy
}
const DEFAULT_PROMPT = "Complete the task as instructed"
export function parseRalphLoopArguments(rawArguments: string): ParsedRalphLoopArguments {
const taskMatch = rawArguments.match(/^("|')(.+?)\1/)
const promptCandidate =
taskMatch?.[2] ??
(rawArguments.startsWith("--")
? ""
: rawArguments.split(/\s+--/)[0]?.trim() ?? "")
const prompt = promptCandidate || DEFAULT_PROMPT
const maxIterationMatch = rawArguments.match(/--max-iterations=(\d+)/i)
const maxIterationsRaw = maxIterationMatch?.[1]
const completionPromiseQuoted = rawArguments.match(/--completion-promise=("|')(.+?)\1/i)
const completionPromiseUnquoted = rawArguments.match(/--completion-promise=([^\s\"']+)/i)
const completionPromise = completionPromiseQuoted?.[2] ?? completionPromiseUnquoted?.[1]
const strategyMatch = rawArguments.match(/--strategy=(reset|continue)/i)
const strategyValue = strategyMatch?.[1]?.toLowerCase()
return {
prompt,
maxIterations: maxIterationsRaw ? Number.parseInt(maxIterationsRaw, 10) : undefined,
completionPromise,
strategy:
strategyValue === "reset" || strategyValue === "continue"
? (strategyValue as RalphLoopStrategy)
: undefined,
}
}

View File

@@ -0,0 +1,56 @@
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: \`<promise>{{COMPLETION_PROMISE}}</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 your completion promise tag 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] [--strategy=reset|continue]\`
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.`
export const RALPH_LOOP_COMMANDS: Record<string, unknown> = {
"ralph-loop": {
name: "ralph-loop",
description: "(builtin) Start self-referential development loop until completion",
template: `<command-instruction>\n${RALPH_LOOP_TEMPLATE}\n</command-instruction>\n\n<user-task>\n$ARGUMENTS\n</user-task>`,
},
"ulw-loop": {
name: "ulw-loop",
description: "(builtin) Start ultrawork loop - continues until completion with ultrawork mode",
template: `<command-instruction>\n${RALPH_LOOP_TEMPLATE}\n</command-instruction>\n\n<user-task>\n$ARGUMENTS\n</user-task>`,
},
"cancel-ralph": {
name: "cancel-ralph",
description: "(builtin) Cancel active Ralph Loop",
template: `<command-instruction>\n${CANCEL_RALPH_TEMPLATE}\n</command-instruction>`,
},
}

View File

@@ -0,0 +1,105 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { existsSync, readFileSync } from "node:fs"
import { log } from "./logger"
import { HOOK_NAME } from "./constants"
import { withTimeout } from "./with-timeout"
interface OpenCodeSessionMessage {
info?: { role?: string }
parts?: Array<{ type: string; text?: string }>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function buildPromisePattern(promise: string): RegExp {
return new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
}
export function detectCompletionInTranscript(transcriptPath: string | undefined, promise: string): boolean {
if (!transcriptPath) return false
try {
if (!existsSync(transcriptPath)) return false
const content = readFileSync(transcriptPath, "utf-8")
const pattern = buildPromisePattern(promise)
const lines = content.split("\n").filter((line) => line.trim())
for (const line of lines) {
try {
const entry = JSON.parse(line) as { type?: string }
if (entry.type !== "user" && pattern.test(line)) return true
} catch {
// ignore malformed lines
}
}
return false
} catch {
return false
}
}
export async function detectCompletionInSessionMessages(
ctx: PluginInput,
options: { sessionID: string; promise: string; apiTimeoutMs: number; directory: string },
): Promise<boolean> {
try {
const sessionApi = ctx.client.session as unknown as {
messages?: (args: {
path: { id: string }
query?: { directory: string }
}) => Promise<unknown>
}
if (typeof sessionApi.messages !== "function") return false
const response = await withTimeout(
sessionApi.messages({
path: { id: options.sessionID },
query: { directory: options.directory },
}),
options.apiTimeoutMs,
)
const messageArray: unknown[] = Array.isArray(response)
? response
: isRecord(response) && Array.isArray(response.data)
? (response.data as unknown[])
: isRecord(response) && Array.isArray(response["200"])
? (response["200"] as unknown[])
: []
const assistantMessages = (messageArray as OpenCodeSessionMessage[]).filter(
(msg) => msg.info?.role === "assistant",
)
if (assistantMessages.length === 0) return false
const pattern = buildPromisePattern(options.promise)
const recentAssistants = assistantMessages.slice(-3)
for (const assistant of recentAssistants) {
if (!assistant.parts) continue
let responseText = ""
for (const part of assistant.parts) {
if (part.type !== "text") continue
responseText += `${responseText ? "\n" : ""}${part.text ?? ""}`
}
if (pattern.test(responseText)) {
return true
}
}
return false
} catch (err) {
setTimeout(() => {
log(`[${HOOK_NAME}] Session messages check failed`, {
sessionID: options.sessionID,
error: String(err),
})
}, 0)
return false
}
}

106
src/ralph-loop/config.ts Normal file
View File

@@ -0,0 +1,106 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
export type RalphLoopStrategy = "reset" | "continue"
export type RalphLoopConfig = {
enabled?: boolean
default_max_iterations?: number
state_dir?: string
default_strategy?: RalphLoopStrategy
}
function resolveOpenCodeConfigDir(): string {
const xdg = process.env.XDG_CONFIG_HOME
if (xdg && xdg.length > 0) {
return join(xdg, "opencode")
}
return join(homedir(), ".config", "opencode")
}
function stripJsonComments(input: string): string {
// Minimal JSONC stripper that preserves strings.
let out = ""
let i = 0
let inString: '"' | "'" | null = null
while (i < input.length) {
const ch = input[i]
const next = i + 1 < input.length ? input[i + 1] : ""
if (inString) {
out += ch
if (ch === "\\") {
// Skip escaped char.
if (i + 1 < input.length) {
out += input[i + 1]
i += 2
continue
}
}
if (ch === inString) {
inString = null
}
i++
continue
}
if (ch === '"' || ch === "'") {
inString = ch
out += ch
i++
continue
}
if (ch === "/" && next === "/") {
// Line comment.
i += 2
while (i < input.length && input[i] !== "\n") i++
continue
}
if (ch === "/" && next === "*") {
// Block comment.
i += 2
while (i < input.length) {
if (input[i] === "*" && i + 1 < input.length && input[i + 1] === "/") {
i += 2
break
}
i++
}
continue
}
out += ch
i++
}
return out
}
function tryReadConfig(path: string): RalphLoopConfig | null {
if (!existsSync(path)) return null
try {
const raw = readFileSync(path, "utf-8")
const parsed = JSON.parse(stripJsonComments(raw)) as unknown
if (!parsed || typeof parsed !== "object") return null
return parsed as RalphLoopConfig
} catch {
return null
}
}
export function loadRalphLoopConfig(projectDir: string): RalphLoopConfig {
const userDir = resolveOpenCodeConfigDir()
const user =
tryReadConfig(join(userDir, "ralph-loop.jsonc")) ??
tryReadConfig(join(userDir, "ralph-loop.json"))
const project =
tryReadConfig(join(projectDir, ".opencode", "ralph-loop.jsonc")) ??
tryReadConfig(join(projectDir, ".opencode", "ralph-loop.json"))
return {
...(user ?? {}),
...(project ?? {}),
}
}

View File

@@ -0,0 +1,4 @@
export const HOOK_NAME = "ralph-loop"
export const DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md"
export const DEFAULT_MAX_ITERATIONS = 100
export const DEFAULT_COMPLETION_PROMISE = "DONE"

View File

@@ -0,0 +1,24 @@
import type { RalphLoopState } from "./types"
import { SYSTEM_DIRECTIVE_PREFIX } from "./system-directive"
const CONTINUATION_PROMPT = `${SYSTEM_DIRECTIVE_PREFIX} - RALPH LOOP {{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>{{PROMISE}}</promise>
- Do not stop until the task is truly done
Original task:
{{PROMPT}}`
export function buildContinuationPrompt(state: RalphLoopState): string {
const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(state.iteration))
.replace("{{MAX}}", String(state.max_iterations))
.replace("{{PROMISE}}", state.completion_promise)
.replace("{{PROMPT}}", state.prompt)
return state.ultrawork ? `ultrawork ${continuationPrompt}` : continuationPrompt
}

View File

@@ -0,0 +1,129 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "./logger"
import { createInternalAgentTextPart } from "./internal-initiator-marker"
import { normalizeSDKResponse } from "./normalize-sdk-response"
import { normalizePromptTools, type PromptToolPermission } from "./prompt-tools"
import { withTimeout } from "./with-timeout"
type MessageInfo = {
agent?: string
model?: { providerID: string; modelID: string }
modelID?: string
providerID?: string
tools?: Record<string, PromptToolPermission>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
type PromptArgs = {
path: { id: string }
body: {
parts: Array<{ type: "text"; text: string }>
agent?: string
model?: { providerID: string; modelID: string }
tools?: Record<string, boolean>
}
query?: { directory: string }
}
type PromptApi = (args: PromptArgs) => Promise<unknown>
function getPromptApi(
client: PluginInput["client"],
): PromptApi | null {
const clientRecord = client as unknown
if (!isRecord(clientRecord)) return null
const sessionValue = clientRecord.session
if (!isRecord(sessionValue)) return null
const promptAsyncValue = sessionValue.promptAsync
if (typeof promptAsyncValue === "function") {
return (args) => Reflect.apply(promptAsyncValue, sessionValue, [args]) as Promise<unknown>
}
const promptValue = sessionValue.prompt
if (typeof promptValue === "function") {
return (args) => Reflect.apply(promptValue, sessionValue, [args]) as Promise<unknown>
}
return null
}
export async function injectContinuationPrompt(
ctx: PluginInput,
options: {
sessionID: string
prompt: string
directory: string
apiTimeoutMs: number
inheritFromSessionID?: string
},
): Promise<void> {
let agent: string | undefined
let model: { providerID: string; modelID: string } | undefined
let tools: Record<string, PromptToolPermission> | undefined
const sourceSessionID = options.inheritFromSessionID ?? options.sessionID
try {
const sessionApi = ctx.client.session as unknown as {
messages?: (args: {
path: { id: string }
query?: { directory: string }
}) => Promise<unknown>
}
if (typeof sessionApi.messages !== "function") {
throw new Error("OpenCode client missing session.messages")
}
const messagesResp = await withTimeout(
sessionApi.messages({
path: { id: sourceSessionID },
query: { directory: options.directory },
}),
options.apiTimeoutMs,
)
const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>, {
preferResponseOnMissingData: true,
})
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i]?.info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
agent = info.agent
model =
info.model ??
(info.providerID && info.modelID
? { providerID: info.providerID, modelID: info.modelID }
: undefined)
tools = info.tools
break
}
}
} catch {
// Best effort. If we cannot inherit message context, still inject prompt.
}
const inheritedTools = normalizePromptTools(tools)
const promptApi = getPromptApi(ctx.client)
if (!promptApi) {
throw new Error("OpenCode client missing session.prompt/promptAsync")
}
await promptApi({
path: { id: options.sessionID },
body: {
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
parts: [createInternalAgentTextPart(options.prompt)],
},
query: { directory: options.directory },
})
log("[ralph-loop] continuation injected", { sessionID: options.sessionID })
}

View File

@@ -0,0 +1,8 @@
export const OMO_INTERNAL_INITIATOR_MARKER = "<!-- OMO_INTERNAL_INITIATOR -->"
export function createInternalAgentTextPart(text: string): { type: "text"; text: string } {
return {
type: "text",
text: `${text}\n${OMO_INTERNAL_INITIATOR_MARKER}`,
}
}

View File

@@ -0,0 +1,60 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { RalphLoopState } from "./types"
import { log } from "./logger"
import { HOOK_NAME } from "./constants"
import { buildContinuationPrompt } from "./continuation-prompt-builder"
import { injectContinuationPrompt } from "./continuation-prompt-injector"
import { createIterationSession, selectSessionInTui } from "./session-reset-strategy"
type ContinuationOptions = {
directory: string
apiTimeoutMs: number
previousSessionID: string
loopState: { setSessionID: (sessionID: string) => RalphLoopState | null }
}
export async function continueIteration(
ctx: PluginInput,
state: RalphLoopState,
options: ContinuationOptions,
): Promise<void> {
const strategy = state.strategy ?? "continue"
const continuationPrompt = buildContinuationPrompt(state)
if (strategy === "reset") {
const newSessionID = await createIterationSession(
ctx,
options.previousSessionID,
options.directory,
)
if (!newSessionID) return
await injectContinuationPrompt(ctx, {
sessionID: newSessionID,
inheritFromSessionID: options.previousSessionID,
prompt: continuationPrompt,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
})
await selectSessionInTui(ctx.client, newSessionID)
const boundState = options.loopState.setSessionID(newSessionID)
if (!boundState) {
log(`[${HOOK_NAME}] Failed to bind loop state to new session`, {
previousSessionID: options.previousSessionID,
newSessionID,
})
return
}
return
}
await injectContinuationPrompt(ctx, {
sessionID: options.previousSessionID,
prompt: continuationPrompt,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
})
}

35
src/ralph-loop/logger.ts Normal file
View File

@@ -0,0 +1,35 @@
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
const logFile = path.join(os.tmpdir(), "opencode-plugin-ralph-loop.log")
function shouldLogToConsole(): boolean {
const raw = process.env.RALPH_LOOP_DEBUG
if (!raw) return false
return raw === "1" || raw.toLowerCase() === "true" || raw.toLowerCase() === "yes"
}
export function log(message: string, meta?: unknown): void {
try {
const timestamp = new Date().toISOString()
const entry = `[${timestamp}] ${message}${meta !== undefined ? ` ${JSON.stringify(meta)}` : ""}\n`
fs.appendFileSync(logFile, entry)
} catch {
// ignore
}
if (shouldLogToConsole()) {
try {
// eslint-disable-next-line no-console
console.log(message, meta)
} catch {
// eslint-disable-next-line no-console
console.log(message)
}
}
}
export function getLogFilePath(): string {
return logFile
}

View File

@@ -0,0 +1,33 @@
type SessionState = {
isRecovering?: boolean
}
export function createLoopSessionRecovery(options?: { recoveryWindowMs?: number }) {
const recoveryWindowMs = options?.recoveryWindowMs ?? 5000
const sessions = new Map<string, SessionState>()
function getSessionState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
state = {}
sessions.set(sessionID, state)
}
return state
}
return {
isRecovering(sessionID: string): boolean {
return getSessionState(sessionID).isRecovering === true
},
markRecovering(sessionID: string): void {
const state = getSessionState(sessionID)
state.isRecovering = true
setTimeout(() => {
state.isRecovering = false
}, recoveryWindowMs)
},
clear(sessionID: string): void {
sessions.delete(sessionID)
},
}
}

View File

@@ -0,0 +1,90 @@
import type { RalphLoopOptions, RalphLoopState } from "./types"
import {
DEFAULT_COMPLETION_PROMISE,
DEFAULT_MAX_ITERATIONS,
HOOK_NAME,
} from "./constants"
import { clearState, incrementIteration, readState, writeState } from "./storage"
import { log } from "./logger"
export function createLoopStateController(options: {
directory: string
stateDir: string | undefined
config: RalphLoopOptions["config"] | undefined
}) {
const directory = options.directory
const stateDir = options.stateDir
const config = options.config
return {
startLoop(
sessionID: string,
prompt: string,
loopOptions?: {
maxIterations?: number
completionPromise?: string
ultrawork?: boolean
strategy?: "reset" | "continue"
},
): 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,
ultrawork: loopOptions?.ultrawork,
strategy: loopOptions?.strategy ?? config?.default_strategy ?? "continue",
started_at: new Date().toISOString(),
prompt,
session_id: sessionID,
}
const success = writeState(directory, state, stateDir)
if (success) {
log(`[${HOOK_NAME}] Loop started`, {
sessionID,
maxIterations: state.max_iterations,
completionPromise: state.completion_promise,
})
}
return success
},
cancelLoop(sessionID: string): boolean {
const state = readState(directory, stateDir)
if (!state || state.session_id !== sessionID) {
return false
}
const success = clearState(directory, stateDir)
if (success) {
log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration })
}
return success
},
getState(): RalphLoopState | null {
return readState(directory, stateDir)
},
clear(): boolean {
return clearState(directory, stateDir)
},
incrementIteration(): RalphLoopState | null {
return incrementIteration(directory, stateDir)
},
setSessionID(sessionID: string): RalphLoopState | null {
const state = readState(directory, stateDir)
if (!state) return null
state.session_id = sessionID
if (!writeState(directory, state, stateDir)) return null
return state
},
}
}

View File

@@ -0,0 +1,22 @@
export function normalizeSDKResponse<T>(
response: unknown,
fallback: T,
options?: { preferResponseOnMissingData?: boolean },
): T {
const prefer = options?.preferResponseOnMissingData ?? false
if (response && typeof response === "object") {
const rec = response as Record<string, unknown>
if ("data" in rec) {
const d = rec.data
if (d !== undefined) return d as T
}
if ("200" in rec) {
const d = rec["200"]
if (d !== undefined) return d as T
}
}
if (prefer && response !== undefined) {
return response as T
}
return fallback
}

View File

@@ -0,0 +1,18 @@
export type PromptToolPermission = boolean | "allow" | "deny" | "ask"
export function normalizePromptTools(
tools: Record<string, PromptToolPermission> | undefined,
): Record<string, boolean> | undefined {
if (!tools) return undefined
const normalized: Record<string, boolean> = {}
for (const [toolName, permission] of Object.entries(tools)) {
if (permission === false || permission === "deny") {
normalized[toolName] = false
continue
}
if (permission === true || permission === "allow" || permission === "ask") {
normalized[toolName] = true
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined
}

View File

@@ -0,0 +1,221 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "./logger"
import type { RalphLoopOptions, RalphLoopState } from "./types"
import { HOOK_NAME } from "./constants"
import {
detectCompletionInSessionMessages,
detectCompletionInTranscript,
} from "./completion-promise-detector"
import { continueIteration } from "./iteration-continuation"
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
type ToastBody = {
title: string
message: string
variant: string
duration: number
}
type ShowToastApi = (args: { body: ToastBody }) => Promise<unknown>
function getShowToastApi(client: unknown): ShowToastApi | null {
if (!isRecord(client)) return null
const tui = client.tui
if (!isRecord(tui)) return null
const showToast = tui.showToast
if (typeof showToast !== "function") return null
return (args) => Reflect.apply(showToast, tui, [args]) as Promise<unknown>
}
type SessionRecovery = {
isRecovering: (sessionID: string) => boolean
markRecovering: (sessionID: string) => void
clear: (sessionID: string) => void
}
type LoopStateController = {
getState: () => RalphLoopState | null
clear: () => boolean
incrementIteration: () => RalphLoopState | null
setSessionID: (sessionID: string) => RalphLoopState | null
}
type RalphLoopEventHandlerOptions = {
directory: string
apiTimeoutMs: number
getTranscriptPath: (sessionID: string) => string | undefined
checkSessionExists?: RalphLoopOptions["checkSessionExists"]
sessionRecovery: SessionRecovery
loopState: LoopStateController
}
export function createRalphLoopEventHandler(ctx: PluginInput, options: RalphLoopEventHandlerOptions) {
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
const showToast = getShowToastApi(ctx.client)
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
if (options.sessionRecovery.isRecovering(sessionID)) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
return
}
const state = options.loopState.getState()
if (!state || !state.active) return
if (state.session_id && state.session_id !== sessionID) {
if (options.checkSessionExists) {
try {
const exists = await options.checkSessionExists(state.session_id)
if (!exists) {
options.loopState.clear()
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
orphanedSessionId: state.session_id,
currentSessionId: sessionID,
})
return
}
} catch (err) {
log(`[${HOOK_NAME}] Failed to check session existence`, {
sessionId: state.session_id,
error: String(err),
})
}
}
return
}
const transcriptPath = options.getTranscriptPath(sessionID)
const completionViaTranscript = detectCompletionInTranscript(
transcriptPath,
state.completion_promise,
)
const completionViaApi = completionViaTranscript
? false
: await detectCompletionInSessionMessages(ctx, {
sessionID,
promise: state.completion_promise,
apiTimeoutMs: options.apiTimeoutMs,
directory: options.directory,
})
if (completionViaTranscript || completionViaApi) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionViaTranscript ? "transcript_file" : "session_messages_api",
})
options.loopState.clear()
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
const message = state.ultrawork
? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)`
: `Task completed after ${state.iteration} iteration(s)`
await showToast
?.({ body: { title, message, 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,
})
options.loopState.clear()
await showToast
?.({
body: {
title: "Ralph Loop Stopped",
message: `Max iterations (${state.max_iterations}) reached without completion`,
variant: "warning",
duration: 5000,
},
})
.catch(() => {})
return
}
const newState = options.loopState.incrementIteration()
if (!newState) {
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
return
}
log(`[${HOOK_NAME}] Continuing loop`, {
sessionID,
iteration: newState.iteration,
max: newState.max_iterations,
})
await showToast
?.({
body: {
title: "Ralph Loop",
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
variant: "info",
duration: 2000,
},
})
.catch(() => {})
try {
await continueIteration(ctx, newState, {
previousSessionID: sessionID,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState,
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject continuation`, {
sessionID,
error: String(err),
})
}
return
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (!sessionInfo?.id) return
const state = options.loopState.getState()
if (state?.session_id === sessionInfo.id) {
options.loopState.clear()
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id })
}
options.sessionRecovery.clear(sessionInfo.id)
return
}
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const error = props?.error as { name?: string } | undefined
if (error?.name === "MessageAbortedError") {
if (sessionID) {
const state = options.loopState.getState()
if (state?.session_id === sessionID) {
options.loopState.clear()
log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
}
options.sessionRecovery.clear(sessionID)
}
return
}
if (sessionID) {
options.sessionRecovery.markRecovering(sessionID)
}
}
}
}

View File

@@ -0,0 +1,56 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { RalphLoopOptions, RalphLoopState } from "./types"
import { getTranscriptPath as getDefaultTranscriptPath } from "./transcript"
import { createLoopSessionRecovery } from "./loop-session-recovery"
import { createLoopStateController } from "./loop-state-controller"
import { createRalphLoopEventHandler } from "./ralph-loop-event-handler"
export interface RalphLoopHook {
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
startLoop: (
sessionID: string,
prompt: string,
options?: {
maxIterations?: number
completionPromise?: string
ultrawork?: boolean
strategy?: "reset" | "continue"
},
) => boolean
cancelLoop: (sessionID: string) => boolean
getState: () => RalphLoopState | null
}
const DEFAULT_API_TIMEOUT = 5000 as const
export function createRalphLoopHook(ctx: PluginInput, options?: RalphLoopOptions): RalphLoopHook {
const config = options?.config
const stateDir = config?.state_dir
const getTranscriptPath = options?.getTranscriptPath ?? ((id: string) => getDefaultTranscriptPath(id))
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
const checkSessionExists = options?.checkSessionExists
const loopState = createLoopStateController({
directory: ctx.directory,
stateDir,
config,
})
const sessionRecovery = createLoopSessionRecovery()
const event = createRalphLoopEventHandler(ctx, {
directory: ctx.directory,
apiTimeoutMs: apiTimeout,
getTranscriptPath,
checkSessionExists,
sessionRecovery,
loopState,
})
return {
event,
startLoop: loopState.startLoop,
cancelLoop: loopState.cancelLoop,
getState: loopState.getState as () => RalphLoopState | null,
}
}

View File

@@ -0,0 +1,69 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "./logger"
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
export async function createIterationSession(
ctx: PluginInput,
parentSessionID: string,
directory: string,
): Promise<string | null> {
const sessionApi = ctx.client.session as unknown as {
create?: (args: {
body: { parentID: string; title?: string }
query?: { directory: string }
}) => Promise<unknown>
}
if (typeof sessionApi.create !== "function") return null
const createResult = await sessionApi.create({
body: { parentID: parentSessionID, title: "Ralph Loop Iteration" },
query: { directory },
})
const data = isRecord(createResult) ? (createResult.data as unknown) : undefined
const id = isRecord(data) ? (data.id as unknown) : undefined
const error = isRecord(createResult) ? createResult.error : undefined
if (error !== undefined || typeof id !== "string" || id.length === 0) {
log("[ralph-loop] Failed to create iteration session", {
parentSessionID,
error: String(error ?? "No session ID returned"),
})
return null
}
return id
}
export async function selectSessionInTui(
client: PluginInput["client"],
sessionID: string,
): Promise<boolean> {
const selectSession = getSelectSessionApi(client)
if (!selectSession) return false
try {
await selectSession({ body: { sessionID } })
return true
} catch (error: unknown) {
log("[ralph-loop] Failed to select session in TUI", {
sessionID,
error: String(error),
})
return false
}
}
type SelectSessionApi = (args: { body: { sessionID: string } }) => Promise<unknown>
function getSelectSessionApi(client: unknown): SelectSessionApi | null {
if (!isRecord(client)) return null
const tuiValue = client.tui
if (!isRecord(tuiValue)) return null
const selectSessionValue = tuiValue.selectSession
if (typeof selectSessionValue !== "function") return null
return (args) => Reflect.apply(selectSessionValue, tuiValue, [args]) as Promise<unknown>
}

View File

@@ -0,0 +1,36 @@
export function parseFrontmatter(content: string): {
data: Record<string, unknown>
body: string
} {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n([\s\S]*)$/
const match = content.match(frontmatterRegex)
if (!match) {
return { data: {}, body: content }
}
const yaml = match[1] ?? ""
const body = match[2] ?? ""
const data: Record<string, unknown> = {}
for (const rawLine of yaml.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line) continue
const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/)
if (!m) continue
const key = m[1]
if (!key) continue
let value: unknown = m[2] ?? ""
if (typeof value === "string") {
const v = value.trim()
if (v === "true") value = true
else if (v === "false") value = false
else if (/^-?\d+(\.\d+)?$/.test(v)) value = Number(v)
else value = v
}
data[key] = value
}
return { data, body }
}

96
src/ralph-loop/storage.ts Normal file
View File

@@ -0,0 +1,96 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import type { RalphLoopState } from "./types"
import {
DEFAULT_COMPLETION_PROMISE,
DEFAULT_MAX_ITERATIONS,
DEFAULT_STATE_FILE,
} from "./constants"
import { parseFrontmatter } from "./simple-frontmatter"
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 as unknown)
const iteration = (data.iteration as unknown)
if (active === undefined || iteration === undefined) return null
const isActive = active === true || active === "true"
const iterationNum = typeof iteration === "number" ? iteration : Number(iteration)
if (Number.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,
ultrawork:
data.ultrawork === true || data.ultrawork === "true" ? true : undefined,
strategy:
data.strategy === "reset" || data.strategy === "continue"
? (data.strategy as "reset" | "continue")
: 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 ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}\n` : ""
const strategyLine = state.strategy ? `strategy: "${state.strategy}"\n` : ""
const content = `---\nactive: ${state.active}\niteration: ${state.iteration}\nmax_iterations: ${state.max_iterations}\ncompletion_promise: "${state.completion_promise}"\nstarted_at: "${state.started_at}"\n${sessionIdLine}${ultraworkLine}${strategyLine}---\n${state.prompt}\n`
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
return writeState(directory, state, customPath) ? state : null
}

View File

@@ -0,0 +1 @@
export const SYSTEM_DIRECTIVE_PREFIX = "[SYSTEM DIRECTIVE: OH-MY-OPENCODE"

View File

@@ -0,0 +1,8 @@
import { join } from "node:path"
import { getClaudeConfigDir } from "./claude-config-dir"
const TRANSCRIPT_DIR = join(getClaudeConfigDir(), "transcripts")
export function getTranscriptPath(sessionId: string): string {
return join(TRANSCRIPT_DIR, `${sessionId}.jsonl`)
}

20
src/ralph-loop/types.ts Normal file
View File

@@ -0,0 +1,20 @@
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
ultrawork?: boolean
strategy?: "reset" | "continue"
}
export interface RalphLoopOptions {
config?: RalphLoopConfig
getTranscriptPath?: (sessionId: string) => string | undefined
apiTimeout?: number
checkSessionExists?: (sessionId: string) => Promise<boolean>
}

View File

@@ -0,0 +1,17 @@
export async function withTimeout<TData>(promise: Promise<TData>, timeoutMs: number): Promise<TData> {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error("API timeout"))
}, timeoutMs)
})
try {
return await Promise.race([promise, timeoutPromise])
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
}
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"types": ["bun-types"],
"noUncheckedIndexedAccess": true
},
"include": ["src/**/*.ts"]
}