diff --git a/src/hooks/prometheus-md-only/agent-resolution.ts b/src/hooks/prometheus-md-only/agent-resolution.ts index 22dc9cae0..f650ab722 100644 --- a/src/hooks/prometheus-md-only/agent-resolution.ts +++ b/src/hooks/prometheus-md-only/agent-resolution.ts @@ -12,21 +12,30 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection" type OpencodeClient = PluginInput["client"] +function isCompactionAgent(agent: string): boolean { + return agent.toLowerCase() === "compaction" +} + async function getAgentFromMessageFiles( sessionID: string, client?: OpencodeClient ): Promise { if (isSqliteBackend() && client) { const firstAgent = await findFirstMessageWithAgentFromSDK(client, sessionID) - if (firstAgent) return firstAgent + if (firstAgent && !isCompactionAgent(firstAgent)) return firstAgent const nearest = await findNearestMessageWithFieldsFromSDK(client, sessionID) - return nearest?.agent + if (nearest?.agent && !isCompactionAgent(nearest.agent)) return nearest.agent + return undefined } const messageDir = getMessageDir(sessionID) if (!messageDir) return undefined - return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent + const firstAgent = findFirstMessageWithAgent(messageDir) + if (firstAgent && !isCompactionAgent(firstAgent)) return firstAgent + const nearestAgent = findNearestMessageWithFields(messageDir)?.agent + if (nearestAgent && !isCompactionAgent(nearestAgent)) return nearestAgent + return undefined } /** diff --git a/src/hooks/todo-continuation-enforcer/compaction-guard.ts b/src/hooks/todo-continuation-enforcer/compaction-guard.ts new file mode 100644 index 000000000..38f3d640a --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/compaction-guard.ts @@ -0,0 +1,10 @@ +import { COMPACTION_GUARD_MS } from "./constants" +import type { SessionState } from "./types" + +export function isCompactionGuardActive(state: SessionState, now: number): boolean { + if (!state.recentCompactionAt) { + return false + } + + return now - state.recentCompactionAt < COMPACTION_GUARD_MS +} diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts index a26c7bc09..5829ea867 100644 --- a/src/hooks/todo-continuation-enforcer/constants.ts +++ b/src/hooks/todo-continuation-enforcer/constants.ts @@ -17,6 +17,7 @@ export const TOAST_DURATION_MS = 900 export const COUNTDOWN_GRACE_PERIOD_MS = 500 export const ABORT_WINDOW_MS = 3000 +export const COMPACTION_GUARD_MS = 60_000 export const CONTINUATION_COOLDOWN_MS = 5_000 export const MAX_STAGNATION_COUNT = 3 export const MAX_CONSECUTIVE_FAILURES = 5 diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index 5e4cb124c..f5b2b84e1 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" +import { getSessionAgent } from "../../features/claude-code-session-state" import { createInternalAgentTextPart, normalizeSDKResponse, @@ -20,6 +21,7 @@ import { DEFAULT_SKIP_AGENTS, HOOK_NAME, } from "./constants" +import { isCompactionGuardActive } from "./compaction-guard" import { getMessageDir } from "./message-directory" import { getIncompleteCount } from "./todo" import type { ResolvedMessageInfo, Todo } from "./types" @@ -88,7 +90,7 @@ export async function injectContinuation(args: { return } - let agentName = resolvedInfo?.agent + let agentName = resolvedInfo?.agent ?? getSessionAgent(sessionID) let model = resolvedInfo?.model let tools = resolvedInfo?.tools @@ -120,6 +122,14 @@ export async function injectContinuation(args: { return } + if (!agentName) { + const compactionState = sessionStateStore.getExistingState(sessionID) + if (compactionState && isCompactionGuardActive(compactionState, Date.now())) { + log(`[${HOOK_NAME}] Skipped: agent unknown after compaction`, { sessionID }) + return + } + } + if (!hasWritePermission(tools)) { log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName }) return diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts index 4e4ef69d0..a3eb71bf7 100644 --- a/src/hooks/todo-continuation-enforcer/handler.ts +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -63,6 +63,17 @@ export function createTodoContinuationHandler(args: { return } + if (event.type === "session.compacted") { + const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined + if (sessionID) { + const state = sessionStateStore.getState(sessionID) + state.recentCompactionAt = Date.now() + sessionStateStore.cancelCountdown(sessionID) + log(`[${HOOK_NAME}] Session compacted: marked recentCompactionAt`, { sessionID }) + } + return + } + if (event.type === "session.deleted") { const sessionInfo = props?.info as { id?: string } | undefined if (sessionInfo?.id) { diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index c55bf4abc..58a200121 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -1,7 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" - import type { BackgroundManager } from "../../features/background-agent" -import type { ToolPermission } from "../../features/hook-message-injector" +import { getSessionAgent } from "../../features/claude-code-session-state" import { normalizeSDKResponse } from "../../shared" import { log } from "../../shared/logger" import { getAgentConfigKey } from "../../shared/agent-display-names" @@ -19,6 +18,8 @@ import { hasUnansweredQuestion } from "./pending-question-detection" import { shouldStopForStagnation } from "./stagnation-detection" import { getIncompleteCount } from "./todo" import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types" +import { resolveLatestMessageInfo } from "./resolve-message-info" +import { isCompactionGuardActive } from "./compaction-guard" import type { SessionStateStore } from "./session-state" import { startCountdown } from "./countdown" @@ -119,10 +120,7 @@ export async function handleSessionIdle(args: { && Date.now() - state.lastInjectedAt >= FAILURE_RESET_WINDOW_MS ) { state.consecutiveFailures = 0 - log(`[${HOOK_NAME}] Reset consecutive failures after recovery window`, { - sessionID, - failureResetWindowMs: FAILURE_RESET_WINDOW_MS, - }) + log(`[${HOOK_NAME}] Reset consecutive failures after recovery window`, { sessionID, failureResetWindowMs: FAILURE_RESET_WINDOW_MS }) } if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { @@ -146,42 +144,33 @@ export async function handleSessionIdle(args: { } let resolvedInfo: ResolvedMessageInfo | undefined - let hasCompactionMessage = false try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent === "compaction") { - hasCompactionMessage = true - continue - } - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - resolvedInfo = { - agent: info.agent, - model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined), - tools: info.tools as Record | undefined, - } - break - } - } + resolvedInfo = await resolveLatestMessageInfo(ctx, sessionID) } catch (error) { log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) }) } - log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage }) + const sessionAgent = getSessionAgent(sessionID) + if (!resolvedInfo?.agent && sessionAgent) { + resolvedInfo = { ...resolvedInfo, agent: sessionAgent } + } + + const compactionGuardActive = isCompactionGuardActive(state, Date.now()) + + log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, compactionGuardActive }) const resolvedAgentName = resolvedInfo?.agent if (resolvedAgentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(resolvedAgentName))) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedAgentName }) return } - if (hasCompactionMessage && !resolvedInfo?.agent) { + if (compactionGuardActive && !resolvedInfo?.agent) { log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID }) return } + if (state.recentCompactionAt && resolvedInfo?.agent) { + state.recentCompactionAt = undefined + } if (isContinuationStopped?.(sessionID)) { log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) @@ -197,7 +186,6 @@ export async function handleSessionIdle(args: { if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) { return } - startCountdown({ ctx, sessionID, diff --git a/src/hooks/todo-continuation-enforcer/resolve-message-info.ts b/src/hooks/todo-continuation-enforcer/resolve-message-info.ts new file mode 100644 index 000000000..cf4c5170c --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/resolve-message-info.ts @@ -0,0 +1,31 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { normalizeSDKResponse } from "../../shared" + +import type { MessageInfo, ResolvedMessageInfo } from "./types" + +export async function resolveLatestMessageInfo( + ctx: PluginInput, + sessionID: string +): Promise { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) + + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i].info + if (info?.agent === "compaction") { + continue + } + if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { + return { + agent: info.agent, + model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined), + tools: info.tools, + } + } + } + + return undefined +} diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index c801ab128..b7a434e10 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -35,6 +35,7 @@ export interface SessionState { inFlight?: boolean stagnationCount: number consecutiveFailures: number + recentCompactionAt?: number } export interface MessageInfo {