From a562a7805013df675d64df38cffcc410ab06b7e8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 3 Dec 2025 13:11:58 +0900 Subject: [PATCH] feat(hook): add todo-continuation-enforcer for automatic task resumption --- src/hooks/index.ts | 1 + src/hooks/todo-continuation-enforcer.ts | 144 ++++++++++++++++++++++++ src/index.ts | 128 ++------------------- 3 files changed, 153 insertions(+), 120 deletions(-) create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/todo-continuation-enforcer.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..beee45f --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from "./todo-continuation-enforcer" diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts new file mode 100644 index 0000000..2377439 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer.ts @@ -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 + 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() + const interruptedSessions = new Set() + const errorSessions = new Set() + + return async ({ event }: { event: { type: string; properties?: unknown } }) => { + const props = event.properties as Record | 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 | 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) + } + } + } +} diff --git a/src/index.ts b/src/index.ts index cfa30f8..f6dd662 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,132 +1,20 @@ -import type { Plugin } from "@opencode-ai/plugin"; -import { builtinAgents } from "./agents"; -import { - createTodoContinuationEnforcer, - createContextWindowMonitorHook, -} from "./hooks"; -import { updateTerminalTitle } from "./features/terminal"; +import type { Plugin } from "@opencode-ai/plugin" +import { builtinAgents } from "./agents" +import { createTodoContinuationEnforcer } from "./hooks" const OhMyOpenCodePlugin: Plugin = async (ctx) => { - const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx); - const contextWindowMonitor = createContextWindowMonitorHook(ctx); - updateTerminalTitle({ sessionId: "main" }); - - let mainSessionID: string | undefined; - let currentSessionID: string | undefined; - let currentSessionTitle: string | undefined; + const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx) return { config: async (config) => { - if (!config) return; config.agent = { ...config.agent, ...builtinAgents, - }; - }, - - event: async (input) => { - await todoContinuationEnforcer(input); - await contextWindowMonitor.event(input); - - const { event } = input; - const props = event.properties as Record | undefined; - - if (event.type === "session.created") { - const sessionInfo = props?.info as - | { id?: string; title?: string; parentID?: string } - | undefined; - if (!sessionInfo?.parentID) { - mainSessionID = sessionInfo?.id; - currentSessionID = sessionInfo?.id; - currentSessionTitle = sessionInfo?.title; - updateTerminalTitle({ - sessionId: currentSessionID || "main", - status: "idle", - directory: ctx.directory, - sessionTitle: currentSessionTitle, - }); - } - } - - if (event.type === "session.updated") { - const sessionInfo = props?.info as - | { id?: string; title?: string; parentID?: string } - | undefined; - if (!sessionInfo?.parentID) { - currentSessionID = sessionInfo?.id; - currentSessionTitle = sessionInfo?.title; - updateTerminalTitle({ - sessionId: currentSessionID || "main", - status: "processing", - directory: ctx.directory, - sessionTitle: currentSessionTitle, - }); - } - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id === mainSessionID) { - mainSessionID = undefined; - currentSessionID = undefined; - currentSessionTitle = undefined; - updateTerminalTitle({ - sessionId: "main", - status: "idle", - }); - } - } - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined; - if (sessionID && sessionID === mainSessionID) { - updateTerminalTitle({ - sessionId: sessionID, - status: "error", - directory: ctx.directory, - sessionTitle: currentSessionTitle, - }); - } - } - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined; - if (sessionID && sessionID === mainSessionID) { - updateTerminalTitle({ - sessionId: sessionID, - status: "idle", - directory: ctx.directory, - sessionTitle: currentSessionTitle, - }); - } } }, - "tool.execute.before": async (input, _output) => { - if (input.sessionID === mainSessionID) { - updateTerminalTitle({ - sessionId: input.sessionID, - status: "tool", - currentTool: input.tool, - directory: ctx.directory, - sessionTitle: currentSessionTitle, - }); - } - }, + event: todoContinuationEnforcer, + } +} - "tool.execute.after": async (input, output) => { - await contextWindowMonitor["tool.execute.after"](input, output); - - if (input.sessionID === mainSessionID) { - updateTerminalTitle({ - sessionId: input.sessionID, - status: "idle", - directory: ctx.directory, - sessionTitle: currentSessionTitle, - }); - } - }, - }; -}; - -export default OhMyOpenCodePlugin; +export default OhMyOpenCodePlugin