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,
+ }
}