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)
This commit is contained in:
19
src/hooks/non-interactive-env/detector.ts
Normal file
19
src/hooks/non-interactive-env/detector.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./consta
|
|||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
|
||||||
export * from "./constants"
|
export * from "./constants"
|
||||||
|
export * from "./detector"
|
||||||
export * from "./types"
|
export * from "./types"
|
||||||
|
|
||||||
const BANNED_COMMAND_PATTERNS = SHELL_COMMAND_PATTERNS.banned
|
const BANNED_COMMAND_PATTERNS = SHELL_COMMAND_PATTERNS.banned
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import {
|
|||||||
findNearestMessageWithFields,
|
findNearestMessageWithFields,
|
||||||
MESSAGE_STORAGE,
|
MESSAGE_STORAGE,
|
||||||
} from "../features/hook-message-injector"
|
} from "../features/hook-message-injector"
|
||||||
|
import type { BackgroundManager } from "../features/background-agent"
|
||||||
import { log } from "../shared/logger"
|
import { log } from "../shared/logger"
|
||||||
|
import { isNonInteractive } from "./non-interactive-env/detector"
|
||||||
|
|
||||||
const HOOK_NAME = "todo-continuation-enforcer"
|
const HOOK_NAME = "todo-continuation-enforcer"
|
||||||
|
|
||||||
|
export interface TodoContinuationEnforcerOptions {
|
||||||
|
backgroundManager?: BackgroundManager
|
||||||
|
}
|
||||||
|
|
||||||
export interface TodoContinuationEnforcer {
|
export interface TodoContinuationEnforcer {
|
||||||
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||||
markRecovering: (sessionID: string) => void
|
markRecovering: (sessionID: string) => void
|
||||||
@@ -70,12 +76,17 @@ interface CountdownState {
|
|||||||
intervalId: ReturnType<typeof setInterval>
|
intervalId: ReturnType<typeof setInterval>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer {
|
export function createTodoContinuationEnforcer(
|
||||||
|
ctx: PluginInput,
|
||||||
|
options: TodoContinuationEnforcerOptions = {}
|
||||||
|
): TodoContinuationEnforcer {
|
||||||
|
const { backgroundManager } = options
|
||||||
const remindedSessions = new Set<string>()
|
const remindedSessions = new Set<string>()
|
||||||
const interruptedSessions = new Set<string>()
|
const interruptedSessions = new Set<string>()
|
||||||
const errorSessions = new Set<string>()
|
const errorSessions = new Set<string>()
|
||||||
const recoveringSessions = new Set<string>()
|
const recoveringSessions = new Set<string>()
|
||||||
const pendingCountdowns = new Map<string, CountdownState>()
|
const pendingCountdowns = new Map<string, CountdownState>()
|
||||||
|
const preemptivelyInjectedSessions = new Set<string>()
|
||||||
|
|
||||||
const markRecovering = (sessionID: string): void => {
|
const markRecovering = (sessionID: string): void => {
|
||||||
recoveringSessions.add(sessionID)
|
recoveringSessions.add(sessionID)
|
||||||
@@ -269,7 +280,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
|
|||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = info?.sessionID as string | undefined
|
||||||
const role = info?.role 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 })
|
log(`[${HOOK_NAME}] message.updated received`, { sessionID, role, finish })
|
||||||
|
|
||||||
if (sessionID && role === "user") {
|
if (sessionID && role === "user") {
|
||||||
@@ -279,13 +290,74 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
|
|||||||
pendingCountdowns.delete(sessionID)
|
pendingCountdowns.delete(sessionID)
|
||||||
log(`[${HOOK_NAME}] Cancelled countdown on user message`, { sessionID })
|
log(`[${HOOK_NAME}] Cancelled countdown on user message`, { sessionID })
|
||||||
}
|
}
|
||||||
// Allow new continuation after user sends another message
|
|
||||||
remindedSessions.delete(sessionID)
|
remindedSessions.delete(sessionID)
|
||||||
|
preemptivelyInjectedSessions.delete(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionID && role === "assistant" && finish) {
|
if (sessionID && role === "assistant" && finish) {
|
||||||
remindedSessions.delete(sessionID)
|
remindedSessions.delete(sessionID)
|
||||||
log(`[${HOOK_NAME}] Cleared reminded state on assistant finish`, { 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)
|
interruptedSessions.delete(sessionInfo.id)
|
||||||
errorSessions.delete(sessionInfo.id)
|
errorSessions.delete(sessionInfo.id)
|
||||||
recoveringSessions.delete(sessionInfo.id)
|
recoveringSessions.delete(sessionInfo.id)
|
||||||
|
preemptivelyInjectedSessions.delete(sessionInfo.id)
|
||||||
|
|
||||||
const countdown = pendingCountdowns.get(sessionInfo.id)
|
const countdown = pendingCountdowns.get(sessionInfo.id)
|
||||||
if (countdown) {
|
if (countdown) {
|
||||||
|
|||||||
19
src/index.ts
19
src/index.ts
@@ -227,9 +227,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
|
||||||
? createTodoContinuationEnforcer(ctx)
|
|
||||||
: null;
|
|
||||||
const contextWindowMonitor = isHookEnabled("context-window-monitor")
|
const contextWindowMonitor = isHookEnabled("context-window-monitor")
|
||||||
? createContextWindowMonitorHook(ctx)
|
? createContextWindowMonitorHook(ctx)
|
||||||
: null;
|
: null;
|
||||||
@@ -240,13 +237,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
? createSessionNotification(ctx)
|
? createSessionNotification(ctx)
|
||||||
: null;
|
: 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")
|
const commentChecker = isHookEnabled("comment-checker")
|
||||||
? createCommentCheckerHooks()
|
? createCommentCheckerHooks()
|
||||||
: null;
|
: null;
|
||||||
@@ -305,6 +295,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
|
|
||||||
const backgroundManager = new BackgroundManager(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")
|
const backgroundNotificationHook = isHookEnabled("background-notification")
|
||||||
? createBackgroundNotificationHook(backgroundManager)
|
? createBackgroundNotificationHook(backgroundManager)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
Reference in New Issue
Block a user