From 4de2399f2217b42dd955bf5c9d209b7ab148dfa5 Mon Sep 17 00:00:00 2001 From: justsisyphus Date: Fri, 16 Jan 2026 02:33:44 +0900 Subject: [PATCH] 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) --- src/features/background-agent/manager.ts | 34 ++++++--- src/features/hook-message-injector/index.ts | 2 +- src/hooks/ralph-loop/index.ts | 30 ++++++-- src/hooks/sisyphus-orchestrator/index.ts | 25 ++++-- src/hooks/todo-continuation-enforcer.ts | 84 ++++++++++++++------- src/tools/sisyphus-task/tools.ts | 30 ++++++-- 6 files changed, 150 insertions(+), 55 deletions(-) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 5860258fa..2abc4cac5 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -675,21 +675,33 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea ` } - // Dynamically lookup the parent session's current message context - // This ensures we use the CURRENT model/agent, not the stale one from task creation time - const messageDir = getMessageDir(task.parentSessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + let agent: string | undefined = task.parentAgent + let model: { providerID: string; modelID: string } | undefined - const agent = currentMessage?.agent ?? task.parentAgent - const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined + try { + const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } }) + 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 ?? task.parentAgent + model = info.model + break + } + } + } catch { + const messageDir = getMessageDir(task.parentSessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agent = currentMessage?.agent ?? task.parentAgent + model = currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + } log("[background-agent] notifyParentSession context:", { taskId: task.id, - messageDir: !!messageDir, - currentAgent: currentMessage?.agent, - currentModel: currentMessage?.model, resolvedAgent: agent, resolvedModel: model, }) diff --git a/src/features/hook-message-injector/index.ts b/src/features/hook-message-injector/index.ts index fcb0624d9..9a46758f9 100644 --- a/src/features/hook-message-injector/index.ts +++ b/src/features/hook-message-injector/index.ts @@ -1,4 +1,4 @@ export { injectHookMessage, findNearestMessageWithFields, findFirstMessageWithAgent } from "./injector" export type { StoredMessage } from "./injector" -export type { MessageMeta, OriginalMessageContext, TextPart } from "./types" +export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" export { MESSAGE_STORAGE } from "./constants" diff --git a/src/hooks/ralph-loop/index.ts b/src/hooks/ralph-loop/index.ts index 6fcc31c9b..aef0cb3d0 100644 --- a/src/hooks/ralph-loop/index.ts +++ b/src/hooks/ralph-loop/index.ts @@ -315,12 +315,30 @@ export function createRalphLoopHook( .catch(() => {}) try { - const messageDir = getMessageDir(sessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const agent = currentMessage?.agent - const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined + 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 }, diff --git a/src/hooks/sisyphus-orchestrator/index.ts b/src/hooks/sisyphus-orchestrator/index.ts index e79bf271b..b032cfe8c 100644 --- a/src/hooks/sisyphus-orchestrator/index.ts +++ b/src/hooks/sisyphus-orchestrator/index.ts @@ -436,11 +436,26 @@ export function createSisyphusOrchestratorHook( try { log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) - const messageDir = getMessageDir(sessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : 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?: { model?: { providerID: string; modelID: string } } + }> + for (let i = messages.length - 1; i >= 0; i--) { + const msgModel = messages[i].info?.model + if (msgModel?.providerID && msgModel?.modelID) { + model = { providerID: msgModel.providerID, modelID: msgModel.modelID } + break + } + } + } catch { + const messageDir = getMessageDir(sessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + model = currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + } await ctx.client.session.prompt({ path: { id: sessionID }, diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts index 0c4966ea2..e88103a0b 100644 --- a/src/hooks/todo-continuation-enforcer.ts +++ b/src/hooks/todo-continuation-enforcer.ts @@ -6,6 +6,7 @@ import { getMainSessionID, subagentSessions } from "../features/claude-code-sess import { findNearestMessageWithFields, MESSAGE_STORAGE, + type ToolPermission, } from "../features/hook-message-injector" import { log } from "../shared/logger" @@ -151,7 +152,18 @@ export function createTodoContinuationEnforcer( }).catch(() => {}) } - async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise { + interface ResolvedMessageInfo { + agent?: string + model?: { providerID: string; modelID: string } + tools?: Record + } + + async function injectContinuation( + sessionID: string, + incompleteCount: number, + total: number, + resolvedInfo?: ResolvedMessageInfo + ): Promise { const state = sessions.get(sessionID) if (state?.isRecovering) { @@ -159,8 +171,6 @@ export function createTodoContinuationEnforcer( return } - - const hasRunningBgTasks = backgroundManager ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") : false @@ -185,38 +195,44 @@ export function createTodoContinuationEnforcer( return } - const messageDir = getMessageDir(sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + let agentName = resolvedInfo?.agent + let model = resolvedInfo?.model + let tools = resolvedInfo?.tools + + if (!agentName || !model) { + const messageDir = getMessageDir(sessionID) + const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agentName = agentName ?? prevMessage?.agent + model = model ?? (prevMessage?.model?.providerID && prevMessage?.model?.modelID + ? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID } + : undefined) + tools = tools ?? prevMessage?.tools + } - const agentName = prevMessage?.agent if (agentName && skipAgents.includes(agentName)) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) return } - const editPermission = prevMessage?.tools?.edit - const writePermission = prevMessage?.tools?.write - const hasWritePermission = !prevMessage?.tools || + const editPermission = tools?.edit + const writePermission = tools?.write + const hasWritePermission = !tools || ((editPermission !== false && editPermission !== "deny") && (writePermission !== false && writePermission !== "deny")) if (!hasWritePermission) { - log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent }) + log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName }) return } const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]` - const model = prevMessage?.model?.providerID && prevMessage?.model?.modelID - ? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID } - : undefined - try { - log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, model, incompleteCount: freshIncompleteCount }) + log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount }) await ctx.client.session.prompt({ path: { id: sessionID }, body: { - agent: prevMessage?.agent, + agent: agentName, ...(model !== undefined ? { model } : {}), parts: [{ type: "text", text: prompt }], }, @@ -229,7 +245,12 @@ export function createTodoContinuationEnforcer( } } - function startCountdown(sessionID: string, incompleteCount: number, total: number): void { + function startCountdown( + sessionID: string, + incompleteCount: number, + total: number, + resolvedInfo?: ResolvedMessageInfo + ): void { const state = getState(sessionID) cancelCountdown(sessionID) @@ -246,7 +267,7 @@ export function createTodoContinuationEnforcer( state.countdownTimer = setTimeout(() => { cancelCountdown(sessionID) - injectContinuation(sessionID, incompleteCount, total) + injectContinuation(sessionID, incompleteCount, total, resolvedInfo) }, COUNTDOWN_SECONDS * 1000) log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount }) @@ -350,15 +371,26 @@ export function createTodoContinuationEnforcer( return } - let agentName: string | undefined + let resolvedInfo: ResolvedMessageInfo | undefined try { const messagesResp = await ctx.client.session.messages({ path: { id: sessionID }, }) - const messages = (messagesResp.data ?? []) as Array<{ info?: { agent?: string } }> + const messages = (messagesResp.data ?? []) as Array<{ + info?: { + agent?: string + model?: { providerID: string; modelID: string } + tools?: Record + } + }> for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].info?.agent) { - agentName = messages[i].info?.agent + const info = messages[i].info + if (info?.agent || info?.model) { + resolvedInfo = { + agent: info.agent, + model: info.model, + tools: info.tools, + } break } } @@ -366,13 +398,13 @@ export function createTodoContinuationEnforcer( log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) }) } - log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName, skipAgents }) - if (agentName && skipAgents.includes(agentName)) { - log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) + log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents }) + if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) { + log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent }) return } - startCountdown(sessionID, incompleteCount, todos.length) + startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo) return } diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index b499dd895..e6ee744f6 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -279,12 +279,30 @@ Use \`background_output\` with task_id="${task.id}" to check progress.` }) try { - const resumeMessageDir = getMessageDir(args.resume) - const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null - const resumeAgent = resumeMessage?.agent - const resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID - ? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID } - : undefined + let resumeAgent: string | undefined + let resumeModel: { providerID: string; modelID: string } | undefined + + try { + const messagesResp = await client.session.messages({ path: { id: args.resume } }) + 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) { + resumeAgent = info.agent + resumeModel = info.model + break + } + } + } catch { + const resumeMessageDir = getMessageDir(args.resume) + const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null + resumeAgent = resumeMessage?.agent + resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID + ? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID } + : undefined + } await client.session.prompt({ path: { id: args.resume },