Files
oh-my-opencode-free-fork/src/hooks/todo-continuation-enforcer.ts
YeonGyu-Kim 5fbcb88a3f fix(todo-continuation-enforcer): persist errorBypass mode until user sends message
Previously, errorBypass mode was cleared on session.idle, causing continuation
to fire again on next idle event. This led to unwanted task resumption after
user abort.

Changes:
- Don't clear errorBypass on session.idle - stay in errorBypass mode
- Clear errorBypass to idle only when user sends a new message

This ensures that once user aborts, the enforcer respects that decision until
the user explicitly sends a message to resume.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 17:48:33 +09:00

564 lines
19 KiB
TypeScript

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,
} from "../features/hook-message-injector"
import type { BackgroundManager } from "../features/background-agent"
import { log } from "../shared/logger"
const HOOK_NAME = "todo-continuation-enforcer"
export interface TodoContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
}
export interface TodoContinuationEnforcer {
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
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`
const COUNTDOWN_SECONDS = 2
const TOAST_DURATION_MS = 900
const MIN_INJECTION_INTERVAL_MS = 10_000
// ============================================================================
// STATE MACHINE TYPES
// ============================================================================
type SessionMode =
| "idle" // Observed idle, no countdown started yet
| "countingDown" // Waiting N seconds before injecting
| "injecting" // Currently calling session.prompt
| "recovering" // Session recovery in progress (external control)
| "errorBypass" // Bypass mode after session.error/interrupt
interface SessionState {
version: number // Monotonic generation token - increment to invalidate pending callbacks
mode: SessionMode
timer?: ReturnType<typeof setTimeout> // Pending countdown timer
lastAttemptedAt?: number // Timestamp of last injection attempt (throttle all attempts)
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
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<string, unknown>
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
}
function getIncompleteCount(todos: Todo[]): number {
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
}
// ============================================================================
// MAIN IMPLEMENTATION
// ============================================================================
export function createTodoContinuationEnforcer(
ctx: PluginInput,
options: TodoContinuationEnforcerOptions = {}
): TodoContinuationEnforcer {
const { backgroundManager } = options
// Single source of truth: per-session state machine
const sessions = new Map<string, SessionState>()
// ============================================================================
// STATE HELPERS
// ============================================================================
function getOrCreateState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
state = { version: 0, mode: "idle" }
sessions.set(sessionID, state)
}
return state
}
function clearTimer(state: SessionState): void {
if (state.timer) {
clearTimeout(state.timer)
state.timer = undefined
}
}
/**
* Invalidate any pending or in-flight operation by incrementing version.
* ALWAYS bumps version regardless of current mode to prevent last-mile races.
*/
function invalidate(sessionID: string, reason: string): void {
const state = sessions.get(sessionID)
if (!state) return
// Skip if in recovery mode (external control)
if (state.mode === "recovering") return
state.version++
clearTimer(state)
if (state.mode !== "idle" && state.mode !== "errorBypass") {
log(`[${HOOK_NAME}] Invalidated`, { sessionID, reason, prevMode: state.mode, newVersion: state.version })
state.mode = "idle"
}
}
/**
* Check if this is the main session (not a subagent session).
*/
function isMainSession(sessionID: string): boolean {
const mainSessionID = getMainSessionID()
// If no main session is set, allow all. If set, only allow main.
return !mainSessionID || sessionID === mainSessionID
}
// ============================================================================
// EXTERNAL API
// ============================================================================
const markRecovering = (sessionID: string): void => {
const state = getOrCreateState(sessionID)
invalidate(sessionID, "entering recovery mode")
state.mode = "recovering"
log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID })
}
const markRecoveryComplete = (sessionID: string): void => {
const state = sessions.get(sessionID)
if (state && state.mode === "recovering") {
state.mode = "idle"
log(`[${HOOK_NAME}] Session recovery complete`, { sessionID })
}
}
// ============================================================================
// TOAST HELPER
// ============================================================================
async function showCountdownToast(seconds: number, incompleteCount: number): Promise<void> {
await ctx.client.tui.showToast({
body: {
title: "Todo Continuation",
message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`,
variant: "warning" as const,
duration: TOAST_DURATION_MS,
},
}).catch(() => {})
}
// ============================================================================
// CORE INJECTION LOGIC
// ============================================================================
async function executeInjection(sessionID: string, capturedVersion: number): Promise<void> {
const state = sessions.get(sessionID)
if (!state) return
// Version check: if version changed since we started, abort
if (state.version !== capturedVersion) {
log(`[${HOOK_NAME}] Injection aborted: version mismatch`, {
sessionID, capturedVersion, currentVersion: state.version
})
return
}
// Mode check: must still be in countingDown mode
if (state.mode !== "countingDown") {
log(`[${HOOK_NAME}] Injection aborted: mode changed`, {
sessionID, mode: state.mode
})
return
}
// Throttle check: minimum interval between injection attempts
if (state.lastAttemptedAt) {
const elapsed = Date.now() - state.lastAttemptedAt
if (elapsed < MIN_INJECTION_INTERVAL_MS) {
log(`[${HOOK_NAME}] Injection throttled: too soon since last injection`, {
sessionID, elapsedMs: elapsed, minIntervalMs: MIN_INJECTION_INTERVAL_MS
})
state.mode = "idle"
return
}
}
state.mode = "injecting"
// Re-verify todos (CRITICAL: always re-check before injecting)
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[]
} catch (err) {
log(`[${HOOK_NAME}] Failed to fetch todos for injection`, { sessionID, error: String(err) })
state.mode = "idle"
return
}
// Version check again after async operation
if (state.version !== capturedVersion) {
log(`[${HOOK_NAME}] Injection aborted after todo fetch: version mismatch`, { sessionID })
state.mode = "idle"
return
}
const incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) {
log(`[${HOOK_NAME}] No incomplete todos at injection time`, { sessionID, total: todos.length })
state.mode = "idle"
return
}
// Skip entirely if background tasks are running (no false positives)
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks still running`, { sessionID })
state.mode = "idle"
return
}
// Get previous message agent info
const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
// Check write permission
const agentHasWritePermission = !prevMessage?.tools ||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
if (!agentHasWritePermission) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, {
sessionID, agent: prevMessage?.agent, tools: prevMessage?.tools
})
state.mode = "idle"
return
}
// Plan mode agents only analyze and plan, not implement - skip todo continuation
const agentName = prevMessage?.agent?.toLowerCase() ?? ""
const isPlanModeAgent = agentName === "plan" || agentName === "planner-sisyphus"
if (isPlanModeAgent) {
log(`[${HOOK_NAME}] Skipped: plan mode agent detected`, {
sessionID, agent: prevMessage?.agent
})
state.mode = "idle"
return
}
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incompleteCount}/${todos.length} completed, ${incompleteCount} remaining]`
// Final version check right before API call (last-mile race mitigation)
if (state.version !== capturedVersion) {
log(`[${HOOK_NAME}] Injection aborted: version changed before API call`, { sessionID })
state.mode = "idle"
return
}
// Set lastAttemptedAt BEFORE calling API (throttle attempts, not just successes)
state.lastAttemptedAt = Date.now()
try {
log(`[${HOOK_NAME}] Injecting continuation prompt`, {
sessionID,
agent: prevMessage?.agent,
incompleteCount
})
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: prevMessage?.agent,
parts: [{ type: "text", text: prompt }],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Continuation prompt injected successfully`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Prompt injection failed`, { sessionID, error: String(err) })
}
state.mode = "idle"
}
// ============================================================================
// COUNTDOWN STARTER
// ============================================================================
function startCountdown(sessionID: string, incompleteCount: number): void {
const state = getOrCreateState(sessionID)
// Cancel any existing countdown
invalidate(sessionID, "starting new countdown")
// Increment version for this new countdown
state.version++
state.mode = "countingDown"
const capturedVersion = state.version
log(`[${HOOK_NAME}] Starting countdown`, {
sessionID,
seconds: COUNTDOWN_SECONDS,
version: capturedVersion,
incompleteCount
})
// Show initial toast
showCountdownToast(COUNTDOWN_SECONDS, incompleteCount)
// Show countdown toasts
let secondsRemaining = COUNTDOWN_SECONDS
const toastInterval = setInterval(() => {
// Check if countdown was cancelled
if (state.version !== capturedVersion) {
clearInterval(toastInterval)
return
}
secondsRemaining--
if (secondsRemaining > 0) {
showCountdownToast(secondsRemaining, incompleteCount)
}
}, 1000)
// Schedule the injection
state.timer = setTimeout(() => {
clearInterval(toastInterval)
clearTimer(state)
executeInjection(sessionID, capturedVersion)
}, COUNTDOWN_SECONDS * 1000)
}
// ============================================================================
// EVENT HANDLER
// ============================================================================
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
// -------------------------------------------------------------------------
// SESSION.ERROR - Enter error bypass mode
// -------------------------------------------------------------------------
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const isInterrupt = detectInterrupt(props?.error)
const state = getOrCreateState(sessionID)
invalidate(sessionID, isInterrupt ? "user interrupt" : "session error")
state.mode = "errorBypass"
log(`[${HOOK_NAME}] session.error received`, { sessionID, isInterrupt, error: props?.error })
return
}
// -------------------------------------------------------------------------
// SESSION.IDLE - Main trigger for todo continuation
// -------------------------------------------------------------------------
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
log(`[${HOOK_NAME}] session.idle received`, { sessionID })
// Skip if not main session
if (!isMainSession(sessionID)) {
log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID })
return
}
const state = getOrCreateState(sessionID)
// Skip if in recovery mode
if (state.mode === "recovering") {
log(`[${HOOK_NAME}] Skipped: session in recovery mode`, { sessionID })
return
}
// Skip if in error bypass mode (DO NOT clear - wait for user message)
if (state.mode === "errorBypass") {
log(`[${HOOK_NAME}] Skipped: error bypass (awaiting user message to resume)`, { sessionID })
return
}
// Skip if already counting down or injecting
if (state.mode === "countingDown" || state.mode === "injecting") {
log(`[${HOOK_NAME}] Skipped: already ${state.mode}`, { sessionID })
return
}
// Fetch todos
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[]
} 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 incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) {
log(`[${HOOK_NAME}] All todos completed`, { sessionID, total: todos.length })
return
}
// Skip if background tasks are running (avoid toast spam with no injection)
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks still running`, { sessionID })
return
}
log(`[${HOOK_NAME}] Found incomplete todos`, {
sessionID,
incomplete: incompleteCount,
total: todos.length
})
startCountdown(sessionID, incompleteCount)
return
}
// -------------------------------------------------------------------------
// MESSAGE.UPDATED - Cancel countdown on activity
// -------------------------------------------------------------------------
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
const finish = info?.finish as string | undefined
if (!sessionID) return
// User message: Always cancel countdown and clear errorBypass
if (role === "user") {
const state = sessions.get(sessionID)
if (state?.mode === "errorBypass") {
state.mode = "idle"
log(`[${HOOK_NAME}] User message cleared errorBypass mode`, { sessionID })
}
invalidate(sessionID, "user message received")
return
}
// Assistant message WITHOUT finish: Agent is working, cancel countdown
if (role === "assistant" && !finish) {
invalidate(sessionID, "assistant is working (streaming)")
return
}
// Assistant message WITH finish: Agent finished a turn (let session.idle handle it)
if (role === "assistant" && finish) {
log(`[${HOOK_NAME}] Assistant turn finished`, { sessionID, finish })
return
}
return
}
// -------------------------------------------------------------------------
// MESSAGE.PART.UPDATED - Cancel countdown on streaming activity
// -------------------------------------------------------------------------
if (event.type === "message.part.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
if (sessionID && role === "assistant") {
invalidate(sessionID, "assistant streaming")
}
return
}
// -------------------------------------------------------------------------
// TOOL EVENTS - Cancel countdown when tools are executing
// -------------------------------------------------------------------------
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
invalidate(sessionID, `tool execution (${event.type})`)
}
return
}
// -------------------------------------------------------------------------
// SESSION.DELETED - Cleanup
// -------------------------------------------------------------------------
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
const state = sessions.get(sessionInfo.id)
if (state) {
clearTimer(state)
}
sessions.delete(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted, state cleaned up`, { sessionID: sessionInfo.id })
}
return
}
}
return {
handler,
markRecovering,
markRecoveryComplete,
}
}