diff --git a/src/config/schema.ts b/src/config/schema.ts index e7cb30aea..34ec376be 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -59,7 +59,6 @@ export const AgentNameSchema = BuiltinAgentNameSchema export const HookNameSchema = z.enum([ "todo-continuation-enforcer", - "task-continuation-enforcer", "context-window-monitor", "session-recovery", "session-notification", diff --git a/src/hooks/index.ts b/src/hooks/index.ts index eceadff5c..a964780c7 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,5 +1,4 @@ export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer"; -export { createTaskContinuationEnforcer, type TaskContinuationEnforcer } from "./task-continuation-enforcer"; export { createContextWindowMonitorHook } from "./context-window-monitor"; export { createSessionNotification } from "./session-notification"; export { createSessionRecoveryHook, type SessionRecoveryHook, type SessionRecoveryOptions } from "./session-recovery"; diff --git a/src/hooks/task-continuation-enforcer.test.ts b/src/hooks/task-continuation-enforcer.test.ts deleted file mode 100644 index 1a0cbc75d..000000000 --- a/src/hooks/task-continuation-enforcer.test.ts +++ /dev/null @@ -1,763 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" - -import { mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" - -import { BackgroundManager } from "../features/background-agent" -import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state" -import type { OhMyOpenCodeConfig } from "../config/schema" -import { TaskObjectSchema } from "../tools/task/types" -import type { TaskObject } from "../tools/task/types" -import { createTaskContinuationEnforcer } from "./task-continuation-enforcer" - -type TimerCallback = (...args: any[]) => void - -interface FakeTimers { - advanceBy: (ms: number, advanceClock?: boolean) => Promise - restore: () => void -} - -function createFakeTimers(): FakeTimers { - const originalNow = Date.now() - let clockNow = originalNow - let timerNow = 0 - let nextId = 1 - const timers = new Map() - const cleared = new Set() - - const original = { - setTimeout: globalThis.setTimeout, - clearTimeout: globalThis.clearTimeout, - setInterval: globalThis.setInterval, - clearInterval: globalThis.clearInterval, - dateNow: Date.now, - } - - const normalizeDelay = (delay?: number) => { - if (typeof delay !== "number" || !Number.isFinite(delay)) return 0 - return delay < 0 ? 0 : delay - } - - const schedule = (callback: TimerCallback, delay: number | undefined, interval: number | null, args: any[]) => { - const id = nextId++ - timers.set(id, { - id, - time: timerNow + normalizeDelay(delay), - interval, - callback, - args, - }) - return id - } - - const clear = (id: number | undefined) => { - if (typeof id !== "number") return - cleared.add(id) - timers.delete(id) - } - - globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => { - return schedule(callback, delay, null, args) as unknown as ReturnType - }) as typeof setTimeout - - globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => { - const interval = normalizeDelay(delay) - return schedule(callback, delay, interval, args) as unknown as ReturnType - }) as typeof setInterval - - globalThis.clearTimeout = ((id?: number) => { - clear(id) - }) as typeof clearTimeout - - globalThis.clearInterval = ((id?: number) => { - clear(id) - }) as typeof clearInterval - - Date.now = () => clockNow - - const advanceBy = async (ms: number, advanceClock: boolean = false) => { - const clamped = Math.max(0, ms) - const target = timerNow + clamped - if (advanceClock) { - clockNow += clamped - } - while (true) { - let next: { id: number; time: number; interval: number | null; callback: TimerCallback; args: any[] } | undefined - for (const timer of timers.values()) { - if (timer.time <= target && (!next || timer.time < next.time)) { - next = timer - } - } - if (!next) break - - timerNow = next.time - timers.delete(next.id) - next.callback(...next.args) - - if (next.interval !== null && !cleared.has(next.id)) { - timers.set(next.id, { - id: next.id, - time: timerNow + next.interval, - interval: next.interval, - callback: next.callback, - args: next.args, - }) - } else { - cleared.delete(next.id) - } - - await Promise.resolve() - } - timerNow = target - await Promise.resolve() - } - - const restore = () => { - globalThis.setTimeout = original.setTimeout - globalThis.clearTimeout = original.clearTimeout - globalThis.setInterval = original.setInterval - globalThis.clearInterval = original.clearInterval - Date.now = original.dateNow - } - - return { advanceBy, restore } -} - -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -describe("task-continuation-enforcer", () => { - let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }> - let toastCalls: Array<{ title: string; message: string }> - let fakeTimers: FakeTimers - let taskDir: string - - interface MockMessage { - info: { - id: string - role: "user" | "assistant" - error?: { name: string; data?: { message: string } } - } - } - - let mockMessages: MockMessage[] = [] - - function createMockPluginInput() { - return { - client: { - session: { - messages: async () => ({ data: mockMessages }), - prompt: async (opts: any) => { - promptCalls.push({ - sessionID: opts.path.id, - agent: opts.body.agent, - model: opts.body.model, - text: opts.body.parts[0].text, - }) - return {} - }, - }, - tui: { - showToast: async (opts: any) => { - toastCalls.push({ - title: opts.body.title, - message: opts.body.message, - }) - return {} - }, - }, - }, - directory: "/tmp/test", - } as any - } - - function createTempTaskDir(): string { - return mkdtempSync(join(tmpdir(), "omo-task-continuation-")) - } - - function writeTaskFile(dir: string, task: TaskObject): void { - const parsed = TaskObjectSchema.safeParse(task) - expect(parsed.success).toBe(true) - if (!parsed.success) return - writeFileSync(join(dir, `${parsed.data.id}.json`), JSON.stringify(parsed.data), "utf-8") - } - - function writeCorruptedTaskFile(dir: string, taskId: string): void { - writeFileSync(join(dir, `${taskId}.json`), "{ this is not valid json", "utf-8") - } - - function createConfig(dir: string): Partial { - return { - sisyphus: { - tasks: { - claude_code_compat: true, - storage_path: dir, - }, - }, - } - } - - function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager { - return { - getTasksByParentSession: () => (runningTasks ? [{ status: "running" }] : []), - } as any - } - - beforeEach(() => { - fakeTimers = createFakeTimers() - _resetForTesting() - promptCalls = [] - toastCalls = [] - mockMessages = [] - taskDir = createTempTaskDir() - }) - - afterEach(() => { - fakeTimers.restore() - _resetForTesting() - rmSync(taskDir, { recursive: true, force: true }) - }) - - test("should inject continuation when idle with incomplete tasks on disk", async () => { - fakeTimers.restore() - // given - main session with incomplete tasks - const sessionID = "main-123" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - writeTaskFile(taskDir, { - id: "T-2", - subject: "Task 2", - description: "", - status: "completed", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), { - backgroundManager: new BackgroundManager(createMockPluginInput()), - }) - - // when - session goes idle - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - - // then - countdown toast shown - await wait(50) - expect(toastCalls.length).toBeGreaterThanOrEqual(1) - expect(toastCalls[0].title).toBe("Task Continuation") - - // then - after countdown, continuation injected - await wait(2500) - expect(promptCalls.length).toBe(1) - expect(promptCalls[0].text).toContain("TASK CONTINUATION") - }, { timeout: 15000 }) - - test("should NOT inject when all tasks are completed", async () => { - // given - session with all tasks completed - const sessionID = "main-456" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "completed", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - session goes idle - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - no continuation injected - expect(promptCalls).toHaveLength(0) - }) - - test("should NOT inject when all tasks are deleted", async () => { - // given - session with all tasks deleted - const sessionID = "main-deleted" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "deleted", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should NOT inject when no task files exist", async () => { - // given - empty task directory - const sessionID = "main-none" - setMainSession(sessionID) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should NOT inject when background tasks are running", async () => { - // given - session with incomplete tasks and running background tasks - const sessionID = "main-bg-running" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), { - backgroundManager: createMockBackgroundManager(true), - }) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should NOT inject for non-main session", async () => { - // given - main session set, different session goes idle - setMainSession("main-session") - const otherSession = "other-session" - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: otherSession } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should inject for background task session (subagent)", async () => { - fakeTimers.restore() - // given - main session set, background task session registered - setMainSession("main-session") - const bgTaskSession = "bg-task-session" - subagentSessions.add(bgTaskSession) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: bgTaskSession } } }) - - // then - await wait(2500) - expect(promptCalls.length).toBe(1) - expect(promptCalls[0].sessionID).toBe(bgTaskSession) - }, { timeout: 15000 }) - - test("should cancel countdown on user message after grace period", async () => { - // given - const sessionID = "main-cancel" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - session goes idle - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - - // when - wait past grace period (500ms), then user sends message - await fakeTimers.advanceBy(600, true) - await hook.handler({ - event: { - type: "message.updated", - properties: { info: { sessionID, role: "user" } }, - }, - }) - - // then - await fakeTimers.advanceBy(2500) - expect(promptCalls).toHaveLength(0) - }) - - test("should ignore user message within grace period", async () => { - fakeTimers.restore() - // given - const sessionID = "main-grace" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await hook.handler({ - event: { - type: "message.updated", - properties: { info: { sessionID, role: "user" } }, - }, - }) - - // then - countdown should continue - await wait(2500) - expect(promptCalls).toHaveLength(1) - }, { timeout: 15000 }) - - test("should cancel countdown on assistant activity", async () => { - // given - const sessionID = "main-assistant" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(500) - await hook.handler({ - event: { - type: "message.part.updated", - properties: { info: { sessionID, role: "assistant" } }, - }, - }) - - // then - await fakeTimers.advanceBy(3000) - expect(promptCalls).toHaveLength(0) - }) - - test("should cancel countdown on tool execution", async () => { - // given - const sessionID = "main-tool" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(500) - await hook.handler({ event: { type: "tool.execute.before", properties: { sessionID } } }) - - // then - await fakeTimers.advanceBy(3000) - expect(promptCalls).toHaveLength(0) - }) - - test("should skip injection during recovery mode", async () => { - // given - const sessionID = "main-recovery" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - hook.markRecovering(sessionID) - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should inject after recovery complete", async () => { - fakeTimers.restore() - // given - const sessionID = "main-recovery-done" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - hook.markRecovering(sessionID) - hook.markRecoveryComplete(sessionID) - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - - // then - await wait(3000) - expect(promptCalls.length).toBe(1) - }, { timeout: 15000 }) - - test("should cleanup on session deleted", async () => { - // given - const sessionID = "main-delete" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(500) - await hook.handler({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should skip when last assistant message was aborted (API fallback)", async () => { - // given - const sessionID = "main-api-abort" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - mockMessages = [ - { info: { id: "msg-1", role: "user" } }, - { info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError", data: { message: "aborted" } } } }, - ] - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should skip when abort detected via session.error event", async () => { - // given - const sessionID = "main-event-abort" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - mockMessages = [ - { info: { id: "msg-1", role: "user" } }, - { info: { id: "msg-2", role: "assistant" } }, - ] - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - abort error event fires - await hook.handler({ - event: { - type: "session.error", - properties: { sessionID, error: { name: "MessageAbortedError" } }, - }, - }) - - // when - session goes idle immediately after - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should handle corrupted task files gracefully (readJsonSafe returns null)", async () => { - fakeTimers.restore() - // given - const sessionID = "main-corrupt" - setMainSession(sessionID) - - writeCorruptedTaskFile(taskDir, "T-corrupt") - writeTaskFile(taskDir, { - id: "T-ok", - subject: "Task OK", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await wait(2500) - - // then - expect(promptCalls).toHaveLength(1) - }, { timeout: 15000 }) - - test("should NOT inject when isContinuationStopped returns true", async () => { - // given - const sessionID = "main-stopped" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), { - isContinuationStopped: (id) => id === sessionID, - }) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) - - test("should cancel all countdowns via cancelAllCountdowns", async () => { - // given - const sessionID = "main-cancel-all" - setMainSession(sessionID) - - writeTaskFile(taskDir, { - id: "T-1", - subject: "Task 1", - description: "", - status: "pending", - blocks: [], - blockedBy: [], - threadID: "test", - }) - - const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), {}) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(500) - hook.cancelAllCountdowns() - await fakeTimers.advanceBy(3000) - - // then - expect(promptCalls).toHaveLength(0) - }) -}) diff --git a/src/hooks/task-continuation-enforcer.ts b/src/hooks/task-continuation-enforcer.ts deleted file mode 100644 index f3b7f9c54..000000000 --- a/src/hooks/task-continuation-enforcer.ts +++ /dev/null @@ -1,530 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" - -import type { BackgroundManager } from "../features/background-agent" -import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state" -import { - findNearestMessageWithFields, - MESSAGE_STORAGE, - type ToolPermission, -} from "../features/hook-message-injector" -import { listTaskFiles, readJsonSafe, getTaskDir } from "../features/claude-tasks/storage" -import type { OhMyOpenCodeConfig } from "../config/schema" -import { TaskObjectSchema } from "../tools/task/types" -import type { TaskObject } from "../tools/task/types" -import { log } from "../shared/logger" -import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" - -const HOOK_NAME = "task-continuation-enforcer" - -const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"] - -export interface TaskContinuationEnforcerOptions { - backgroundManager?: BackgroundManager - skipAgents?: string[] - isContinuationStopped?: (sessionID: string) => boolean -} - -export interface TaskContinuationEnforcer { - handler: (input: { event: { type: string; properties?: unknown } }) => Promise - markRecovering: (sessionID: string) => void - markRecoveryComplete: (sessionID: string) => void - cancelAllCountdowns: () => void -} - -interface SessionState { - countdownTimer?: ReturnType - countdownInterval?: ReturnType - isRecovering?: boolean - countdownStartedAt?: number - abortDetectedAt?: number -} - -const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TASK_CONTINUATION)} - -Incomplete tasks remain in your task 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 COUNTDOWN_GRACE_PERIOD_MS = 500 - -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 getIncompleteCount(tasks: TaskObject[]): number { - return tasks.filter(t => t.status !== "completed" && t.status !== "deleted").length -} - -interface MessageInfo { - id?: string - role?: string - error?: { name?: string; data?: unknown } -} - -function isLastAssistantMessageAborted(messages: Array<{ info?: MessageInfo }>): boolean { - if (!messages || messages.length === 0) return false - - const assistantMessages = messages.filter(m => m.info?.role === "assistant") - if (assistantMessages.length === 0) return false - - const lastAssistant = assistantMessages[assistantMessages.length - 1] - const errorName = lastAssistant.info?.error?.name - - if (!errorName) return false - - return errorName === "MessageAbortedError" || errorName === "AbortError" -} - -function loadTasksFromDisk(config: Partial): TaskObject[] { - const taskIds = listTaskFiles(config) - const taskDirectory = getTaskDir(config) - const tasks: TaskObject[] = [] - - for (const id of taskIds) { - const task = readJsonSafe(join(taskDirectory, `${id}.json`), TaskObjectSchema) - if (task) tasks.push(task) - } - - return tasks -} - -export function createTaskContinuationEnforcer( - ctx: PluginInput, - config: Partial, - options: TaskContinuationEnforcerOptions = {} -): TaskContinuationEnforcer { - const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped } = options - const sessions = new Map() - - function getState(sessionID: string): SessionState { - let state = sessions.get(sessionID) - if (!state) { - state = {} - sessions.set(sessionID, state) - } - return state - } - - function cancelCountdown(sessionID: string): void { - const state = sessions.get(sessionID) - if (!state) return - - if (state.countdownTimer) { - clearTimeout(state.countdownTimer) - state.countdownTimer = undefined - } - if (state.countdownInterval) { - clearInterval(state.countdownInterval) - state.countdownInterval = undefined - } - state.countdownStartedAt = undefined - } - - function cleanup(sessionID: string): void { - cancelCountdown(sessionID) - sessions.delete(sessionID) - } - - const markRecovering = (sessionID: string): void => { - const state = getState(sessionID) - state.isRecovering = true - cancelCountdown(sessionID) - log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID }) - } - - const markRecoveryComplete = (sessionID: string): void => { - const state = sessions.get(sessionID) - if (state) { - state.isRecovering = false - log(`[${HOOK_NAME}] Session recovery complete`, { sessionID }) - } - } - - async function showCountdownToast(seconds: number, incompleteCount: number): Promise { - await ctx.client.tui - .showToast({ - body: { - title: "Task Continuation", - message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`, - variant: "warning" as const, - duration: TOAST_DURATION_MS, - }, - }) - .catch(() => {}) - } - - interface ResolvedMessageInfo { - agent?: string - model?: { providerID: string; modelID: string } - tools?: Record - } - - async function injectContinuation( - sessionID: string, - incompleteCount: number, - total: number, - resolvedInfo?: ResolvedMessageInfo - ): Promise { - const state = sessions.get(sessionID) - - if (state?.isRecovering) { - log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID }) - return - } - - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID }) - return - } - - const tasks = loadTasksFromDisk(config) - const freshIncompleteCount = getIncompleteCount(tasks) - if (freshIncompleteCount === 0) { - log(`[${HOOK_NAME}] Skipped injection: no incomplete tasks`, { sessionID }) - return - } - - let agentName = resolvedInfo?.agent - let model = resolvedInfo?.model - let tools = resolvedInfo?.tools - - if (!agentName || !model) { - const messageDir = getMessageDir(sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agentName = agentName ?? prevMessage?.agent - model = - model ?? - (prevMessage?.model?.providerID && prevMessage?.model?.modelID - ? { - providerID: prevMessage.model.providerID, - modelID: prevMessage.model.modelID, - ...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}), - } - : undefined) - tools = tools ?? prevMessage?.tools - } - - if (agentName && skipAgents.includes(agentName)) { - log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) - return - } - - const editPermission = tools?.edit - const writePermission = tools?.write - const hasWritePermission = - !tools || - (editPermission !== false && editPermission !== "deny" && writePermission !== false && writePermission !== "deny") - if (!hasWritePermission) { - log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName }) - return - } - - const incompleteTasks = tasks.filter(t => t.status !== "completed" && t.status !== "deleted") - const taskList = incompleteTasks.map(t => `- [${t.status}] ${t.subject}`).join("\n") - const prompt = `${CONTINUATION_PROMPT} - -[Status: ${tasks.length - freshIncompleteCount}/${tasks.length} completed, ${freshIncompleteCount} remaining] - -Remaining tasks: -${taskList}` - - try { - log(`[${HOOK_NAME}] Injecting continuation`, { - sessionID, - agent: agentName, - model, - incompleteCount: freshIncompleteCount, - }) - - await ctx.client.session.prompt({ - path: { id: sessionID }, - body: { - agent: agentName, - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: prompt }], - }, - query: { directory: ctx.directory }, - }) - - log(`[${HOOK_NAME}] Injection successful`, { sessionID }) - } catch (err) { - log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) }) - } - } - - function startCountdown( - sessionID: string, - incompleteCount: number, - total: number, - resolvedInfo?: ResolvedMessageInfo - ): void { - const state = getState(sessionID) - cancelCountdown(sessionID) - - let secondsRemaining = COUNTDOWN_SECONDS - showCountdownToast(secondsRemaining, incompleteCount) - state.countdownStartedAt = Date.now() - - state.countdownInterval = setInterval(() => { - secondsRemaining-- - if (secondsRemaining > 0) { - showCountdownToast(secondsRemaining, incompleteCount) - } - }, 1000) - - state.countdownTimer = setTimeout(() => { - cancelCountdown(sessionID) - injectContinuation(sessionID, incompleteCount, total, resolvedInfo) - }, COUNTDOWN_SECONDS * 1000) - - log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount }) - } - - const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { - const props = event.properties as Record | undefined - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const error = props?.error as { name?: string } | undefined - if (error?.name === "MessageAbortedError" || error?.name === "AbortError") { - const state = getState(sessionID) - state.abortDetectedAt = Date.now() - log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name }) - } - - cancelCountdown(sessionID) - log(`[${HOOK_NAME}] session.error`, { sessionID }) - return - } - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - log(`[${HOOK_NAME}] session.idle`, { sessionID }) - - const mainSessionID = getMainSessionID() - const isMainSession = sessionID === mainSessionID - const isBackgroundTaskSession = subagentSessions.has(sessionID) - - if (mainSessionID && !isMainSession && !isBackgroundTaskSession) { - log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID }) - return - } - - const state = getState(sessionID) - - if (state.isRecovering) { - log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) - return - } - - // Check 1: Event-based abort detection (primary, most reliable) - if (state.abortDetectedAt) { - const timeSinceAbort = Date.now() - state.abortDetectedAt - const ABORT_WINDOW_MS = 3000 - if (timeSinceAbort < ABORT_WINDOW_MS) { - log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID }) - state.abortDetectedAt = undefined - return - } - state.abortDetectedAt = undefined - } - - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) - return - } - - // Check 2: API-based abort detection (fallback, for cases where event was missed) - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }) - const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? [] - - if (isLastAssistantMessageAborted(messages)) { - log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) - return - } - } catch (err) { - log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(err) }) - } - - const tasks = loadTasksFromDisk(config) - - if (!tasks || tasks.length === 0) { - log(`[${HOOK_NAME}] No tasks`, { sessionID }) - return - } - - const incompleteCount = getIncompleteCount(tasks) - if (incompleteCount === 0) { - log(`[${HOOK_NAME}] All tasks complete`, { sessionID, total: tasks.length }) - return - } - - let resolvedInfo: ResolvedMessageInfo | undefined - let hasCompactionMessage = false - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { - agent?: string - model?: { providerID: string; modelID: string } - modelID?: string - providerID?: string - tools?: Record - } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent === "compaction") { - hasCompactionMessage = true - continue - } - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - resolvedInfo = { - agent: info.agent, - model: - info.model ?? - (info.providerID && info.modelID - ? { providerID: info.providerID, modelID: info.modelID } - : undefined), - tools: info.tools, - } - break - } - } - } catch (err) { - log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) }) - } - - log(`[${HOOK_NAME}] Agent check`, { - sessionID, - agentName: resolvedInfo?.agent, - skipAgents, - hasCompactionMessage, - }) - if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) { - log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent }) - return - } - if (hasCompactionMessage && !resolvedInfo?.agent) { - log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID }) - return - } - - if (isContinuationStopped?.(sessionID)) { - log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) - return - } - - startCountdown(sessionID, incompleteCount, tasks.length, resolvedInfo) - return - } - - if (event.type === "message.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined - - if (!sessionID) return - - if (role === "user") { - const state = sessions.get(sessionID) - if (state?.countdownStartedAt) { - const elapsed = Date.now() - state.countdownStartedAt - if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) { - log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed }) - return - } - } - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - - if (role === "assistant") { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "message.part.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined - - if (sessionID && role === "assistant") { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { - const sessionID = props?.sessionID as string | undefined - if (sessionID) { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - cleanup(sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) - } - return - } - } - - const cancelAllCountdowns = (): void => { - for (const sessionID of sessions.keys()) { - cancelCountdown(sessionID) - } - log(`[${HOOK_NAME}] All countdowns cancelled`) - } - - return { - handler, - markRecovering, - markRecoveryComplete, - cancelAllCountdowns, - } -} diff --git a/src/index.ts b/src/index.ts index f00f0c217..20048cfd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,6 @@ import type { Plugin, ToolDefinition } from "@opencode-ai/plugin"; import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder"; import { createTodoContinuationEnforcer, - createTaskContinuationEnforcer, createContextWindowMonitorHook, createSessionRecoveryHook, createSessionNotification, @@ -541,21 +540,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false; - const taskContinuationEnforcer = isHookEnabled("task-continuation-enforcer") && taskSystemEnabled - ? createTaskContinuationEnforcer(ctx, pluginConfig, { - backgroundManager, - isContinuationStopped: stopContinuationGuard?.isStopped, - }) - : null; - - if (sessionRecovery && (todoContinuationEnforcer || taskContinuationEnforcer)) { + if (sessionRecovery && todoContinuationEnforcer) { sessionRecovery.setOnAbortCallback((sessionID) => { todoContinuationEnforcer?.markRecovering(sessionID); - taskContinuationEnforcer?.markRecovering(sessionID); }); sessionRecovery.setOnRecoveryCompleteCallback((sessionID) => { todoContinuationEnforcer?.markRecoveryComplete(sessionID); - taskContinuationEnforcer?.markRecoveryComplete(sessionID); }); } @@ -742,7 +732,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await backgroundNotificationHook?.event(input); await sessionNotification?.(input); await todoContinuationEnforcer?.handler(input); - await taskContinuationEnforcer?.handler(input); await unstableAgentBabysitter?.event(input); await contextWindowMonitor?.event(input); await directoryAgentsInjector?.event(input); @@ -922,7 +911,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { if (command === "stop-continuation" && sessionID) { stopContinuationGuard?.stop(sessionID); todoContinuationEnforcer?.cancelAllCountdowns(); - taskContinuationEnforcer?.cancelAllCountdowns(); ralphLoop?.cancelLoop(sessionID); clearBoulderState(ctx.directory); log("[stop-continuation] All continuation mechanisms stopped", { diff --git a/src/shared/system-directive.ts b/src/shared/system-directive.ts index 0b8ba4f9b..f2ae8c602 100644 --- a/src/shared/system-directive.ts +++ b/src/shared/system-directive.ts @@ -48,7 +48,6 @@ export function removeSystemReminders(text: string): string { export const SystemDirectiveTypes = { TODO_CONTINUATION: "TODO CONTINUATION", - TASK_CONTINUATION: "TASK CONTINUATION", RALPH_LOOP: "RALPH LOOP", BOULDER_CONTINUATION: "BOULDER CONTINUATION", DELEGATION_REQUIRED: "DELEGATION REQUIRED", diff --git a/src/tools/delegate-task/executor.ts b/src/tools/delegate-task/executor.ts index 721f15a7c..2171b5bb5 100644 --- a/src/tools/delegate-task/executor.ts +++ b/src/tools/delegate-task/executor.ts @@ -20,6 +20,22 @@ import { storeToolMetadata } from "../../features/tool-metadata-store" const SISYPHUS_JUNIOR_AGENT = "sisyphus-junior" +function resolveToolCallID(ctx: ToolContextWithMetadata): string | undefined { + if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") { + return ctx.callID + } + + if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") { + return ctx.callId + } + + if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") { + return ctx.call_id + } + + return undefined +} + export interface ExecutorContext { manager: BackgroundManager client: OpencodeClient @@ -126,9 +142,8 @@ export async function executeBackgroundContinuation( }, } await ctx.metadata?.(bgContMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, bgContMeta) - } + const bgContCallID = resolveToolCallID(ctx) + if (bgContCallID) storeToolMetadata(ctx.sessionID, bgContCallID, bgContMeta) return `Background task continued. @@ -184,9 +199,8 @@ export async function executeSyncContinuation( }, } await ctx.metadata?.(syncContMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, syncContMeta) - } + const syncContCallID = resolveToolCallID(ctx) + if (syncContCallID) storeToolMetadata(ctx.sessionID, syncContCallID, syncContMeta) try { let resumeAgent: string | undefined @@ -339,9 +353,8 @@ export async function executeUnstableAgentTask( }, } await ctx.metadata?.(bgTaskMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, bgTaskMeta) - } + const bgTaskCallID = resolveToolCallID(ctx) + if (bgTaskCallID) storeToolMetadata(ctx.sessionID, bgTaskCallID, bgTaskMeta) const startTime = new Date() const timingCfg = getTimingConfig() @@ -486,9 +499,8 @@ export async function executeBackgroundTask( }, } await ctx.metadata?.(unstableMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, unstableMeta) - } + const unstableCallID = resolveToolCallID(ctx) + if (unstableCallID) storeToolMetadata(ctx.sessionID, unstableCallID, unstableMeta) return `Background task launched. @@ -596,9 +608,8 @@ export async function executeSyncTask( }, } await ctx.metadata?.(syncTaskMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, syncTaskMeta) - } + const syncTaskCallID = resolveToolCallID(ctx) + if (syncTaskCallID) storeToolMetadata(ctx.sessionID, syncTaskCallID, syncTaskMeta) try { const allowTask = isPlanFamily(agentToUse) diff --git a/src/tools/delegate-task/types.ts b/src/tools/delegate-task/types.ts index 1646b1fe9..4327bdced 100644 --- a/src/tools/delegate-task/types.ts +++ b/src/tools/delegate-task/types.ts @@ -34,6 +34,10 @@ export interface ToolContextWithMetadata { * but present at runtime via spread in fromPlugin()). Used for metadata store keying. */ callID?: string + /** @deprecated OpenCode internal naming may vary across versions */ + callId?: string + /** @deprecated OpenCode internal naming may vary across versions */ + call_id?: string } export interface SyncSessionCreatedEvent {