feat(hook): add todo-continuation-enforcer for automatic task resumption

This commit is contained in:
YeonGyu-Kim
2025-12-03 13:11:58 +09:00
parent b6d991066e
commit a562a78050
3 changed files with 153 additions and 120 deletions

View File

@@ -0,0 +1,144 @@
import type { PluginInput } from "@opencode-ai/plugin"
interface Todo {
content: string
status: string
priority: string
id: string
}
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO ENFORCEMENT]
Your todo list is NOT complete. There are still incomplete tasks remaining.
CRITICAL INSTRUCTION:
- You MUST NOT stop working until ALL todos are marked as completed
- Continue working on the next pending task immediately
- Work honestly and diligently to finish every task
- Do NOT ask for permission to continue - just proceed with the work
- Mark each task as completed as soon as you finish it
Resume your work NOW.`
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
}
export function createTodoContinuationEnforcer(ctx: PluginInput) {
const remindedSessions = new Set<string>()
const interruptedSessions = new Set<string>()
const errorSessions = new Set<string>()
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
errorSessions.add(sessionID)
if (detectInterrupt(props?.error)) {
interruptedSessions.add(sessionID)
}
}
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
// Wait for potential session.error events to be processed first
await new Promise(resolve => setTimeout(resolve, 150))
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
interruptedSessions.delete(sessionID)
errorSessions.delete(sessionID)
if (shouldBypass) {
return
}
if (remindedSessions.has(sessionID)) {
return
}
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({
path: { id: sessionID },
})
todos = (response.data ?? response) as Todo[]
} catch {
return
}
if (!todos || todos.length === 0) {
return
}
const incomplete = todos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled"
)
if (incomplete.length === 0) {
return
}
remindedSessions.add(sessionID)
// Re-check if abort occurred during the delay
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
remindedSessions.delete(sessionID)
return
}
try {
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
parts: [
{
type: "text",
text: `${CONTINUATION_PROMPT}\n\n[Status: ${incomplete.length}/${todos.length} tasks remaining]`,
},
],
},
query: { directory: ctx.directory },
})
} catch {
remindedSessions.delete(sessionID)
}
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
if (sessionID && info?.role === "user") {
remindedSessions.delete(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)
}
}
}
}