// @bun // src/ralph-loop/config.ts import { existsSync, readFileSync } from "fs"; import { homedir } from "os"; import { join } from "path"; function resolveOpenCodeConfigDir() { const xdg = process.env.XDG_CONFIG_HOME; if (xdg && xdg.length > 0) { return join(xdg, "opencode"); } return join(homedir(), ".config", "opencode"); } function stripJsonComments(input) { let out = ""; let i = 0; let inString = null; while (i < input.length) { const ch = input[i]; const next = i + 1 < input.length ? input[i + 1] : ""; if (inString) { out += ch; if (ch === "\\") { 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 === "/") { i += 2; while (i < input.length && input[i] !== ` `) i++; continue; } if (ch === "/" && next === "*") { 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) { if (!existsSync(path)) return null; try { const raw = readFileSync(path, "utf-8"); const parsed = JSON.parse(stripJsonComments(raw)); if (!parsed || typeof parsed !== "object") return null; return parsed; } catch { return null; } } function loadRalphLoopConfig(projectDir) { 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 ?? {} }; } // src/ralph-loop/commands.ts var 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 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.`; var 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.`; var RALPH_LOOP_COMMANDS = { "ralph-loop": { name: "ralph-loop", description: "(builtin) Start self-referential development loop until completion", template: ` ${RALPH_LOOP_TEMPLATE} $ARGUMENTS ` }, "ulw-loop": { name: "ulw-loop", description: "(builtin) Start ultrawork loop - continues until completion with ultrawork mode", template: ` ${RALPH_LOOP_TEMPLATE} $ARGUMENTS ` }, "cancel-ralph": { name: "cancel-ralph", description: "(builtin) Cancel active Ralph Loop", template: ` ${CANCEL_RALPH_TEMPLATE} ` } }; // src/ralph-loop/command-arguments.ts var DEFAULT_PROMPT = "Complete the task as instructed"; function parseRalphLoopArguments(rawArguments) { 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 : undefined }; } // src/ralph-loop/transcript.ts import { join as join3 } from "path"; // src/ralph-loop/claude-config-dir.ts import { homedir as homedir2 } from "os"; import { join as join2 } from "path"; function getClaudeConfigDir() { const envConfigDir = process.env.CLAUDE_CONFIG_DIR; if (envConfigDir) return envConfigDir; return join2(homedir2(), ".claude"); } // src/ralph-loop/transcript.ts var TRANSCRIPT_DIR = join3(getClaudeConfigDir(), "transcripts"); function getTranscriptPath(sessionId) { return join3(TRANSCRIPT_DIR, `${sessionId}.jsonl`); } // src/ralph-loop/loop-session-recovery.ts function createLoopSessionRecovery(options) { const recoveryWindowMs = options?.recoveryWindowMs ?? 5000; const sessions = new Map; function getSessionState(sessionID) { let state = sessions.get(sessionID); if (!state) { state = {}; sessions.set(sessionID, state); } return state; } return { isRecovering(sessionID) { return getSessionState(sessionID).isRecovering === true; }, markRecovering(sessionID) { const state = getSessionState(sessionID); state.isRecovering = true; setTimeout(() => { state.isRecovering = false; }, recoveryWindowMs); }, clear(sessionID) { sessions.delete(sessionID); } }; } // src/ralph-loop/constants.ts var HOOK_NAME = "ralph-loop"; var DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md"; var DEFAULT_MAX_ITERATIONS = 100; var DEFAULT_COMPLETION_PROMISE = "DONE"; // src/ralph-loop/storage.ts import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs"; import { dirname, join as join4 } from "path"; // src/ralph-loop/simple-frontmatter.ts function parseFrontmatter(content) { 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 = {}; 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 = 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 }; } // src/ralph-loop/storage.ts function getStateFilePath(directory, customPath) { return customPath ? join4(directory, customPath) : join4(directory, DEFAULT_STATE_FILE); } function readState(directory, customPath) { const filePath = getStateFilePath(directory, customPath); if (!existsSync2(filePath)) return null; try { const content = readFileSync2(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 (Number.isNaN(iterationNum)) return null; const stripQuotes = (val) => { 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 : undefined }; } catch { return null; } } function writeState(directory, state, customPath) { const filePath = getStateFilePath(directory, customPath); try { const dir = dirname(filePath); if (!existsSync2(dir)) { mkdirSync(dir, { recursive: true }); } const sessionIdLine = state.session_id ? `session_id: "${state.session_id}" ` : ""; const ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork} ` : ""; const strategyLine = state.strategy ? `strategy: "${state.strategy}" ` : ""; const content = `--- active: ${state.active} iteration: ${state.iteration} max_iterations: ${state.max_iterations} completion_promise: "${state.completion_promise}" started_at: "${state.started_at}" ${sessionIdLine}${ultraworkLine}${strategyLine}--- ${state.prompt} `; writeFileSync(filePath, content, "utf-8"); return true; } catch { return false; } } function clearState(directory, customPath) { const filePath = getStateFilePath(directory, customPath); try { if (existsSync2(filePath)) { unlinkSync(filePath); } return true; } catch { return false; } } function incrementIteration(directory, customPath) { const state = readState(directory, customPath); if (!state) return null; state.iteration += 1; return writeState(directory, state, customPath) ? state : null; } // src/ralph-loop/logger.ts import * as fs from "fs"; import * as os from "os"; import * as path from "path"; var logFile = path.join(os.tmpdir(), "opencode-plugin-ralph-loop.log"); function shouldLogToConsole() { const raw = process.env.RALPH_LOOP_DEBUG; if (!raw) return false; return raw === "1" || raw.toLowerCase() === "true" || raw.toLowerCase() === "yes"; } function log(message, meta) { try { const timestamp = new Date().toISOString(); const entry = `[${timestamp}] ${message}${meta !== undefined ? ` ${JSON.stringify(meta)}` : ""} `; fs.appendFileSync(logFile, entry); } catch {} if (shouldLogToConsole()) { try { console.log(message, meta); } catch { console.log(message); } } } // src/ralph-loop/loop-state-controller.ts function createLoopStateController(options) { const directory = options.directory; const stateDir = options.stateDir; const config = options.config; return { startLoop(sessionID, prompt, loopOptions) { const state = { 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) { 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() { return readState(directory, stateDir); }, clear() { return clearState(directory, stateDir); }, incrementIteration() { return incrementIteration(directory, stateDir); }, setSessionID(sessionID) { const state = readState(directory, stateDir); if (!state) return null; state.session_id = sessionID; if (!writeState(directory, state, stateDir)) return null; return state; } }; } // src/ralph-loop/completion-promise-detector.ts import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs"; // src/ralph-loop/with-timeout.ts async function withTimeout(promise, timeoutMs) { let timeoutId; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error("API timeout")); }, timeoutMs); }); try { return await Promise.race([promise, timeoutPromise]); } finally { if (timeoutId !== undefined) { clearTimeout(timeoutId); } } } // src/ralph-loop/completion-promise-detector.ts function isRecord(value) { return typeof value === "object" && value !== null; } function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function buildPromisePattern(promise) { return new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is"); } function detectCompletionInTranscript(transcriptPath, promise) { if (!transcriptPath) return false; try { if (!existsSync3(transcriptPath)) return false; const content = readFileSync3(transcriptPath, "utf-8"); const pattern = buildPromisePattern(promise); const lines = content.split(` `).filter((line) => line.trim()); for (const line of lines) { try { const entry = JSON.parse(line); if (entry.type !== "user" && pattern.test(line)) return true; } catch {} } return false; } catch { return false; } } async function detectCompletionInSessionMessages(ctx, options) { try { const sessionApi = ctx.client.session; 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 = Array.isArray(response) ? response : isRecord(response) && Array.isArray(response.data) ? response.data : isRecord(response) && Array.isArray(response["200"]) ? response["200"] : []; const assistantMessages = messageArray.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 ? ` ` : ""}${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; } } // src/ralph-loop/system-directive.ts var SYSTEM_DIRECTIVE_PREFIX = "[SYSTEM DIRECTIVE: OH-MY-OPENCODE"; // src/ralph-loop/continuation-prompt-builder.ts var 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}} - Do not stop until the task is truly done Original task: {{PROMPT}}`; function buildContinuationPrompt(state) { 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; } // src/ralph-loop/internal-initiator-marker.ts var OMO_INTERNAL_INITIATOR_MARKER = ""; function createInternalAgentTextPart(text) { return { type: "text", text: `${text} ${OMO_INTERNAL_INITIATOR_MARKER}` }; } // src/ralph-loop/normalize-sdk-response.ts function normalizeSDKResponse(response, fallback, options) { const prefer = options?.preferResponseOnMissingData ?? false; if (response && typeof response === "object") { const rec = response; if ("data" in rec) { const d = rec.data; if (d !== undefined) return d; } if ("200" in rec) { const d = rec["200"]; if (d !== undefined) return d; } } if (prefer && response !== undefined) { return response; } return fallback; } // src/ralph-loop/prompt-tools.ts function normalizePromptTools(tools) { if (!tools) return; const normalized = {}; 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; } // src/ralph-loop/continuation-prompt-injector.ts function isRecord2(value) { return typeof value === "object" && value !== null; } function getPromptApi(client) { const clientRecord = client; if (!isRecord2(clientRecord)) return null; const sessionValue = clientRecord.session; if (!isRecord2(sessionValue)) return null; const promptAsyncValue = sessionValue.promptAsync; if (typeof promptAsyncValue === "function") { return (args) => Reflect.apply(promptAsyncValue, sessionValue, [args]); } const promptValue = sessionValue.prompt; if (typeof promptValue === "function") { return (args) => Reflect.apply(promptValue, sessionValue, [args]); } return null; } async function injectContinuationPrompt(ctx, options) { let agent; let model; let tools; const sourceSessionID = options.inheritFromSessionID ?? options.sessionID; try { const sessionApi = ctx.client.session; 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, [], { 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 {} 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 }); } // src/ralph-loop/session-reset-strategy.ts function isRecord3(value) { return typeof value === "object" && value !== null; } async function createIterationSession(ctx, parentSessionID, directory) { const sessionApi = ctx.client.session; if (typeof sessionApi.create !== "function") return null; const createResult = await sessionApi.create({ body: { parentID: parentSessionID, title: "Ralph Loop Iteration" }, query: { directory } }); const data = isRecord3(createResult) ? createResult.data : undefined; const id = isRecord3(data) ? data.id : undefined; const error = isRecord3(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; } async function selectSessionInTui(client, sessionID) { const selectSession = getSelectSessionApi(client); if (!selectSession) return false; try { await selectSession({ body: { sessionID } }); return true; } catch (error) { log("[ralph-loop] Failed to select session in TUI", { sessionID, error: String(error) }); return false; } } function getSelectSessionApi(client) { if (!isRecord3(client)) return null; const tuiValue = client.tui; if (!isRecord3(tuiValue)) return null; const selectSessionValue = tuiValue.selectSession; if (typeof selectSessionValue !== "function") return null; return (args) => Reflect.apply(selectSessionValue, tuiValue, [args]); } // src/ralph-loop/iteration-continuation.ts async function continueIteration(ctx, state, options) { 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 }); } // src/ralph-loop/ralph-loop-event-handler.ts function isRecord4(value) { return typeof value === "object" && value !== null; } function getShowToastApi(client) { if (!isRecord4(client)) return null; const tui = client.tui; if (!isRecord4(tui)) return null; const showToast = tui.showToast; if (typeof showToast !== "function") return null; return (args) => Reflect.apply(showToast, tui, [args]); } function createRalphLoopEventHandler(ctx, options) { return async ({ event }) => { const props = event.properties; const showToast = getShowToastApi(ctx.client); if (event.type === "session.idle") { const sessionID = props?.sessionID; 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; 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; const error = props?.error; 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); } } }; } // src/ralph-loop/ralph-loop-hook.ts var DEFAULT_API_TIMEOUT = 5000; function createRalphLoopHook(ctx, options) { const config = options?.config; const stateDir = config?.state_dir; const getTranscriptPath2 = options?.getTranscriptPath ?? ((id) => getTranscriptPath(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: getTranscriptPath2, checkSessionExists, sessionRecovery, loopState }); return { event, startLoop: loopState.startLoop, cancelLoop: loopState.cancelLoop, getState: loopState.getState }; } // src/index.ts function isRecord5(value) { return typeof value === "object" && value !== null; } var RalphLoopPlugin = async (ctx) => { const ralphConfig = loadRalphLoopConfig(ctx.directory); const ralphLoop = createRalphLoopHook(ctx, { config: ralphConfig, checkSessionExists: async (sessionID) => { try { const sessionApi = ctx.client.session; if (typeof sessionApi.get !== "function") return false; const response = await sessionApi.get({ path: { id: sessionID }, query: { directory: ctx.directory } }); const data = isRecord5(response) && "data" in response ? response.data : isRecord5(response) && ("200" in response) ? response["200"] : response; const id = isRecord5(data) ? data.id : undefined; return typeof id === "string" && id.length > 0; } catch { return false; } } }); return { config: async (config) => { const existing = config.command ?? {}; config.command = { ...RALPH_LOOP_COMMANDS, ...existing }; }, "tool.execute.before": async (input, output) => { 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, output) => { if (!ralphLoop) return; const parts = output.parts; const promptText = parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(` `).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 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) => { await ralphLoop.event(input); } }; }; var src_default = RalphLoopPlugin; export { src_default as default };