From 2025f7e88496bd35eb7125beb6eea4c741f963df Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 19 Dec 2025 19:06:35 +0900 Subject: [PATCH] fix(todo-continuation-enforcer): only show countdown when incomplete todos exist in main session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) Changes: - Add main session check: skip toast for subagent sessions - Move todo validation BEFORE countdown: only start countdown when incomplete todos actually exist - Improve toast message to show remaining task count This fixes the issue where countdown toast was showing on every idle event, even when no todos existed or in subagent sessions. --- src/hooks/todo-continuation-enforcer.ts | 116 +++++++++++++----------- 1 file changed, 65 insertions(+), 51 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts index 94d00ea..58e5983 100644 --- a/src/hooks/todo-continuation-enforcer.ts +++ b/src/hooks/todo-continuation-enforcer.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync } from "node:fs" import { join } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" +import { getMainSessionID } from "../features/claude-code-session-state" import { findNearestMessageWithFields, MESSAGE_STORAGE, @@ -112,6 +113,12 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati log(`[${HOOK_NAME}] session.idle received`, { sessionID }) + const mainSessionID = getMainSessionID() + if (mainSessionID && sessionID !== mainSessionID) { + log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID, mainSessionID }) + return + } + const existingCountdown = pendingCountdowns.get(sessionID) if (existingCountdown) { clearInterval(existingCountdown.intervalId) @@ -119,11 +126,61 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati log(`[${HOOK_NAME}] Cancelled existing countdown`, { 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) + + if (shouldBypass) { + interruptedSessions.delete(sessionID) + errorSessions.delete(sessionID) + log(`[${HOOK_NAME}] Skipped: error/interrupt bypass`, { sessionID }) + return + } + + if (remindedSessions.has(sessionID)) { + log(`[${HOOK_NAME}] Skipped: already reminded this session`, { sessionID }) + return + } + + // Check for incomplete todos BEFORE starting countdown + 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, starting countdown`, { sessionID, incomplete: incomplete.length, total: todos.length }) + const showCountdownToast = async (seconds: number): Promise => { await ctx.client.tui.showToast({ body: { title: "Todo Continuation", - message: `Resuming in ${seconds}s...`, + message: `Resuming in ${seconds}s... (${incomplete.length} tasks remaining)`, variant: "warning" as const, duration: TOAST_DURATION_MS, }, @@ -132,66 +189,23 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati const executeAfterCountdown = async (): Promise => { pendingCountdowns.delete(sessionID) - log(`[${HOOK_NAME}] Countdown finished, checking conditions`, { sessionID }) + log(`[${HOOK_NAME}] Countdown finished, executing continuation`, { sessionID }) - // Check if session is in recovery mode - if so, skip entirely without clearing state + // Re-check conditions after countdown if (recoveringSessions.has(sessionID)) { - log(`[${HOOK_NAME}] Skipped: session in recovery mode`, { sessionID }) + log(`[${HOOK_NAME}] Abort: session entered recovery mode during countdown`, { 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 }) + if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) { + log(`[${HOOK_NAME}] Abort: error/interrupt occurred during countdown`, { sessionID }) + interruptedSessions.delete(sessionID) + errorSessions.delete(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)