2026-02-08 15:01:42 +09:00
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
2026-03-13 00:55:37 +08:00
import { getSessionAgent } from "../../features/claude-code-session-state"
2026-02-16 18:20:19 +09:00
import { normalizeSDKResponse } from "../../shared"
2026-02-08 15:01:42 +09:00
import { log } from "../../shared/logger"
2026-02-16 20:43:09 +09:00
import { getAgentConfigKey } from "../../shared/agent-display-names"
2026-02-08 15:01:42 +09:00
2026-03-24 20:36:22 +09:00
import { ABORT_WINDOW_MS , CONTINUATION_COOLDOWN_MS , DEFAULT_SKIP_AGENTS , FAILURE_RESET_WINDOW_MS , HOOK_NAME , MAX_CONSECUTIVE_FAILURES } from "./constants"
2026-02-08 15:01:42 +09:00
import { isLastAssistantMessageAborted } from "./abort-detection"
2026-02-19 14:36:50 +09:00
import { hasUnansweredQuestion } from "./pending-question-detection"
2026-03-08 02:18:08 +09:00
import { shouldStopForStagnation } from "./stagnation-detection"
2026-02-08 15:01:42 +09:00
import { getIncompleteCount } from "./todo"
2026-04-04 18:56:36 +09:00
import type { MessageInfo , MessageWithInfo , ResolvedMessageInfo , Todo } from "./types"
2026-03-13 00:55:37 +08:00
import { resolveLatestMessageInfo } from "./resolve-message-info"
2026-03-24 20:36:22 +09:00
import { acknowledgeCompactionGuard , isCompactionGuardActive } from "./compaction-guard"
2026-02-08 15:01:42 +09:00
import type { SessionStateStore } from "./session-state"
import { startCountdown } from "./countdown"
export async function handleSessionIdle ( args : {
ctx : PluginInput
sessionID : string
sessionStateStore : SessionStateStore
backgroundManager? : BackgroundManager
skipAgents? : string [ ]
isContinuationStopped ? : ( sessionID : string ) = > boolean
} ) : Promise < void > {
const {
ctx ,
sessionID ,
sessionStateStore ,
backgroundManager ,
skipAgents = DEFAULT_SKIP_AGENTS ,
isContinuationStopped ,
} = args
log ( ` [ ${ HOOK_NAME } ] session.idle ` , { sessionID } )
const state = sessionStateStore . getState ( sessionID )
2026-03-24 20:36:22 +09:00
const observedCompactionEpoch = state . recentCompactionEpoch
2026-02-08 15:01:42 +09:00
if ( state . isRecovering ) {
log ( ` [ ${ HOOK_NAME } ] Skipped: in recovery ` , { sessionID } )
return
}
2026-04-03 18:40:02 +09:00
if ( state . wasCancelled ) {
log ( ` [ ${ HOOK_NAME } ] Skipped: session was cancelled ` , { sessionID } )
return
}
2026-02-08 15:01:42 +09:00
if ( state . abortDetectedAt ) {
const timeSinceAbort = Date . now ( ) - state . abortDetectedAt
if ( timeSinceAbort < ABORT_WINDOW_MS ) {
log ( ` [ ${ HOOK_NAME } ] Skipped: abort detected via event ${ timeSinceAbort } ms ago ` , { sessionID } )
state . abortDetectedAt = undefined
return
}
state . abortDetectedAt = undefined
}
const hasRunningBgTasks = backgroundManager
? backgroundManager . getTasksByParentSession ( sessionID ) . some ( ( task : { status : string } ) = > task . status === "running" )
: false
if ( hasRunningBgTasks ) {
log ( ` [ ${ HOOK_NAME } ] Skipped: background tasks running ` , { sessionID } )
return
}
2026-04-04 18:56:36 +09:00
let prefetchedMessages : MessageWithInfo [ ] | undefined
2026-02-08 15:01:42 +09:00
try {
const messagesResp = await ctx . client . session . messages ( {
path : { id : sessionID } ,
query : { directory : ctx.directory } ,
} )
2026-04-04 18:56:36 +09:00
prefetchedMessages = normalizeSDKResponse ( messagesResp , [ ] as MessageWithInfo [ ] )
if ( isLastAssistantMessageAborted ( prefetchedMessages ) ) {
2026-02-08 15:01:42 +09:00
log ( ` [ ${ HOOK_NAME } ] Skipped: last assistant message was aborted (API fallback) ` , { sessionID } )
return
}
2026-04-04 18:56:36 +09:00
if ( hasUnansweredQuestion ( prefetchedMessages ) ) {
2026-02-19 14:36:50 +09:00
log ( ` [ ${ HOOK_NAME } ] Skipped: pending question awaiting user response ` , { sessionID } )
return
}
2026-02-08 15:01:42 +09:00
} catch ( error ) {
log ( ` [ ${ HOOK_NAME } ] Messages fetch failed, continuing ` , { sessionID , error : String ( error ) } )
}
let todos : Todo [ ] = [ ]
try {
const response = await ctx . client . session . todo ( { path : { id : sessionID } } )
2026-02-16 18:20:19 +09:00
todos = normalizeSDKResponse ( response , [ ] as Todo [ ] , { preferResponseOnMissingData : true } )
2026-02-08 15:01:42 +09:00
} catch ( error ) {
log ( ` [ ${ HOOK_NAME } ] Todo fetch failed ` , { sessionID , error : String ( error ) } )
return
}
if ( ! todos || todos . length === 0 ) {
2026-03-18 14:45:14 +09:00
sessionStateStore . resetContinuationProgress ( sessionID )
2026-03-08 02:18:08 +09:00
sessionStateStore . resetContinuationProgress ( sessionID )
2026-02-08 15:01:42 +09:00
log ( ` [ ${ HOOK_NAME } ] No todos ` , { sessionID } )
return
}
const incompleteCount = getIncompleteCount ( todos )
if ( incompleteCount === 0 ) {
2026-03-18 14:45:14 +09:00
sessionStateStore . resetContinuationProgress ( sessionID )
2026-03-08 02:18:08 +09:00
sessionStateStore . resetContinuationProgress ( sessionID )
2026-02-08 15:01:42 +09:00
log ( ` [ ${ HOOK_NAME } ] All todos complete ` , { sessionID , total : todos.length } )
return
}
2026-02-13 11:52:31 +09:00
if ( state . inFlight ) {
log ( ` [ ${ HOOK_NAME } ] Skipped: injection in flight ` , { sessionID } )
return
}
2026-02-16 15:27:00 +09:00
if (
state . consecutiveFailures >= MAX_CONSECUTIVE_FAILURES
&& state . lastInjectedAt
&& Date . now ( ) - state . lastInjectedAt >= FAILURE_RESET_WINDOW_MS
) {
state . consecutiveFailures = 0
2026-03-13 00:55:37 +08:00
log ( ` [ ${ HOOK_NAME } ] Reset consecutive failures after recovery window ` , { sessionID , failureResetWindowMs : FAILURE_RESET_WINDOW_MS } )
2026-02-16 15:27:00 +09:00
}
2026-02-16 15:00:41 +09:00
if ( state . consecutiveFailures >= MAX_CONSECUTIVE_FAILURES ) {
2026-03-18 14:45:14 +09:00
log ( ` [ ${ HOOK_NAME } ] Skipped: max consecutive failures reached ` , { sessionID , consecutiveFailures : state.consecutiveFailures } )
2026-02-16 15:00:41 +09:00
return
}
const effectiveCooldown =
CONTINUATION_COOLDOWN_MS * Math . pow ( 2 , Math . min ( state . consecutiveFailures , 5 ) )
if ( state . lastInjectedAt && Date . now ( ) - state . lastInjectedAt < effectiveCooldown ) {
2026-03-18 14:45:14 +09:00
log ( ` [ ${ HOOK_NAME } ] Skipped: cooldown active ` , { sessionID , effectiveCooldown , consecutiveFailures : state.consecutiveFailures } )
2026-02-13 11:52:31 +09:00
return
}
2026-02-08 15:01:42 +09:00
let resolvedInfo : ResolvedMessageInfo | undefined
2026-03-15 20:02:56 -06:00
let encounteredCompaction = false
2026-02-08 15:01:42 +09:00
try {
2026-04-04 18:56:36 +09:00
const messageInfoResult = await resolveLatestMessageInfo ( ctx , sessionID , prefetchedMessages )
2026-03-15 20:02:56 -06:00
resolvedInfo = messageInfoResult . resolvedInfo
encounteredCompaction = messageInfoResult . encounteredCompaction
2026-02-08 15:01:42 +09:00
} catch ( error ) {
log ( ` [ ${ HOOK_NAME } ] Failed to fetch messages for agent check ` , { sessionID , error : String ( error ) } )
}
2026-03-13 00:55:37 +08:00
const sessionAgent = getSessionAgent ( sessionID )
if ( ! resolvedInfo ? . agent && sessionAgent ) {
resolvedInfo = { . . . resolvedInfo , agent : sessionAgent }
}
2026-03-24 20:36:22 +09:00
const acknowledgedCompaction = resolvedInfo ? . agent ? acknowledgeCompactionGuard ( state , observedCompactionEpoch ) : false
2026-03-13 00:55:37 +08:00
const compactionGuardActive = isCompactionGuardActive ( state , Date . now ( ) )
2026-03-24 20:36:22 +09:00
log ( ` [ ${ HOOK_NAME } ] Agent check ` , {
sessionID ,
agentName : resolvedInfo?.agent ,
skipAgents ,
compactionGuardActive ,
observedCompactionEpoch ,
currentCompactionEpoch : state.recentCompactionEpoch ,
acknowledgedCompaction ,
} )
2026-02-08 15:01:42 +09:00
2026-02-16 20:43:09 +09:00
const resolvedAgentName = resolvedInfo ? . agent
if ( resolvedAgentName && skipAgents . some ( s = > getAgentConfigKey ( s ) === getAgentConfigKey ( resolvedAgentName ) ) ) {
log ( ` [ ${ HOOK_NAME } ] Skipped: agent in skipAgents list ` , { sessionID , agent : resolvedAgentName } )
2026-02-08 15:01:42 +09:00
return
}
2026-03-15 20:02:56 -06:00
if ( ( compactionGuardActive || encounteredCompaction ) && ! resolvedInfo ? . agent ) {
2026-02-08 15:01:42 +09:00
log ( ` [ ${ HOOK_NAME } ] Skipped: compaction occurred but no agent info resolved ` , { sessionID } )
return
}
2026-03-24 20:36:22 +09:00
if ( compactionGuardActive ) {
log ( ` [ ${ HOOK_NAME } ] Skipped: compaction guard still armed for current epoch ` , { sessionID , observedCompactionEpoch , currentCompactionEpoch : state.recentCompactionEpoch } )
return
2026-03-13 00:55:37 +08:00
}
2026-02-08 15:01:42 +09:00
if ( isContinuationStopped ? . ( sessionID ) ) {
log ( ` [ ${ HOOK_NAME } ] Skipped: continuation stopped for session ` , { sessionID } )
return
}
2026-03-11 17:56:49 +09:00
const progressUpdate = sessionStateStore . trackContinuationProgress ( sessionID , incompleteCount , todos )
2026-03-08 02:18:08 +09:00
if ( shouldStopForStagnation ( { sessionID , incompleteCount , progressUpdate } ) ) {
return
}
2026-02-08 15:01:42 +09:00
startCountdown ( {
ctx ,
sessionID ,
incompleteCount ,
total : todos.length ,
resolvedInfo ,
backgroundManager ,
skipAgents ,
sessionStateStore ,
2026-02-19 14:07:52 +09:00
isContinuationStopped ,
2026-02-08 15:01:42 +09:00
} )
}