diff --git a/src/hooks/ralph-loop/completion-promise-detector.ts b/src/hooks/ralph-loop/completion-promise-detector.ts new file mode 100644 index 00000000..8fa1f914 --- /dev/null +++ b/src/hooks/ralph-loop/completion-promise-detector.ts @@ -0,0 +1,90 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { existsSync, readFileSync } from "node:fs" +import { log } from "../../shared/logger" +import { HOOK_NAME } from "./constants" + +interface OpenCodeSessionMessage { + info?: { role?: string } + parts?: Array<{ type: string; text?: string }> +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function buildPromisePattern(promise: string): RegExp { + return new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "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") continue + if (pattern.test(line)) return true + } catch { + continue + } + } + return false + } catch { + return false + } +} + +export async function detectCompletionInSessionMessages( + ctx: PluginInput, + options: { + sessionID: string + promise: string + apiTimeoutMs: number + directory: string + }, +): Promise { + try { + const response = await Promise.race([ + ctx.client.session.messages({ + path: { id: options.sessionID }, + query: { directory: options.directory }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("API timeout")), options.apiTimeoutMs), + ), + ]) + + const messages = (response as { data?: unknown[] }).data ?? [] + if (!Array.isArray(messages)) return false + + const assistantMessages = (messages as OpenCodeSessionMessage[]).filter( + (msg) => msg.info?.role === "assistant", + ) + const lastAssistant = assistantMessages[assistantMessages.length - 1] + if (!lastAssistant?.parts) return false + + const pattern = buildPromisePattern(options.promise) + const responseText = lastAssistant.parts + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join("\n") + + return pattern.test(responseText) + } catch (err) { + log(`[${HOOK_NAME}] Session messages check failed`, { + sessionID: options.sessionID, + error: String(err), + }) + return false + } +} diff --git a/src/hooks/ralph-loop/continuation-prompt-builder.ts b/src/hooks/ralph-loop/continuation-prompt-builder.ts new file mode 100644 index 00000000..b2727b8f --- /dev/null +++ b/src/hooks/ralph-loop/continuation-prompt-builder.ts @@ -0,0 +1,27 @@ +import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" +import type { RalphLoopState } from "./types" + +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}} +- 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 +} diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts new file mode 100644 index 00000000..45e6dba5 --- /dev/null +++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts @@ -0,0 +1,61 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" +import { findNearestMessageWithFields } from "../../features/hook-message-injector" +import { getMessageDir } from "./message-storage-directory" + +type MessageInfo = { + agent?: string + model?: { providerID: string; modelID: string } + modelID?: string + providerID?: string +} + +export async function injectContinuationPrompt( + ctx: PluginInput, + options: { sessionID: string; prompt: string; directory: string }, +): Promise { + let agent: string | undefined + let model: { providerID: string; modelID: string } | undefined + + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: options.sessionID }, + }) + const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }> + 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) + break + } + } + } catch { + const messageDir = getMessageDir(options.sessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agent = currentMessage?.agent + model = + currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { + providerID: currentMessage.model.providerID, + modelID: currentMessage.model.modelID, + } + : undefined + } + + await ctx.client.session.promptAsync({ + path: { id: options.sessionID }, + body: { + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: options.prompt }], + }, + query: { directory: options.directory }, + }) + + log("[ralph-loop] continuation injected", { sessionID: options.sessionID }) +} diff --git a/src/hooks/ralph-loop/loop-session-recovery.ts b/src/hooks/ralph-loop/loop-session-recovery.ts new file mode 100644 index 00000000..517200e5 --- /dev/null +++ b/src/hooks/ralph-loop/loop-session-recovery.ts @@ -0,0 +1,33 @@ +type SessionState = { + isRecovering?: boolean +} + +export function createLoopSessionRecovery(options?: { recoveryWindowMs?: number }) { + const recoveryWindowMs = options?.recoveryWindowMs ?? 5000 + const sessions = new Map() + + 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) + }, + } +} diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts new file mode 100644 index 00000000..402f9297 --- /dev/null +++ b/src/hooks/ralph-loop/loop-state-controller.ts @@ -0,0 +1,81 @@ +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 "../../shared/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 + }, + ): 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, + 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) + }, + } +} diff --git a/src/hooks/ralph-loop/message-storage-directory.ts b/src/hooks/ralph-loop/message-storage-directory.ts new file mode 100644 index 00000000..7d4caca1 --- /dev/null +++ b/src/hooks/ralph-loop/message-storage-directory.ts @@ -0,0 +1,16 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../../features/hook-message-injector" + +export function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + return null +} diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts new file mode 100644 index 00000000..5ba52b87 --- /dev/null +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -0,0 +1,178 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" +import type { RalphLoopOptions, RalphLoopState } from "./types" +import { HOOK_NAME } from "./constants" +import { + detectCompletionInSessionMessages, + detectCompletionInTranscript, +} from "./completion-promise-detector" +import { buildContinuationPrompt } from "./continuation-prompt-builder" +import { injectContinuationPrompt } from "./continuation-prompt-injector" + +type SessionRecovery = { + isRecovering: (sessionID: string) => boolean + markRecovering: (sessionID: string) => void + clear: (sessionID: string) => void +} +type LoopStateController = { getState: () => RalphLoopState | null; clear: () => boolean; incrementIteration: () => 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 => { + const props = event.properties as Record | undefined + + 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 ctx.client.tui.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 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 = 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 ctx.client.tui + .showToast({ + body: { + title: "Ralph Loop", + message: `Iteration ${newState.iteration}/${newState.max_iterations}`, + variant: "info", + duration: 2000, + }, + }) + .catch(() => {}) + + try { + await injectContinuationPrompt(ctx, { + sessionID, + prompt: buildContinuationPrompt(newState), + directory: options.directory, + }) + } 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) + } + } + } +} diff --git a/src/hooks/ralph-loop/ralph-loop-hook.ts b/src/hooks/ralph-loop/ralph-loop-hook.ts index 6be3a5e8..d55a1882 100644 --- a/src/hooks/ralph-loop/ralph-loop-hook.ts +++ b/src/hooks/ralph-loop/ralph-loop-hook.ts @@ -1,60 +1,9 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { existsSync, readFileSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { log } from "../../shared/logger" -import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" -import { readState, writeState, clearState, incrementIteration } from "./storage" -import { - HOOK_NAME, - DEFAULT_MAX_ITERATIONS, - DEFAULT_COMPLETION_PROMISE, -} from "./constants" -import type { RalphLoopState, RalphLoopOptions } from "./types" +import type { RalphLoopOptions, RalphLoopState } from "./types" import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" - -function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - return null -} - -export * from "./types" -export * from "./constants" -export { readState, writeState, clearState, incrementIteration } from "./storage" - -interface SessionState { - isRecovering?: boolean -} - -interface OpenCodeSessionMessage { - info?: { - role?: string - } - parts?: Array<{ - type: string - text?: string - [key: string]: unknown - }> -} - -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}} -- Do not stop until the task is truly done - -Original task: -{{PROMPT}}` +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 @@ -73,356 +22,32 @@ export function createRalphLoopHook( ctx: PluginInput, options?: RalphLoopOptions ): RalphLoopHook { - const sessions = new Map() const config = options?.config const stateDir = config?.state_dir const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT const checkSessionExists = options?.checkSessionExists - function getSessionState(sessionID: string): SessionState { - let state = sessions.get(sessionID) - if (!state) { - state = {} - sessions.set(sessionID, state) - } - return state - } + const loopState = createLoopStateController({ + directory: ctx.directory, + stateDir, + config, + }) + const sessionRecovery = createLoopSessionRecovery() - function detectCompletionPromise( - transcriptPath: string | undefined, - promise: string - ): boolean { - if (!transcriptPath) return false + const event = createRalphLoopEventHandler(ctx, { + directory: ctx.directory, + apiTimeoutMs: apiTimeout, + getTranscriptPath, + checkSessionExists, + sessionRecovery, + loopState, + }) - try { - if (!existsSync(transcriptPath)) return false - - const content = readFileSync(transcriptPath, "utf-8") - const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") - const lines = content.split("\n").filter(l => l.trim()) - - for (const line of lines) { - try { - const entry = JSON.parse(line) - if (entry.type === "user") continue - if (pattern.test(line)) return true - } catch { - continue - } - } - return false - } catch { - return false - } - } - - function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - } - - async function detectCompletionInSessionMessages( - sessionID: string, - promise: string - ): Promise { - try { - const response = await Promise.race([ - ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("API timeout")), apiTimeout) - ), - ]) - - const messages = (response as { data?: unknown[] }).data ?? [] - if (!Array.isArray(messages)) return false - - const assistantMessages = (messages as OpenCodeSessionMessage[]).filter( - (msg) => msg.info?.role === "assistant" - ) - const lastAssistant = assistantMessages[assistantMessages.length - 1] - if (!lastAssistant?.parts) return false - - const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") - const responseText = lastAssistant.parts - .filter((p) => p.type === "text") - .map((p) => p.text ?? "") - .join("\n") - - return pattern.test(responseText) - } catch (err) { - log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) }) - return false - } - } - - const startLoop = ( - sessionID: string, - prompt: string, - loopOptions?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } - ): 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, - 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) { - if (checkSessionExists) { - try { - const originalSessionExists = await checkSessionExists(state.session_id) - if (!originalSessionExists) { - clearState(ctx.directory, stateDir) - 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 = getTranscriptPath(sessionID) - const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise) - - const completionDetectedViaApi = completionDetectedViaTranscript - ? false - : await detectCompletionInSessionMessages(sessionID, state.completion_promise) - - if (completionDetectedViaTranscript || completionDetectedViaApi) { - log(`[${HOOK_NAME}] Completion detected!`, { - sessionID, - iteration: state.iteration, - promise: state.completion_promise, - detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api", - }) - clearState(ctx.directory, stateDir) - - 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 ctx.client.tui - .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, - }) - 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) - - const finalPrompt = newState.ultrawork - ? `ultrawork ${continuationPrompt}` - : continuationPrompt - - await ctx.client.tui - .showToast({ - body: { - title: "Ralph Loop", - message: `Iteration ${newState.iteration}/${newState.max_iterations}`, - variant: "info", - duration: 2000, - }, - }) - .catch(() => {}) - - try { - let agent: string | undefined - let model: { providerID: string; modelID: string } | undefined - - try { - const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> - 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) - break - } - } - } catch { - const messageDir = getMessageDir(sessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agent = currentMessage?.agent - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - } - - await ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { - ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: finalPrompt }], - }, - 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 - const error = props?.error as { name?: string } | undefined - - if (error?.name === "MessageAbortedError") { - if (sessionID) { - const state = readState(ctx.directory, stateDir) - if (state?.session_id === sessionID) { - clearState(ctx.directory, stateDir) - log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID }) - } - sessions.delete(sessionID) - } - return - } - - if (sessionID) { - const sessionState = getSessionState(sessionID) - sessionState.isRecovering = true - setTimeout(() => { - sessionState.isRecovering = false - }, 5000) - } - } - } - - return { - event, - startLoop, - cancelLoop, - getState, - } + return { + event, + startLoop: loopState.startLoop, + cancelLoop: loopState.cancelLoop, + getState: loopState.getState as () => RalphLoopState | null, + } }