feat(hook): add todo-continuation-enforcer for automatic task resumption
This commit is contained in:
1
src/hooks/index.ts
Normal file
1
src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./todo-continuation-enforcer"
|
||||||
144
src/hooks/todo-continuation-enforcer.ts
Normal file
144
src/hooks/todo-continuation-enforcer.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/index.ts
124
src/index.ts
@@ -1,132 +1,20 @@
|
|||||||
import type { Plugin } from "@opencode-ai/plugin";
|
import type { Plugin } from "@opencode-ai/plugin"
|
||||||
import { builtinAgents } from "./agents";
|
import { builtinAgents } from "./agents"
|
||||||
import {
|
import { createTodoContinuationEnforcer } from "./hooks"
|
||||||
createTodoContinuationEnforcer,
|
|
||||||
createContextWindowMonitorHook,
|
|
||||||
} from "./hooks";
|
|
||||||
import { updateTerminalTitle } from "./features/terminal";
|
|
||||||
|
|
||||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
|
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx)
|
||||||
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
|
|
||||||
updateTerminalTitle({ sessionId: "main" });
|
|
||||||
|
|
||||||
let mainSessionID: string | undefined;
|
|
||||||
let currentSessionID: string | undefined;
|
|
||||||
let currentSessionTitle: string | undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: async (config) => {
|
config: async (config) => {
|
||||||
if (!config) return;
|
|
||||||
config.agent = {
|
config.agent = {
|
||||||
...config.agent,
|
...config.agent,
|
||||||
...builtinAgents,
|
...builtinAgents,
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
event: async (input) => {
|
|
||||||
await todoContinuationEnforcer(input);
|
|
||||||
await contextWindowMonitor.event(input);
|
|
||||||
|
|
||||||
const { event } = input;
|
|
||||||
const props = event.properties as Record<string, unknown> | 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) => {
|
event: todoContinuationEnforcer,
|
||||||
if (input.sessionID === mainSessionID) {
|
|
||||||
updateTerminalTitle({
|
|
||||||
sessionId: input.sessionID,
|
|
||||||
status: "tool",
|
|
||||||
currentTool: input.tool,
|
|
||||||
directory: ctx.directory,
|
|
||||||
sessionTitle: currentSessionTitle,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
"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
|
||||||
|
|||||||
Reference in New Issue
Block a user