import { existsSync, readdirSync } from "node:fs" import { join } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import { findNearestMessageWithFields, MESSAGE_STORAGE, } from "../features/hook-message-injector" import { log } from "../shared/logger" const HOOK_NAME = "todo-continuation-enforcer" export interface TodoContinuationEnforcer { handler: (input: { event: { type: string; properties?: unknown } }) => Promise markRecovering: (sessionID: string) => void markRecoveryComplete: (sessionID: string) => void } interface Todo { content: string status: string priority: string id: string } const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION] Incomplete tasks remain in your todo list. Continue working on the next pending task. - Proceed without asking for permission - Mark each task complete when finished - Do not stop until all tasks are done` 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 } function detectInterrupt(error: unknown): boolean { if (!error) return false if (typeof error === "object") { const errObj = error as Record const name = errObj.name as string | undefined const message = (errObj.message as string | undefined)?.toLowerCase() ?? "" if (name === "MessageAbortedError" || name === "AbortError") return true if (name === "DOMException" && message.includes("abort")) return true if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true } if (typeof error === "string") { const lower = error.toLowerCase() return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt") } return false } export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer { const remindedSessions = new Set() const interruptedSessions = new Set() const errorSessions = new Set() const recoveringSessions = new Set() const pendingTimers = new Map>() const markRecovering = (sessionID: string): void => { recoveringSessions.add(sessionID) } const markRecoveryComplete = (sessionID: string): void => { recoveringSessions.delete(sessionID) } const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { const props = event.properties as Record | undefined if (event.type === "session.error") { const sessionID = props?.sessionID as string | undefined if (sessionID) { const isInterrupt = detectInterrupt(props?.error) errorSessions.add(sessionID) if (isInterrupt) { interruptedSessions.add(sessionID) } log(`[${HOOK_NAME}] session.error received`, { sessionID, isInterrupt, error: props?.error }) // Cancel pending continuation if error occurs const timer = pendingTimers.get(sessionID) if (timer) { clearTimeout(timer) pendingTimers.delete(sessionID) } } return } if (event.type === "session.idle") { const sessionID = props?.sessionID as string | undefined if (!sessionID) return log(`[${HOOK_NAME}] session.idle received`, { sessionID }) // Cancel any existing timer to debounce const existingTimer = pendingTimers.get(sessionID) if (existingTimer) { clearTimeout(existingTimer) log(`[${HOOK_NAME}] Cancelled existing timer`, { sessionID }) } // Schedule continuation check const timer = setTimeout(async () => { pendingTimers.delete(sessionID) log(`[${HOOK_NAME}] Timer fired, checking conditions`, { sessionID }) // Check if session is in recovery mode - if so, skip entirely without clearing state if (recoveringSessions.has(sessionID)) { log(`[${HOOK_NAME}] Skipped: session in recovery mode`, { sessionID }) return } const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID) interruptedSessions.delete(sessionID) errorSessions.delete(sessionID) if (shouldBypass) { log(`[${HOOK_NAME}] Skipped: error/interrupt bypass`, { sessionID }) return } if (remindedSessions.has(sessionID)) { log(`[${HOOK_NAME}] Skipped: already reminded this session`, { sessionID }) return } let todos: Todo[] = [] try { log(`[${HOOK_NAME}] Fetching todos for session`, { sessionID }) const response = await ctx.client.session.todo({ path: { id: sessionID }, }) todos = (response.data ?? response) as Todo[] log(`[${HOOK_NAME}] Todo API response`, { sessionID, todosCount: todos?.length ?? 0 }) } catch (err) { log(`[${HOOK_NAME}] Todo API error`, { sessionID, error: String(err) }) return } if (!todos || todos.length === 0) { log(`[${HOOK_NAME}] No todos found`, { sessionID }) return } const incomplete = todos.filter( (t) => t.status !== "completed" && t.status !== "cancelled" ) if (incomplete.length === 0) { log(`[${HOOK_NAME}] All todos completed`, { sessionID, total: todos.length }) return } log(`[${HOOK_NAME}] Found incomplete todos`, { sessionID, incomplete: incomplete.length, total: todos.length }) remindedSessions.add(sessionID) // Re-check if abort occurred during the delay/fetch if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID) || recoveringSessions.has(sessionID)) { log(`[${HOOK_NAME}] Abort occurred during delay/fetch`, { sessionID }) remindedSessions.delete(sessionID) return } try { // Get previous message's agent info to respect agent mode const messageDir = getMessageDir(sessionID) const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null log(`[${HOOK_NAME}] Injecting continuation prompt`, { sessionID, agent: prevMessage?.agent }) await ctx.client.session.prompt({ path: { id: sessionID }, body: { agent: prevMessage?.agent, parts: [ { type: "text", text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`, }, ], }, query: { directory: ctx.directory }, }) log(`[${HOOK_NAME}] Continuation prompt injected successfully`, { sessionID }) } catch (err) { log(`[${HOOK_NAME}] Prompt injection failed`, { sessionID, error: String(err) }) remindedSessions.delete(sessionID) } }, 200) pendingTimers.set(sessionID, timer) } if (event.type === "message.updated") { const info = props?.info as Record | undefined const sessionID = info?.sessionID as string | undefined log(`[${HOOK_NAME}] message.updated received`, { sessionID, role: info?.role }) if (sessionID && info?.role === "user") { // Cancel pending continuation on user interaction (real user input) const timer = pendingTimers.get(sessionID) if (timer) { clearTimeout(timer) pendingTimers.delete(sessionID) log(`[${HOOK_NAME}] Cancelled pending timer on user message`, { sessionID }) } } // Clear reminded state when assistant responds (allows re-remind on next idle) if (sessionID && info?.role === "assistant" && remindedSessions.has(sessionID)) { remindedSessions.delete(sessionID) log(`[${HOOK_NAME}] Cleared remindedSessions on assistant response`, { sessionID }) } } if (event.type === "session.deleted") { const sessionInfo = props?.info as { id?: string } | undefined if (sessionInfo?.id) { remindedSessions.delete(sessionInfo.id) interruptedSessions.delete(sessionInfo.id) errorSessions.delete(sessionInfo.id) recoveringSessions.delete(sessionInfo.id) // Cancel pending continuation const timer = pendingTimers.get(sessionInfo.id) if (timer) { clearTimeout(timer) pendingTimers.delete(sessionInfo.id) } } } } return { handler, markRecovering, markRecoveryComplete, } }