From 8d9b68d84b82d6c43198b9dd04bc5627bc71df6b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 25 Dec 2025 15:27:34 +0900 Subject: [PATCH] Prevent premature exit in non-interactive mode when tasks pending (#216) (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects non-interactive environments (CI, opencode run) and prevents session idle when: - Background tasks are still running - Incomplete todos remain in the queue Changes: - Add isNonInteractive() detector for CI/headless environment detection - Export detector from non-interactive-env hook module - Enhance todo-continuation-enforcer to inject prompts BEFORE session.idle - Pass BackgroundManager to todo-continuation-enforcer for task status checks This fix prevents `opencode run` from exiting prematurely when work is pending. 🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode) --- src/hooks/non-interactive-env/detector.ts | 19 ++++++ src/hooks/non-interactive-env/index.ts | 1 + src/hooks/todo-continuation-enforcer.ts | 79 ++++++++++++++++++++++- src/index.ts | 19 +++--- 4 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 src/hooks/non-interactive-env/detector.ts diff --git a/src/hooks/non-interactive-env/detector.ts b/src/hooks/non-interactive-env/detector.ts new file mode 100644 index 0000000..b6b0213 --- /dev/null +++ b/src/hooks/non-interactive-env/detector.ts @@ -0,0 +1,19 @@ +export function isNonInteractive(): boolean { + if (process.env.CI === "true" || process.env.CI === "1") { + return true + } + + if (process.env.OPENCODE_RUN === "true" || process.env.OPENCODE_NON_INTERACTIVE === "true") { + return true + } + + if (process.env.GITHUB_ACTIONS === "true") { + return true + } + + if (!process.stdout.isTTY) { + return true + } + + return false +} diff --git a/src/hooks/non-interactive-env/index.ts b/src/hooks/non-interactive-env/index.ts index c05d45f..d4d9c00 100644 --- a/src/hooks/non-interactive-env/index.ts +++ b/src/hooks/non-interactive-env/index.ts @@ -3,6 +3,7 @@ import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./consta import { log } from "../../shared" export * from "./constants" +export * from "./detector" export * from "./types" const BANNED_COMMAND_PATTERNS = SHELL_COMMAND_PATTERNS.banned diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts index 26cdde7..e1de485 100644 --- a/src/hooks/todo-continuation-enforcer.ts +++ b/src/hooks/todo-continuation-enforcer.ts @@ -6,10 +6,16 @@ import { findNearestMessageWithFields, MESSAGE_STORAGE, } from "../features/hook-message-injector" +import type { BackgroundManager } from "../features/background-agent" import { log } from "../shared/logger" +import { isNonInteractive } from "./non-interactive-env/detector" const HOOK_NAME = "todo-continuation-enforcer" +export interface TodoContinuationEnforcerOptions { + backgroundManager?: BackgroundManager +} + export interface TodoContinuationEnforcer { handler: (input: { event: { type: string; properties?: unknown } }) => Promise markRecovering: (sessionID: string) => void @@ -70,12 +76,17 @@ interface CountdownState { intervalId: ReturnType } -export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer { +export function createTodoContinuationEnforcer( + ctx: PluginInput, + options: TodoContinuationEnforcerOptions = {} +): TodoContinuationEnforcer { + const { backgroundManager } = options const remindedSessions = new Set() const interruptedSessions = new Set() const errorSessions = new Set() const recoveringSessions = new Set() const pendingCountdowns = new Map() + const preemptivelyInjectedSessions = new Set() const markRecovering = (sessionID: string): void => { recoveringSessions.add(sessionID) @@ -269,7 +280,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati const info = props?.info as Record | undefined const sessionID = info?.sessionID as string | undefined const role = info?.role as string | undefined - const finish = info?.finish as boolean | undefined + const finish = info?.finish as string | undefined log(`[${HOOK_NAME}] message.updated received`, { sessionID, role, finish }) if (sessionID && role === "user") { @@ -279,13 +290,74 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati pendingCountdowns.delete(sessionID) log(`[${HOOK_NAME}] Cancelled countdown on user message`, { sessionID }) } - // Allow new continuation after user sends another message remindedSessions.delete(sessionID) + preemptivelyInjectedSessions.delete(sessionID) } if (sessionID && role === "assistant" && finish) { remindedSessions.delete(sessionID) log(`[${HOOK_NAME}] Cleared reminded state on assistant finish`, { sessionID }) + + const isTerminalFinish = finish && !["tool-calls", "unknown"].includes(finish) + if (isTerminalFinish && isNonInteractive()) { + log(`[${HOOK_NAME}] Terminal finish in non-interactive mode`, { sessionID, finish }) + + const mainSessionID = getMainSessionID() + if (mainSessionID && sessionID !== mainSessionID) { + log(`[${HOOK_NAME}] Skipped preemptive: not main session`, { sessionID, mainSessionID }) + return + } + + if (preemptivelyInjectedSessions.has(sessionID)) { + log(`[${HOOK_NAME}] Skipped preemptive: already injected`, { sessionID }) + return + } + + if (recoveringSessions.has(sessionID) || errorSessions.has(sessionID) || interruptedSessions.has(sessionID)) { + log(`[${HOOK_NAME}] Skipped preemptive: session in error/recovery state`, { sessionID }) + return + } + + const hasRunningBgTasks = backgroundManager + ? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running") + : false + + let hasIncompleteTodos = false + try { + const response = await ctx.client.session.todo({ path: { id: sessionID } }) + const todos = (response.data ?? response) as Todo[] + hasIncompleteTodos = todos?.some((t) => t.status !== "completed" && t.status !== "cancelled") ?? false + } catch { + log(`[${HOOK_NAME}] Failed to fetch todos for preemptive check`, { sessionID }) + } + + if (hasRunningBgTasks || hasIncompleteTodos) { + log(`[${HOOK_NAME}] Preemptive injection needed`, { sessionID, hasRunningBgTasks, hasIncompleteTodos }) + preemptivelyInjectedSessions.add(sessionID) + + try { + const messageDir = getMessageDir(sessionID) + const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + + const prompt = hasRunningBgTasks + ? "[SYSTEM] Background tasks are still running. Wait for their completion before proceeding." + : CONTINUATION_PROMPT + + await ctx.client.session.prompt({ + path: { id: sessionID }, + body: { + agent: prevMessage?.agent, + parts: [{ type: "text", text: prompt }], + }, + query: { directory: ctx.directory }, + }) + log(`[${HOOK_NAME}] Preemptive injection successful`, { sessionID }) + } catch (err) { + log(`[${HOOK_NAME}] Preemptive injection failed`, { sessionID, error: String(err) }) + preemptivelyInjectedSessions.delete(sessionID) + } + } + } } } @@ -296,6 +368,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati interruptedSessions.delete(sessionInfo.id) errorSessions.delete(sessionInfo.id) recoveringSessions.delete(sessionInfo.id) + preemptivelyInjectedSessions.delete(sessionInfo.id) const countdown = pendingCountdowns.get(sessionInfo.id) if (countdown) { diff --git a/src/index.ts b/src/index.ts index accbe66..cbb21a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -227,9 +227,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { return undefined; }; - const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") - ? createTodoContinuationEnforcer(ctx) - : null; const contextWindowMonitor = isHookEnabled("context-window-monitor") ? createContextWindowMonitorHook(ctx) : null; @@ -240,13 +237,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? createSessionNotification(ctx) : null; - // Wire up recovery state tracking between session-recovery and todo-continuation-enforcer - // This prevents the continuation enforcer from injecting prompts during active recovery - if (sessionRecovery && todoContinuationEnforcer) { - sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering); - sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete); - } - const commentChecker = isHookEnabled("comment-checker") ? createCommentCheckerHooks() : null; @@ -305,6 +295,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const backgroundManager = new BackgroundManager(ctx); + const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") + ? createTodoContinuationEnforcer(ctx, { backgroundManager }) + : null; + + if (sessionRecovery && todoContinuationEnforcer) { + sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering); + sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete); + } + const backgroundNotificationHook = isHookEnabled("background-notification") ? createBackgroundNotificationHook(backgroundManager) : null;