diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index ee2b5e367..3c79ebf9c 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -43,7 +43,57 @@ "disabled_hooks": { "type": "array", "items": { - "type": "string" + "type": "string", + "enum": [ + "gpt-permission-continuation", + "todo-continuation-enforcer", + "context-window-monitor", + "session-recovery", + "session-notification", + "comment-checker", + "tool-output-truncator", + "question-label-truncator", + "directory-agents-injector", + "directory-readme-injector", + "empty-task-response-detector", + "think-mode", + "model-fallback", + "anthropic-context-window-limit-recovery", + "preemptive-compaction", + "rules-injector", + "background-notification", + "auto-update-checker", + "startup-toast", + "keyword-detector", + "agent-usage-reminder", + "non-interactive-env", + "interactive-bash-session", + "thinking-block-validator", + "ralph-loop", + "category-skill-reminder", + "compaction-context-injector", + "compaction-todo-preserver", + "claude-code-hooks", + "auto-slash-command", + "edit-error-recovery", + "json-error-recovery", + "delegate-task-retry", + "prometheus-md-only", + "sisyphus-junior-notepad", + "no-sisyphus-gpt", + "no-hephaestus-non-gpt", + "start-work", + "atlas", + "unstable-agent-babysitter", + "task-resume-info", + "stop-continuation-guard", + "tasks-todowrite-disabler", + "runtime-fallback", + "write-existing-file-guard", + "anthropic-effort", + "hashline-read-enhancer", + "read-image-resizer" + ] } }, "disabled_commands": { @@ -3742,11 +3792,16 @@ "include_co_authored_by": { "default": true, "type": "boolean" + }, + "git_env_prefix": { + "default": "GIT_MASTER=1", + "type": "string" } }, "required": [ "commit_footer", - "include_co_authored_by" + "include_co_authored_by", + "git_env_prefix" ], "additionalProperties": false }, diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index 28ab58851..4acc37584 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -1,6 +1,7 @@ import { z } from "zod" export const HookNameSchema = z.enum([ + "gpt-permission-continuation", "todo-continuation-enforcer", "context-window-monitor", "session-recovery", diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index d24bbef4d..a645fd3a6 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -11,6 +11,7 @@ import { CommentCheckerConfigSchema } from "./comment-checker" import { BuiltinCommandNameSchema } from "./commands" import { ExperimentalConfigSchema } from "./experimental" import { GitMasterConfigSchema } from "./git-master" +import { HookNameSchema } from "./hooks" import { NotificationConfigSchema } from "./notification" import { RalphLoopConfigSchema } from "./ralph-loop" import { RuntimeFallbackConfigSchema } from "./runtime-fallback" @@ -30,7 +31,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ disabled_mcps: z.array(AnyMcpNameSchema).optional(), disabled_agents: z.array(z.string()).optional(), disabled_skills: z.array(BuiltinSkillNameSchema).optional(), - disabled_hooks: z.array(z.string()).optional(), + disabled_hooks: z.array(HookNameSchema).optional(), disabled_commands: z.array(BuiltinCommandNameSchema).optional(), /** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */ disabled_tools: z.array(z.string()).optional(), diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 2e9307239..50fd532b1 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -110,6 +110,7 @@ function scheduleRetry(input: { const currentProgress = getPlanProgress(currentBoulder.active_plan) if (currentProgress.isComplete) return if (options?.isContinuationStopped?.(sessionID)) return + if (options?.shouldSkipContinuation?.(sessionID)) return if (hasRunningBackgroundTasks(sessionID, options)) return await injectContinuation({ @@ -192,6 +193,11 @@ export async function handleAtlasSessionIdle(input: { return } + if (options?.shouldSkipContinuation?.(sessionID)) { + log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID }) + return + } + if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) { scheduleRetry({ ctx, sessionID, sessionState, options }) log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 4bc9d5dc1..22ca44c42 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -1042,6 +1042,37 @@ describe("atlas hook", () => { expect(mockInput._promptMock).not.toHaveBeenCalled() }) + test("should skip when another continuation hook already injected", async () => { + // given - boulder state with incomplete plan + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + } + writeBoulderState(TEST_DIR, state) + + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput, { + directory: TEST_DIR, + shouldSkipContinuation: (sessionID: string) => sessionID === MAIN_SESSION_ID, + }) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + // then - should not call prompt because another continuation already handled it + expect(mockInput._promptMock).not.toHaveBeenCalled() + }) + test("should clear abort state on message.updated", async () => { // given - boulder with incomplete plan const planPath = join(TEST_DIR, "test-plan.md") diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts index b96021ce9..54e45051d 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -7,6 +7,7 @@ export interface AtlasHookOptions { directory: string backgroundManager?: BackgroundManager isContinuationStopped?: (sessionID: string) => boolean + shouldSkipContinuation?: (sessionID: string) => boolean agentOverrides?: AgentOverrides /** Enable auto-commit after each atomic task completion (default: true) */ autoCommit?: boolean diff --git a/src/hooks/gpt-permission-continuation/assistant-message.ts b/src/hooks/gpt-permission-continuation/assistant-message.ts new file mode 100644 index 000000000..6e1c2335e --- /dev/null +++ b/src/hooks/gpt-permission-continuation/assistant-message.ts @@ -0,0 +1,44 @@ +type TextPart = { + type?: string + text?: string +} + +type MessageInfo = { + id?: string + role?: string + error?: unknown + model?: { + providerID?: string + modelID?: string + } + providerID?: string + modelID?: string +} + +export type SessionMessage = { + info?: MessageInfo + parts?: TextPart[] +} + +export function getLastAssistantMessage(messages: SessionMessage[]): SessionMessage | null { + for (let index = messages.length - 1; index >= 0; index--) { + if (messages[index].info?.role === "assistant") { + return messages[index] + } + } + + return null +} + +export function extractAssistantText(message: SessionMessage): string { + return (message.parts ?? []) + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text?.trim() ?? "") + .filter(Boolean) + .join("\n") +} + +export function isGptAssistantMessage(message: SessionMessage): boolean { + const modelID = message.info?.model?.modelID ?? message.info?.modelID + return typeof modelID === "string" && modelID.toLowerCase().includes("gpt") +} diff --git a/src/hooks/gpt-permission-continuation/constants.ts b/src/hooks/gpt-permission-continuation/constants.ts new file mode 100644 index 000000000..690a0f0bd --- /dev/null +++ b/src/hooks/gpt-permission-continuation/constants.ts @@ -0,0 +1,10 @@ +export const HOOK_NAME = "gpt-permission-continuation" +export const CONTINUATION_PROMPT = "continue" + +export const DEFAULT_STALL_PATTERNS = [ + "if you want", + "would you like", + "shall i", + "do you want me to", + "let me know if", +] as const diff --git a/src/hooks/gpt-permission-continuation/detector.ts b/src/hooks/gpt-permission-continuation/detector.ts new file mode 100644 index 000000000..a28894ec2 --- /dev/null +++ b/src/hooks/gpt-permission-continuation/detector.ts @@ -0,0 +1,23 @@ +import { DEFAULT_STALL_PATTERNS } from "./constants" + +function getTrailingSegment(text: string): string { + const normalized = text.trim().replace(/\s+/g, " ") + if (!normalized) return "" + + const sentenceParts = normalized.split(/(?<=[.!?])\s+/) + return sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? "" +} + +export function detectStallPattern( + text: string, + patterns: readonly string[] = DEFAULT_STALL_PATTERNS, +): boolean { + if (!text.trim()) return false + + const tail = text.slice(-800) + const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean) + const hotZone = lines.slice(-3).join(" ") + const trailingSegment = getTrailingSegment(hotZone) + + return patterns.some((pattern) => trailingSegment.startsWith(pattern.toLowerCase())) +} diff --git a/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts b/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts new file mode 100644 index 000000000..dfb8b4591 --- /dev/null +++ b/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test" + +import { createGptPermissionContinuationHook } from "." + +type SessionMessage = { + info: { + id: string + role: "user" | "assistant" + model?: { + providerID?: string + modelID?: string + } + modelID?: string + } + parts?: Array<{ type: string; text?: string }> +} + +function createMockPluginInput(messages: SessionMessage[]) { + const promptCalls: string[] = [] + + const ctx = { + directory: "/tmp/test", + client: { + session: { + messages: async () => ({ data: messages }), + prompt: async (input: { body: { parts: Array<{ text: string }> } }) => { + promptCalls.push(input.body.parts[0]?.text ?? "") + return {} + }, + promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => { + promptCalls.push(input.body.parts[0]?.text ?? "") + return {} + }, + }, + }, + } as any + + return { ctx, promptCalls } +} + +describe("gpt-permission-continuation", () => { + test("injects continue when the last GPT assistant reply asks for permission", async () => { + // given + const { ctx, promptCalls } = createMockPluginInput([ + { + info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, + parts: [{ type: "text", text: "I finished the analysis. If you want, I can apply the changes next." }], + }, + ]) + const hook = createGptPermissionContinuationHook(ctx) + + // when + await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) + + // then + expect(promptCalls).toEqual(["continue"]) + }) + + test("does not inject when the last assistant model is not GPT", async () => { + // given + const { ctx, promptCalls } = createMockPluginInput([ + { + info: { + id: "msg-1", + role: "assistant", + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + }, + parts: [{ type: "text", text: "If you want, I can keep going." }], + }, + ]) + const hook = createGptPermissionContinuationHook(ctx) + + // when + await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) + + // then + expect(promptCalls).toEqual([]) + }) + + test("does not inject when the last assistant reply is not a stall pattern", async () => { + // given + const { ctx, promptCalls } = createMockPluginInput([ + { + info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, + parts: [{ type: "text", text: "I completed the refactor and all tests pass." }], + }, + ]) + const hook = createGptPermissionContinuationHook(ctx) + + // when + await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) + + // then + expect(promptCalls).toEqual([]) + }) + + test("does not inject when a permission phrase appears before the final sentence", async () => { + // given + const { ctx, promptCalls } = createMockPluginInput([ + { + info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, + parts: [{ type: "text", text: "If you want, I can keep going. The current work is complete." }], + }, + ]) + const hook = createGptPermissionContinuationHook(ctx) + + // when + await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) + + // then + expect(promptCalls).toEqual([]) + }) + + test("does not inject when continuation is stopped for the session", async () => { + // given + const { ctx, promptCalls } = createMockPluginInput([ + { + info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, + parts: [{ type: "text", text: "If you want, I can continue with the fix." }], + }, + ]) + const hook = createGptPermissionContinuationHook(ctx, { + isContinuationStopped: (sessionID) => sessionID === "ses-1", + }) + + // when + await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) + + // then + expect(promptCalls).toEqual([]) + }) + + test("does not inject twice for the same assistant message", async () => { + // given + const { ctx, promptCalls } = createMockPluginInput([ + { + info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, + parts: [{ type: "text", text: "Would you like me to continue with the fix?" }], + }, + ]) + const hook = createGptPermissionContinuationHook(ctx) + + // when + await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) + await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) + + // then + expect(promptCalls).toEqual(["continue"]) + }) +}) diff --git a/src/hooks/gpt-permission-continuation/handler.ts b/src/hooks/gpt-permission-continuation/handler.ts new file mode 100644 index 000000000..0db3142dc --- /dev/null +++ b/src/hooks/gpt-permission-continuation/handler.ts @@ -0,0 +1,116 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { normalizeSDKResponse } from "../../shared" +import { log } from "../../shared/logger" + +import { + extractAssistantText, + getLastAssistantMessage, + isGptAssistantMessage, + type SessionMessage, +} from "./assistant-message" +import { CONTINUATION_PROMPT, HOOK_NAME } from "./constants" +import { detectStallPattern } from "./detector" +import type { SessionStateStore } from "./session-state" + +async function promptContinuation( + ctx: PluginInput, + sessionID: string, +): Promise { + const payload = { + path: { id: sessionID }, + body: { + parts: [{ type: "text" as const, text: CONTINUATION_PROMPT }], + }, + query: { directory: ctx.directory }, + } + + if (typeof ctx.client.session.promptAsync === "function") { + await ctx.client.session.promptAsync(payload) + return + } + + await ctx.client.session.prompt(payload) +} + +export function createGptPermissionContinuationHandler(args: { + ctx: PluginInput + sessionStateStore: SessionStateStore + isContinuationStopped?: (sessionID: string) => boolean +}): (input: { event: { type: string; properties?: unknown } }) => Promise { + const { ctx, sessionStateStore, isContinuationStopped } = args + + return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { + const properties = event.properties as Record | undefined + + if (event.type === "session.deleted") { + const sessionID = (properties?.info as { id?: string } | undefined)?.id + if (sessionID) { + sessionStateStore.cleanup(sessionID) + } + return + } + + if (event.type !== "session.idle") return + + const sessionID = properties?.sessionID as string | undefined + if (!sessionID) return + + if (isContinuationStopped?.(sessionID)) { + log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) + return + } + + const state = sessionStateStore.getState(sessionID) + if (state.inFlight) { + log(`[${HOOK_NAME}] Skipped: prompt already in flight`, { sessionID }) + return + } + + try { + const messagesResponse = await ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }) + const messages = normalizeSDKResponse(messagesResponse, [] as SessionMessage[], { + preferResponseOnMissingData: true, + }) + const lastAssistantMessage = getLastAssistantMessage(messages) + if (!lastAssistantMessage) return + + const messageID = lastAssistantMessage.info?.id + if (messageID && state.lastHandledMessageID === messageID) { + log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID }) + return + } + + if (lastAssistantMessage.info?.error) { + log(`[${HOOK_NAME}] Skipped: last assistant message has error`, { sessionID, messageID }) + return + } + + if (!isGptAssistantMessage(lastAssistantMessage)) { + log(`[${HOOK_NAME}] Skipped: last assistant model is not GPT`, { sessionID, messageID }) + return + } + + const assistantText = extractAssistantText(lastAssistantMessage) + if (!detectStallPattern(assistantText)) { + return + } + + state.inFlight = true + await promptContinuation(ctx, sessionID) + state.lastHandledMessageID = messageID + state.lastInjectedAt = Date.now() + log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID }) + } catch (error) { + log(`[${HOOK_NAME}] Failed to inject continuation prompt`, { + sessionID, + error: String(error), + }) + } finally { + state.inFlight = false + } + } +} diff --git a/src/hooks/gpt-permission-continuation/index.ts b/src/hooks/gpt-permission-continuation/index.ts new file mode 100644 index 000000000..a87295635 --- /dev/null +++ b/src/hooks/gpt-permission-continuation/index.ts @@ -0,0 +1,29 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { createGptPermissionContinuationHandler } from "./handler" +import { createSessionStateStore } from "./session-state" + +export type GptPermissionContinuationHook = { + handler: (input: { event: { type: string; properties?: unknown } }) => Promise + wasRecentlyInjected: (sessionID: string) => boolean +} + +export function createGptPermissionContinuationHook( + ctx: PluginInput, + options?: { + isContinuationStopped?: (sessionID: string) => boolean + }, +): GptPermissionContinuationHook { + const sessionStateStore = createSessionStateStore() + + return { + handler: createGptPermissionContinuationHandler({ + ctx, + sessionStateStore, + isContinuationStopped: options?.isContinuationStopped, + }), + wasRecentlyInjected(sessionID: string): boolean { + return sessionStateStore.wasRecentlyInjected(sessionID, 5_000) + }, + } +} diff --git a/src/hooks/gpt-permission-continuation/session-state.ts b/src/hooks/gpt-permission-continuation/session-state.ts new file mode 100644 index 000000000..132675086 --- /dev/null +++ b/src/hooks/gpt-permission-continuation/session-state.ts @@ -0,0 +1,34 @@ +type SessionState = { + inFlight: boolean + lastHandledMessageID?: string + lastInjectedAt?: number +} + +export type SessionStateStore = ReturnType + +export function createSessionStateStore() { + const states = new Map() + + const getState = (sessionID: string): SessionState => { + const existing = states.get(sessionID) + if (existing) return existing + + const created: SessionState = { + inFlight: false, + } + states.set(sessionID, created) + return created + } + + return { + getState, + wasRecentlyInjected(sessionID: string, windowMs: number): boolean { + const state = states.get(sessionID) + if (!state?.lastInjectedAt) return false + return Date.now() - state.lastInjectedAt <= windowMs + }, + cleanup(sessionID: string): void { + states.delete(sessionID) + }, + } +} diff --git a/src/hooks/gpt-permission-continuation/todo-coordination.test.ts b/src/hooks/gpt-permission-continuation/todo-coordination.test.ts new file mode 100644 index 000000000..dc32db7af --- /dev/null +++ b/src/hooks/gpt-permission-continuation/todo-coordination.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" + +import { createTodoContinuationEnforcer } from "../todo-continuation-enforcer" +import { createGptPermissionContinuationHook } from "." + +describe("gpt-permission-continuation coordination", () => { + test("injects only once when GPT permission continuation and todo continuation are both eligible", async () => { + // given + const promptCalls: string[] = [] + const toastCalls: string[] = [] + const sessionID = "ses-dual-continuation" + const ctx = { + directory: "/tmp/test", + client: { + session: { + messages: async () => ({ + data: [ + { + info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, + parts: [{ type: "text", text: "If you want, I can implement the fix next." }], + }, + ], + }), + todo: async () => ({ + data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], + }), + prompt: async (input: { body: { parts: Array<{ text: string }> } }) => { + promptCalls.push(input.body.parts[0]?.text ?? "") + return {} + }, + promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => { + promptCalls.push(input.body.parts[0]?.text ?? "") + return {} + }, + }, + tui: { + showToast: async (input: { body: { title: string } }) => { + toastCalls.push(input.body.title) + return {} + }, + }, + }, + } as any + + const gptPermissionContinuation = createGptPermissionContinuationHook(ctx) + const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx, { + shouldSkipContinuation: (id) => gptPermissionContinuation.wasRecentlyInjected(id), + }) + + // when + await gptPermissionContinuation.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + await todoContinuationEnforcer.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + // then + expect(promptCalls).toEqual(["continue"]) + expect(toastCalls).toEqual([]) + }) +}) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 171f5dd12..77b2c3c13 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -30,6 +30,7 @@ export { createCategorySkillReminderHook } from "./category-skill-reminder"; export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop"; export { createNoSisyphusGptHook } from "./no-sisyphus-gpt"; export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt"; +export { createGptPermissionContinuationHook, type GptPermissionContinuationHook } from "./gpt-permission-continuation" export { createAutoSlashCommandHook } from "./auto-slash-command"; export { createEditErrorRecoveryHook } from "./edit-error-recovery"; diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts index e51b2358a..4e4ef69d0 100644 --- a/src/hooks/todo-continuation-enforcer/handler.ts +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -17,6 +17,7 @@ export function createTodoContinuationHandler(args: { backgroundManager?: BackgroundManager skipAgents?: string[] isContinuationStopped?: (sessionID: string) => boolean + shouldSkipContinuation?: (sessionID: string) => boolean }): (input: { event: { type: string; properties?: unknown } }) => Promise { const { ctx, @@ -24,6 +25,7 @@ export function createTodoContinuationHandler(args: { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped, + shouldSkipContinuation, } = args return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { @@ -56,6 +58,7 @@ export function createTodoContinuationHandler(args: { backgroundManager, skipAgents, isContinuationStopped, + shouldSkipContinuation, }) return } diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index b8419efb1..c55bf4abc 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -29,6 +29,7 @@ export async function handleSessionIdle(args: { backgroundManager?: BackgroundManager skipAgents?: string[] isContinuationStopped?: (sessionID: string) => boolean + shouldSkipContinuation?: (sessionID: string) => boolean }): Promise { const { ctx, @@ -37,6 +38,7 @@ export async function handleSessionIdle(args: { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped, + shouldSkipContinuation, } = args log(`[${HOOK_NAME}] session.idle`, { sessionID }) @@ -186,6 +188,11 @@ export async function handleSessionIdle(args: { return } + if (shouldSkipContinuation?.(sessionID)) { + log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID }) + return + } + const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount, todos) if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) { return diff --git a/src/hooks/todo-continuation-enforcer/index.ts b/src/hooks/todo-continuation-enforcer/index.ts index 85a2a6bc9..b7e376563 100644 --- a/src/hooks/todo-continuation-enforcer/index.ts +++ b/src/hooks/todo-continuation-enforcer/index.ts @@ -17,6 +17,7 @@ export function createTodoContinuationEnforcer( backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped, + shouldSkipContinuation, } = options const sessionStateStore = createSessionStateStore() @@ -42,6 +43,7 @@ export function createTodoContinuationEnforcer( backgroundManager, skipAgents, isContinuationStopped, + shouldSkipContinuation, }) const cancelAllCountdowns = (): void => { diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index 508cef6a4..e22f7c629 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -1706,6 +1706,27 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(0) }) + test("should not inject when shouldSkipContinuation returns true", async () => { + // given - session already handled by another continuation hook + const sessionID = "main-skip-other-continuation" + setMainSession(sessionID) + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), { + shouldSkipContinuation: (id) => id === sessionID, + }) + + // when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + await fakeTimers.advanceBy(3000) + + // then - no countdown toast or continuation injection + expect(toastCalls).toHaveLength(0) + expect(promptCalls).toHaveLength(0) + }) + test("should not inject when isContinuationStopped becomes true during countdown", async () => { // given - session where continuation is not stopped at idle time but stops during countdown const sessionID = "main-race-condition" diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index 2a0ffc00b..2221a14c0 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -5,6 +5,7 @@ export interface TodoContinuationEnforcerOptions { backgroundManager?: BackgroundManager skipAgents?: string[] isContinuationStopped?: (sessionID: string) => boolean + shouldSkipContinuation?: (sessionID: string) => boolean } export interface TodoContinuationEnforcer { diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 4d618e83a..ce62dffcd 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -170,6 +170,7 @@ export function createEventHandler(args: { await Promise.resolve(hooks.claudeCodeHooks?.event?.(input)); await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input)); await Promise.resolve(hooks.sessionNotification?.(input)); + await Promise.resolve(hooks.gptPermissionContinuation?.handler?.(input)); await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input)); await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input)); await Promise.resolve(hooks.contextWindowMonitor?.event?.(input)); diff --git a/src/plugin/hooks/create-continuation-hooks.ts b/src/plugin/hooks/create-continuation-hooks.ts index 092064cbf..5dee1724c 100644 --- a/src/plugin/hooks/create-continuation-hooks.ts +++ b/src/plugin/hooks/create-continuation-hooks.ts @@ -3,6 +3,7 @@ import type { BackgroundManager } from "../../features/background-agent" import type { PluginContext } from "../types" import { + createGptPermissionContinuationHook, createTodoContinuationEnforcer, createBackgroundNotificationHook, createStopContinuationGuardHook, @@ -14,6 +15,7 @@ import { safeCreateHook } from "../../shared/safe-create-hook" import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter" export type ContinuationHooks = { + gptPermissionContinuation: ReturnType | null stopContinuationGuard: ReturnType | null compactionContextInjector: ReturnType | null compactionTodoPreserver: ReturnType | null @@ -55,6 +57,13 @@ export function createContinuationHooks(args: { })) : null + const gptPermissionContinuation = isHookEnabled("gpt-permission-continuation") + ? safeHook("gpt-permission-continuation", () => + createGptPermissionContinuationHook(ctx, { + isContinuationStopped: stopContinuationGuard?.isStopped, + })) + : null + const compactionContextInjector = isHookEnabled("compaction-context-injector") ? safeHook("compaction-context-injector", () => createCompactionContextInjector({ ctx, backgroundManager })) @@ -66,9 +75,11 @@ export function createContinuationHooks(args: { const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") ? safeHook("todo-continuation-enforcer", () => - createTodoContinuationEnforcer(ctx, { + createTodoContinuationEnforcer(ctx, { backgroundManager, isContinuationStopped: stopContinuationGuard?.isStopped, + shouldSkipContinuation: (sessionID: string) => + gptPermissionContinuation?.wasRecentlyInjected(sessionID) ?? false, })) : null @@ -111,12 +122,15 @@ export function createContinuationHooks(args: { backgroundManager, isContinuationStopped: (sessionID: string) => stopContinuationGuard?.isStopped(sessionID) ?? false, + shouldSkipContinuation: (sessionID: string) => + gptPermissionContinuation?.wasRecentlyInjected(sessionID) ?? false, agentOverrides: pluginConfig.agents, autoCommit: pluginConfig.start_work?.auto_commit, })) : null return { + gptPermissionContinuation, stopContinuationGuard, compactionContextInjector, compactionTodoPreserver,