11import { existsSync , readdirSync } from "node:fs"
22import { join } from "node:path"
33import type { PluginInput } from "@opencode-ai/plugin"
4+ import { getMainSessionID } from "../features/claude-code-session-state"
45import {
56 findNearestMessageWithFields ,
67 MESSAGE_STORAGE ,
@@ -112,18 +113,74 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
112113
113114 log ( `[${ HOOK_NAME } ] session.idle received` , { sessionID } )
114115
116+ const mainSessionID = getMainSessionID ( )
117+ if ( mainSessionID && sessionID !== mainSessionID ) {
118+ log ( `[${ HOOK_NAME } ] Skipped: not main session` , { sessionID, mainSessionID } )
119+ return
120+ }
121+
115122 const existingCountdown = pendingCountdowns . get ( sessionID )
116123 if ( existingCountdown ) {
117124 clearInterval ( existingCountdown . intervalId )
118125 pendingCountdowns . delete ( sessionID )
119126 log ( `[${ HOOK_NAME } ] Cancelled existing countdown` , { sessionID } )
120127 }
121128
129+ // Check if session is in recovery mode - if so, skip entirely without clearing state
130+ if ( recoveringSessions . has ( sessionID ) ) {
131+ log ( `[${ HOOK_NAME } ] Skipped: session in recovery mode` , { sessionID } )
132+ return
133+ }
134+
135+ const shouldBypass = interruptedSessions . has ( sessionID ) || errorSessions . has ( sessionID )
136+
137+ if ( shouldBypass ) {
138+ interruptedSessions . delete ( sessionID )
139+ errorSessions . delete ( sessionID )
140+ log ( `[${ HOOK_NAME } ] Skipped: error/interrupt bypass` , { sessionID } )
141+ return
142+ }
143+
144+ if ( remindedSessions . has ( sessionID ) ) {
145+ log ( `[${ HOOK_NAME } ] Skipped: already reminded this session` , { sessionID } )
146+ return
147+ }
148+
149+ // Check for incomplete todos BEFORE starting countdown
150+ let todos : Todo [ ] = [ ]
151+ try {
152+ log ( `[${ HOOK_NAME } ] Fetching todos for session` , { sessionID } )
153+ const response = await ctx . client . session . todo ( {
154+ path : { id : sessionID } ,
155+ } )
156+ todos = ( response . data ?? response ) as Todo [ ]
157+ log ( `[${ HOOK_NAME } ] Todo API response` , { sessionID, todosCount : todos ?. length ?? 0 } )
158+ } catch ( err ) {
159+ log ( `[${ HOOK_NAME } ] Todo API error` , { sessionID, error : String ( err ) } )
160+ return
161+ }
162+
163+ if ( ! todos || todos . length === 0 ) {
164+ log ( `[${ HOOK_NAME } ] No todos found` , { sessionID } )
165+ return
166+ }
167+
168+ const incomplete = todos . filter (
169+ ( t ) => t . status !== "completed" && t . status !== "cancelled"
170+ )
171+
172+ if ( incomplete . length === 0 ) {
173+ log ( `[${ HOOK_NAME } ] All todos completed` , { sessionID, total : todos . length } )
174+ return
175+ }
176+
177+ log ( `[${ HOOK_NAME } ] Found incomplete todos, starting countdown` , { sessionID, incomplete : incomplete . length , total : todos . length } )
178+
122179 const showCountdownToast = async ( seconds : number ) : Promise < void > => {
123180 await ctx . client . tui . showToast ( {
124181 body : {
125182 title : "Todo Continuation" ,
126- message : `Resuming in ${ seconds } s...` ,
183+ message : `Resuming in ${ seconds } s... ( ${ incomplete . length } tasks remaining) ` ,
127184 variant : "warning" as const ,
128185 duration : TOAST_DURATION_MS ,
129186 } ,
@@ -132,66 +189,23 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
132189
133190 const executeAfterCountdown = async ( ) : Promise < void > => {
134191 pendingCountdowns . delete ( sessionID )
135- log ( `[${ HOOK_NAME } ] Countdown finished, checking conditions ` , { sessionID } )
192+ log ( `[${ HOOK_NAME } ] Countdown finished, executing continuation ` , { sessionID } )
136193
137- // Check if session is in recovery mode - if so, skip entirely without clearing state
194+ // Re-check conditions after countdown
138195 if ( recoveringSessions . has ( sessionID ) ) {
139- log ( `[${ HOOK_NAME } ] Skipped: session in recovery mode` , { sessionID } )
140- return
141- }
142-
143- const shouldBypass = interruptedSessions . has ( sessionID ) || errorSessions . has ( sessionID )
144-
145- interruptedSessions . delete ( sessionID )
146- errorSessions . delete ( sessionID )
147-
148- if ( shouldBypass ) {
149- log ( `[${ HOOK_NAME } ] Skipped: error/interrupt bypass` , { sessionID } )
150- return
151- }
152-
153- if ( remindedSessions . has ( sessionID ) ) {
154- log ( `[${ HOOK_NAME } ] Skipped: already reminded this session` , { sessionID } )
155- return
156- }
157-
158- let todos : Todo [ ] = [ ]
159- try {
160- log ( `[${ HOOK_NAME } ] Fetching todos for session` , { sessionID } )
161- const response = await ctx . client . session . todo ( {
162- path : { id : sessionID } ,
163- } )
164- todos = ( response . data ?? response ) as Todo [ ]
165- log ( `[${ HOOK_NAME } ] Todo API response` , { sessionID, todosCount : todos ?. length ?? 0 } )
166- } catch ( err ) {
167- log ( `[${ HOOK_NAME } ] Todo API error` , { sessionID, error : String ( err ) } )
196+ log ( `[${ HOOK_NAME } ] Abort: session entered recovery mode during countdown` , { sessionID } )
168197 return
169198 }
170199
171- if ( ! todos || todos . length === 0 ) {
172- log ( `[${ HOOK_NAME } ] No todos found` , { sessionID } )
200+ if ( interruptedSessions . has ( sessionID ) || errorSessions . has ( sessionID ) ) {
201+ log ( `[${ HOOK_NAME } ] Abort: error/interrupt occurred during countdown` , { sessionID } )
202+ interruptedSessions . delete ( sessionID )
203+ errorSessions . delete ( sessionID )
173204 return
174205 }
175206
176- const incomplete = todos . filter (
177- ( t ) => t . status !== "completed" && t . status !== "cancelled"
178- )
179-
180- if ( incomplete . length === 0 ) {
181- log ( `[${ HOOK_NAME } ] All todos completed` , { sessionID, total : todos . length } )
182- return
183- }
184-
185- log ( `[${ HOOK_NAME } ] Found incomplete todos` , { sessionID, incomplete : incomplete . length , total : todos . length } )
186207 remindedSessions . add ( sessionID )
187208
188- // Re-check if abort occurred during the delay/fetch
189- if ( interruptedSessions . has ( sessionID ) || errorSessions . has ( sessionID ) || recoveringSessions . has ( sessionID ) ) {
190- log ( `[${ HOOK_NAME } ] Abort occurred during delay/fetch` , { sessionID } )
191- remindedSessions . delete ( sessionID )
192- return
193- }
194-
195209 try {
196210 // Get previous message's agent info to respect agent mode
197211 const messageDir = getMessageDir ( sessionID )
0 commit comments