Files
oh-my-opencode/src/hooks/ralph-loop/index.ts
T
justsisyphus c9f762f980 fix(hooks): use API instead of filesystem to resolve model info for session.prompt
Previously, continuation hooks (todo-continuation, boulder-continuation, ralph-loop)
and background tasks resolved model info from filesystem cache, which could be stale
or missing. This caused session.prompt to fallback to default model (Sonnet) instead
of using the originally configured model (e.g., Opus).

Now all session.prompt calls first try API (session.messages) to get current model
info, with filesystem as fallback if API fails.

Affected files:
- todo-continuation-enforcer.ts
- sisyphus-orchestrator/index.ts
- ralph-loop/index.ts
- background-agent/manager.ts
- sisyphus-task/tools.ts
- hook-message-injector/index.ts (export ToolPermission type)
2026-01-16 02:33:44 +09:00

405 lines
12 KiB
TypeScript

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 { readState, writeState, clearState, incrementIteration } from "./storage"
import {
HOOK_NAME,
DEFAULT_MAX_ITERATIONS,
DEFAULT_COMPLETION_PROMISE,
} from "./constants"
import type { RalphLoopState, RalphLoopOptions } 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 = `[RALPH LOOP - ITERATION {{ITERATION}}/{{MAX}}]
Your previous attempt did not output the completion promise. Continue working on the task.
IMPORTANT:
- Review your progress so far
- Continue from where you left off
- When FULLY complete, output: <promise>{{PROMISE}}</promise>
- Do not stop until the task is truly done
Original task:
{{PROMPT}}`
export interface RalphLoopHook {
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
startLoop: (
sessionID: string,
prompt: string,
options?: { maxIterations?: number; completionPromise?: string }
) => boolean
cancelLoop: (sessionID: string) => boolean
getState: () => RalphLoopState | null
}
const DEFAULT_API_TIMEOUT = 3000
export function createRalphLoopHook(
ctx: PluginInput,
options?: RalphLoopOptions
): RalphLoopHook {
const sessions = new Map<string, SessionState>()
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
}
function detectCompletionPromise(
transcriptPath: string | undefined,
promise: string
): boolean {
if (!transcriptPath) return false
try {
if (!existsSync(transcriptPath)) return false
const content = readFileSync(transcriptPath, "utf-8")
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
return pattern.test(content)
} catch {
return false
}
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
async function detectCompletionInSessionMessages(
sessionID: string,
promise: string
): Promise<boolean> {
try {
const response = await Promise.race([
ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
}),
new Promise<never>((_, 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(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "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 }
): boolean => {
const state: RalphLoopState = {
active: true,
iteration: 1,
max_iterations:
loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS,
completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE,
started_at: new Date().toISOString(),
prompt,
session_id: sessionID,
}
const success = writeState(ctx.directory, state, stateDir)
if (success) {
log(`[${HOOK_NAME}] Loop started`, {
sessionID,
maxIterations: state.max_iterations,
completionPromise: state.completion_promise,
})
}
return success
}
const cancelLoop = (sessionID: string): boolean => {
const state = readState(ctx.directory, stateDir)
if (!state || state.session_id !== sessionID) {
return false
}
const success = clearState(ctx.directory, stateDir)
if (success) {
log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration })
}
return success
}
const getState = (): RalphLoopState | null => {
return readState(ctx.directory, stateDir)
}
const event = async ({
event,
}: {
event: { type: string; properties?: unknown }
}): Promise<void> => {
const props = event.properties as Record<string, unknown> | 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)
await ctx.client.tui
.showToast({
body: {
title: "Ralph Loop Complete!",
message: `Task completed after ${state.iteration} iteration(s)`,
variant: "success",
duration: 5000,
},
})
.catch(() => {})
return
}
if (state.iteration >= state.max_iterations) {
log(`[${HOOK_NAME}] Max iterations reached`, {
sessionID,
iteration: state.iteration,
max: state.max_iterations,
})
clearState(ctx.directory, stateDir)
await ctx.client.tui
.showToast({
body: {
title: "Ralph Loop Stopped",
message: `Max iterations (${state.max_iterations}) reached without completion`,
variant: "warning",
duration: 5000,
},
})
.catch(() => {})
return
}
const newState = incrementIteration(ctx.directory, stateDir)
if (!newState) {
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
return
}
log(`[${HOOK_NAME}] Continuing loop`, {
sessionID,
iteration: newState.iteration,
max: newState.max_iterations,
})
const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(newState.iteration))
.replace("{{MAX}}", String(newState.max_iterations))
.replace("{{PROMISE}}", newState.completion_promise)
.replace("{{PROMPT}}", newState.prompt)
await ctx.client.tui
.showToast({
body: {
title: "Ralph Loop",
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
variant: "info",
duration: 2000,
},
})
.catch(() => {})
try {
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 } }
}>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent || info?.model) {
agent = info.agent
model = info.model
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.prompt({
path: { id: sessionID },
body: {
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: continuationPrompt }],
},
query: { directory: ctx.directory },
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject continuation`, {
sessionID,
error: String(err),
})
}
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
const state = readState(ctx.directory, stateDir)
if (state?.session_id === sessionInfo.id) {
clearState(ctx.directory, stateDir)
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id })
}
sessions.delete(sessionInfo.id)
}
}
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
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,
}
}