From ccaf759b6baa65da366c129497ff95eaf53a9f51 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 19 Mar 2026 11:37:30 +0900 Subject: [PATCH] fix(hooks): remove gpt permission continuation hook Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- AGENTS.md | 10 +- README.md | 4 +- docs/reference/configuration.md | 5 +- docs/reference/features.md | 6 +- src/AGENTS.md | 4 +- src/config/AGENTS.md | 2 +- src/config/schema/hooks.ts | 1 - src/hooks/AGENTS.md | 6 +- src/hooks/atlas/idle-event.ts | 6 - src/hooks/atlas/index.test.ts | 31 -- src/hooks/atlas/types.ts | 1 - .../assistant-message.ts | 44 -- .../gpt-permission-continuation/constants.ts | 11 - .../gpt-permission-continuation/detector.ts | 32 -- .../gpt-permission-continuation.test.ts | 384 ------------------ .../gpt-permission-continuation/handler.ts | 200 --------- .../gpt-permission-continuation/index.ts | 29 -- .../prompt-builder.ts | 14 - .../session-state.ts | 39 -- .../todo-coordination.test.ts | 64 --- src/hooks/index.ts | 1 - .../todo-continuation-enforcer/handler.ts | 3 - .../todo-continuation-enforcer/idle-event.ts | 7 - src/hooks/todo-continuation-enforcer/index.ts | 2 - .../todo-continuation-enforcer.test.ts | 21 - src/hooks/todo-continuation-enforcer/types.ts | 1 - src/plugin/AGENTS.md | 2 +- src/plugin/event.ts | 1 - src/plugin/hooks/create-continuation-hooks.ts | 14 - src/shared/migration.test.ts | 27 ++ src/shared/migration/hook-names.ts | 1 + 31 files changed, 45 insertions(+), 928 deletions(-) delete mode 100644 src/hooks/gpt-permission-continuation/assistant-message.ts delete mode 100644 src/hooks/gpt-permission-continuation/constants.ts delete mode 100644 src/hooks/gpt-permission-continuation/detector.ts delete mode 100644 src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts delete mode 100644 src/hooks/gpt-permission-continuation/handler.ts delete mode 100644 src/hooks/gpt-permission-continuation/index.ts delete mode 100644 src/hooks/gpt-permission-continuation/prompt-builder.ts delete mode 100644 src/hooks/gpt-permission-continuation/session-state.ts delete mode 100644 src/hooks/gpt-permission-continuation/todo-coordination.test.ts diff --git a/AGENTS.md b/AGENTS.md index f0f44cdab..09d3a19ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## OVERVIEW -OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 46 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1268 TypeScript files, 160k LOC. +OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 45 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1268 TypeScript files, 160k LOC. ## STRUCTURE @@ -14,14 +14,14 @@ oh-my-opencode/ │ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface │ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4) │ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior) -│ ├── hooks/ # 46 hooks across 45 directories + 11 standalone files +│ ├── hooks/ # 45 hooks across 44 directories + 11 standalone files │ ├── tools/ # 26 tools across 15 directories │ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.) │ ├── shared/ # 95+ utility files in 13 categories │ ├── config/ # Zod v4 schema system (24 files) │ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js) │ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app) -│ ├── plugin/ # 8 OpenCode hook handlers + 46 hook composition +│ ├── plugin/ # 8 OpenCode hook handlers + 45 hook composition │ └── plugin-handlers/ # 6-phase config loading pipeline ├── packages/ # Monorepo: cli-runner, 12 platform binaries └── local-ignore/ # Dev-only test fixtures @@ -34,7 +34,7 @@ OhMyOpenCodePlugin(ctx) ├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate ├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler ├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools) - ├─→ createHooks() # 3-tier: Core(37) + Continuation(7) + Skill(2) = 46 hooks + ├─→ createHooks() # 3-tier: Core(37) + Continuation(6) + Skill(2) = 45 hooks └─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface ``` @@ -97,7 +97,7 @@ Fields: agents (14 overridable, 21 fields each), categories (8 built-in + custom - **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style (nested describe with `#given`/`#when`/`#then` prefixes) - **CI test split**: mock-heavy tests run in isolation (separate `bun test` processes), rest in batch - **Factory pattern**: `createXXX()` for all tools, hooks, agents -- **Hook tiers**: Session (23) → Tool-Guard (10) → Transform (4) → Continuation (7) → Skill (2) +- **Hook tiers**: Session (23) → Tool-Guard (10) → Transform (4) → Continuation (6) → Skill (2) - **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all` - **Model resolution**: 4-step: override → category-default → provider-fallback → system-default - **Config format**: JSONC with comments, Zod v4 validation, snake_case keys diff --git a/README.md b/README.md index b4062f709..81d3af043 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ See full [Features Documentation](docs/reference/features.md). - **Claude Code Compatibility**: Full hook system, commands, skills, agents, MCPs - **Built-in MCPs**: websearch (Exa), context7 (docs), grep_app (GitHub search) - **Session Tools**: List, read, search, and analyze session history -- **Productivity Features**: Ralph Loop, Todo Enforcer, GPT permission-tail continuation, Comment Checker, Think Mode, and more +- **Productivity Features**: Ralph Loop, Todo Enforcer, Comment Checker, Think Mode, and more - **Model Setup**: Agent-model matching is built into the [Installation Guide](docs/guide/installation.md#step-5-understand-your-model-setup) ## Configuration @@ -321,7 +321,7 @@ See [Configuration Documentation](docs/reference/configuration.md). - **Sisyphus Agent**: Main orchestrator with Prometheus (Planner) and Metis (Plan Consultant) - **Background Tasks**: Configure concurrency limits per provider/model - **Categories**: Domain-specific task delegation (`visual`, `business-logic`, custom) -- **Hooks**: 25+ built-in hooks, including `gpt-permission-continuation`, all configurable via `disabled_hooks` +- **Hooks**: 25+ built-in hooks, all configurable via `disabled_hooks` - **MCPs**: Built-in websearch (Exa), context7 (docs), grep_app (GitHub search) - **LSP**: Full LSP support with refactoring tools - **Experimental**: Aggressive truncation, auto-resume, and more diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index d13010ee8..bfa80ed5a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -418,15 +418,14 @@ Disable built-in skills: `{ "disabled_skills": ["playwright"] }` Disable built-in hooks via `disabled_hooks`: ```json -{ "disabled_hooks": ["comment-checker", "gpt-permission-continuation"] } +{ "disabled_hooks": ["comment-checker"] } ``` -Available hooks: `gpt-permission-continuation`, `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `no-sisyphus-gpt`, `start-work`, `runtime-fallback` +Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `no-sisyphus-gpt`, `start-work`, `runtime-fallback` **Notes:** - `directory-agents-injector` — auto-disabled on OpenCode 1.1.37+ (native AGENTS.md support) -- `gpt-permission-continuation` — resumes GPT sessions only when the last assistant reply ends with a permission-seeking tail like `If you want, ...`. Disable it if you prefer GPT sessions to wait for explicit user follow-up. - `no-sisyphus-gpt` — **do not disable**. It blocks incompatible GPT models for Sisyphus while allowing the dedicated GPT-5.4 prompt path. - `startup-toast` is a sub-feature of `auto-update-checker`. Disable just the toast by adding `startup-toast` to `disabled_hooks`. diff --git a/docs/reference/features.md b/docs/reference/features.md index c635375c7..09082dc3b 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -680,7 +680,6 @@ Hooks intercept and modify behavior at key points in the agent lifecycle across | **ralph-loop** | Event + Message | Manages self-referential loop continuation. | | **start-work** | Message | Handles /start-work command execution. | | **auto-slash-command** | Message | Automatically executes slash commands from prompts. | -| **gpt-permission-continuation** | Event | Auto-continues GPT sessions when the final assistant reply ends with a permission-seeking tail such as `If you want, ...`. | | **stop-continuation-guard** | Event + Message | Guards the stop-continuation mechanism. | | **category-skill-reminder** | Event + PostToolUse | Reminds agents about available category skills for delegation. | | **anthropic-effort** | Params | Adjusts Anthropic API effort level based on context. | @@ -735,7 +734,6 @@ Hooks intercept and modify behavior at key points in the agent lifecycle across | Hook | Event | Description | | ------------------------------ | ----- | ---------------------------------------------------------- | -| **gpt-permission-continuation** | Event | Continues GPT replies that end in a permission-seeking tail. | | **todo-continuation-enforcer** | Event | Enforces todo completion — yanks idle agents back to work. | | **compaction-todo-preserver** | Event | Preserves todo state during session compaction. | | **unstable-agent-babysitter** | Event | Handles unstable agent behavior with recovery strategies. | @@ -787,12 +785,10 @@ Disable specific hooks in config: ```json { - "disabled_hooks": ["comment-checker", "gpt-permission-continuation"] + "disabled_hooks": ["comment-checker"] } ``` -Use `gpt-permission-continuation` when you want GPT sessions to stop at permission-seeking endings instead of auto-resuming. - ## MCPs ### Built-in MCPs diff --git a/src/AGENTS.md b/src/AGENTS.md index 584c36630..ec451bb07 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -14,7 +14,7 @@ Entry point `index.ts` orchestrates 5-step initialization: loadConfig → create | `plugin-config.ts` | JSONC parse, multi-level merge, Zod v4 validation | | `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler | | `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry (26 tools) | -| `create-hooks.ts` | 3-tier: Core(37) + Continuation(7) + Skill(2) = 46 hooks | +| `create-hooks.ts` | 3-tier: Core(37) + Continuation(6) + Skill(2) = 45 hooks | | `plugin-interface.ts` | 8 OpenCode hook handlers: config, tool, chat.message, chat.params, chat.headers, event, tool.execute.before, tool.execute.after | ## CONFIG LOADING @@ -36,6 +36,6 @@ createHooks() │ ├─ createSessionHooks() # 23: contextWindowMonitor, thinkMode, ralphLoop, modelFallback, runtimeFallback, noSisyphusGpt, noHephaestusNonGpt, anthropicEffort, intentGate... │ ├─ createToolGuardHooks() # 10: commentChecker, rulesInjector, writeExistingFileGuard, jsonErrorRecovery, hashlineReadEnhancer... │ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator - ├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard, ralphLoopActivator... + ├─→ createContinuationHooks() # 6: todoContinuationEnforcer, atlas, stopContinuationGuard, ralphLoopActivator... └─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand ``` diff --git a/src/config/AGENTS.md b/src/config/AGENTS.md index 93c4afaf8..57f0a83df 100644 --- a/src/config/AGENTS.md +++ b/src/config/AGENTS.md @@ -14,7 +14,7 @@ config/schema/ ├── agent-names.ts # BuiltinAgentNameSchema (11), OverridableAgentNameSchema (14) ├── agent-overrides.ts # AgentOverrideConfigSchema (21 fields per agent) ├── categories.ts # 8 built-in + custom categories -├── hooks.ts # HookNameSchema (46 hooks) +├── hooks.ts # HookNameSchema (45 hooks) ├── skills.ts # SkillsConfigSchema (sources, paths, recursive) ├── commands.ts # BuiltinCommandNameSchema ├── experimental.ts # Feature flags (plugin_load_timeout_ms min 1000) diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index f2e84853b..00e04404e 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -1,7 +1,6 @@ import { z } from "zod" export const HookNameSchema = z.enum([ - "gpt-permission-continuation", "todo-continuation-enforcer", "context-window-monitor", "session-recovery", diff --git a/src/hooks/AGENTS.md b/src/hooks/AGENTS.md index 4a25ccb4b..433344d89 100644 --- a/src/hooks/AGENTS.md +++ b/src/hooks/AGENTS.md @@ -1,10 +1,10 @@ -# src/hooks/ — 46 Lifecycle Hooks +# src/hooks/ — 45 Lifecycle Hooks **Generated:** 2026-03-06 ## OVERVIEW -46 hooks across 45 directories + 11 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern. +45 hooks across 44 directories + 11 standalone files. Three-tier composition: Core(37) + Continuation(6) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern. ## HOOK TIERS @@ -109,7 +109,7 @@ hooks/ | contextInjectorMessagesTransform | messages.transform | Inject AGENTS.md/README.md into context | | thinkingBlockValidator | messages.transform | Validate thinking block structure | -### Tier 4: Continuation Hooks (7) — `create-continuation-hooks.ts` +### Tier 4: Continuation Hooks (6) — `create-continuation-hooks.ts` | Hook | Event | Purpose | |------|-------|---------| diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index ec519769e..26714e328 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -88,7 +88,6 @@ 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({ @@ -177,11 +176,6 @@ 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 95f6fcc0d..c3a16a90b 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -1464,37 +1464,6 @@ session_id: ses_untrusted_999 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 bd5402e9c..79a4c51dc 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -8,7 +8,6 @@ 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 deleted file mode 100644 index 6e1c2335e..000000000 --- a/src/hooks/gpt-permission-continuation/assistant-message.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 04eda9a72..000000000 --- a/src/hooks/gpt-permission-continuation/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const HOOK_NAME = "gpt-permission-continuation" -export const CONTINUATION_PROMPT = "continue" -export const MAX_CONSECUTIVE_AUTO_CONTINUES = 3 - -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 deleted file mode 100644 index a91fb1a94..000000000 --- a/src/hooks/gpt-permission-continuation/detector.ts +++ /dev/null @@ -1,32 +0,0 @@ -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())) -} - -export function extractPermissionPhrase(text: string): string | null { - const tail = text.slice(-800) - const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean) - const hotZone = lines.slice(-3).join(" ") - const sentenceParts = hotZone.trim().replace(/\s+/g, " ").split(/(?<=[.!?])\s+/) - const trailingSegment = sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? "" - return trailingSegment || null -} diff --git a/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts b/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts deleted file mode 100644 index 126d80e62..000000000 --- a/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -/// - -import { createOpencodeClient } from "@opencode-ai/sdk" -import { afterEach, describe, expect, it as test } from "bun:test" - -import { subagentSessions, _resetForTesting } from "../../features/claude-code-session-state" -import { createGptPermissionContinuationHook } from "." - -type SessionMessage = { - info: { - id: string - role: "user" | "assistant" - model?: { - providerID?: string - modelID?: string - } - modelID?: string - } - parts?: Array<{ type: string; text?: string }> -} - -type GptPermissionContext = Parameters[0] - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} - -function extractPromptText(input: unknown): string { - if (!isRecord(input)) return "" - - const body = input.body - if (!isRecord(body)) return "" - - const parts = body.parts - if (!Array.isArray(parts)) return "" - - const firstPart = parts[0] - if (!isRecord(firstPart)) return "" - - return typeof firstPart.text === "string" ? firstPart.text : "" -} - -function createMockPluginInput(messages: SessionMessage[]): { - ctx: GptPermissionContext - promptCalls: string[] -} { - const promptCalls: string[] = [] - const client = createOpencodeClient({ directory: "/tmp/test" }) - const shell = Object.assign( - () => { - throw new Error("$ is not used in this test") - }, - { - braces: () => [], - escape: (input: string) => input, - env() { - return shell - }, - cwd() { - return shell - }, - nothrow() { - return shell - }, - throws() { - return shell - }, - }, - ) - const request = new Request("http://localhost") - const response = new Response() - - Reflect.set(client.session, "messages", async () => ({ data: messages, error: undefined, request, response })) - Reflect.set(client.session, "prompt", async (input: unknown) => { - promptCalls.push(extractPromptText(input)) - return { data: undefined, error: undefined, request, response } - }) - Reflect.set(client.session, "promptAsync", async (input: unknown) => { - promptCalls.push(extractPromptText(input)) - return { data: undefined, error: undefined, request, response } - }) - - const ctx: GptPermissionContext = { - client, - project: { - id: "test-project", - worktree: "/tmp/test", - time: { created: Date.now() }, - }, - directory: "/tmp/test", - worktree: "/tmp/test", - serverUrl: new URL("http://localhost"), - $: shell, - } - - return { ctx, promptCalls } -} - -function createAssistantMessage(id: string, text: string): SessionMessage { - return { - info: { id, role: "assistant", modelID: "gpt-5.4" }, - parts: [{ type: "text", text }], - } -} - -function createUserMessage(id: string, text: string): SessionMessage { - return { - info: { id, role: "user" }, - parts: [{ type: "text", text }], - } -} - -function expectContinuationPrompts(promptCalls: string[], count: number): void { - expect(promptCalls).toHaveLength(count) - for (const call of promptCalls) { - expect(call.startsWith("continue")).toBe(true) - } -} - -describe("gpt-permission-continuation", () => { - afterEach(() => { - _resetForTesting() - }) - - 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 - expectContinuationPrompts(promptCalls, 1) - }) - - 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 - expectContinuationPrompts(promptCalls, 1) - }) - - describe("#given repeated GPT permission tails in the same session", () => { - describe("#when the permission phrases keep changing", () => { - test("stops injecting after three consecutive auto-continues", async () => { - // given - const messages: SessionMessage[] = [ - createUserMessage("msg-0", "Please continue the fix."), - createAssistantMessage("msg-1", "If you want, I can apply the patch next."), - ] - const { ctx, promptCalls } = createMockPluginInput(messages) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-2", "continue")) - messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-4", "continue")) - messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-6", "continue")) - messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expectContinuationPrompts(promptCalls, 3) - }) - }) - - describe("#when a real user message arrives between auto-continues", () => { - test("resets the consecutive auto-continue counter", async () => { - // given - const messages: SessionMessage[] = [ - createUserMessage("msg-0", "Please continue the fix."), - createAssistantMessage("msg-1", "If you want, I can apply the patch next."), - ] - const { ctx, promptCalls } = createMockPluginInput(messages) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-2", "continue")) - messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-4", "Please keep going and finish the cleanup.")) - messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-6", "continue")) - messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-8", "continue")) - messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish.")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-10", "continue")) - messages.push(createAssistantMessage("msg-11", "Would you like me to ship the final verification?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expectContinuationPrompts(promptCalls, 5) - }) - }) - - describe("#when the same permission phrase repeats after an auto-continue", () => { - test("stops immediately on stagnation", async () => { - // given - const messages: SessionMessage[] = [ - createUserMessage("msg-0", "Please continue the fix."), - createAssistantMessage("msg-1", "If you want, I can apply the patch next."), - ] - const { ctx, promptCalls } = createMockPluginInput(messages) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-2", "continue")) - messages.push(createAssistantMessage("msg-3", "If you want, I can apply the patch next.")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expectContinuationPrompts(promptCalls, 1) - }) - }) - - describe("#when a user manually types continue after the cap is reached", () => { - test("resets the cap and allows another auto-continue", async () => { - // given - const messages: SessionMessage[] = [ - createUserMessage("msg-0", "Please continue the fix."), - createAssistantMessage("msg-1", "If you want, I can apply the patch next."), - ] - const { ctx, promptCalls } = createMockPluginInput(messages) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-2", "continue")) - messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-4", "continue")) - messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-6", "continue")) - messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-8", "continue")) - messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish.")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expectContinuationPrompts(promptCalls, 4) - }) - }) - }) - - test("does not inject when the session is a subagent 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." }], - }, - ]) - subagentSessions.add("ses-subagent") - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-subagent" } } }) - - // then - expect(promptCalls).toEqual([]) - }) - - test("includes assistant text context in the continuation prompt", async () => { - // given - const assistantText = "I finished the analysis. If you want, I can apply the changes next." - const { ctx, promptCalls } = createMockPluginInput([ - { - info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, - parts: [{ type: "text", text: assistantText }], - }, - ]) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expect(promptCalls).toHaveLength(1) - expect(promptCalls[0].startsWith("continue")).toBe(true) - expect(promptCalls[0]).toContain("If you want, I can apply the changes next.") - }) -}) diff --git a/src/hooks/gpt-permission-continuation/handler.ts b/src/hooks/gpt-permission-continuation/handler.ts deleted file mode 100644 index 15fc85343..000000000 --- a/src/hooks/gpt-permission-continuation/handler.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" - -import { subagentSessions } from "../../features/claude-code-session-state" -import { normalizeSDKResponse } from "../../shared" -import { log } from "../../shared/logger" - -import { - extractAssistantText, - getLastAssistantMessage, - isGptAssistantMessage, - type SessionMessage, -} from "./assistant-message" -import { - CONTINUATION_PROMPT, - HOOK_NAME, - MAX_CONSECUTIVE_AUTO_CONTINUES, -} from "./constants" -import { detectStallPattern, extractPermissionPhrase } from "./detector" -import { buildContextualContinuationPrompt } from "./prompt-builder" -import type { SessionStateStore } from "./session-state" - -type SessionState = ReturnType - -async function promptContinuation( - ctx: PluginInput, - sessionID: string, - assistantText: string, -): Promise { - const prompt = buildContextualContinuationPrompt(assistantText) - const payload = { - path: { id: sessionID }, - body: { - parts: [{ type: "text" as const, text: 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) -} - -function getLastUserMessageBefore( - messages: SessionMessage[], - lastAssistantIndex: number, -): SessionMessage | null { - for (let index = lastAssistantIndex - 1; index >= 0; index--) { - if (messages[index].info?.role === "user") { - return messages[index] - } - } - - return null -} - -function isAutoContinuationUserMessage(message: SessionMessage): boolean { - const text = extractAssistantText(message).trim().toLowerCase() - return text === CONTINUATION_PROMPT || text.startsWith(`${CONTINUATION_PROMPT}\n`) -} - -function resetAutoContinuationState(state: SessionState): void { - state.consecutiveAutoContinueCount = 0 - state.awaitingAutoContinuationResponse = false - state.lastAutoContinuePermissionPhrase = undefined -} - -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 (subagentSessions.has(sessionID)) { - log(`[${HOOK_NAME}] Skipped: session is a subagent`, { 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 lastAssistantIndex = messages.lastIndexOf(lastAssistantMessage) - const previousUserMessage = getLastUserMessageBefore(messages, lastAssistantIndex) - const previousUserMessageWasAutoContinuation = - previousUserMessage !== null - && state.awaitingAutoContinuationResponse - && isAutoContinuationUserMessage(previousUserMessage) - - if (previousUserMessageWasAutoContinuation) { - state.awaitingAutoContinuationResponse = false - } else if (previousUserMessage) { - resetAutoContinuationState(state) - } else { - state.awaitingAutoContinuationResponse = false - } - - 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 - } - - const permissionPhrase = extractPermissionPhrase(assistantText) - if (!permissionPhrase) { - return - } - - if (state.consecutiveAutoContinueCount >= MAX_CONSECUTIVE_AUTO_CONTINUES) { - state.lastHandledMessageID = messageID - log(`[${HOOK_NAME}] Skipped: reached max consecutive auto-continues`, { - sessionID, - messageID, - consecutiveAutoContinueCount: state.consecutiveAutoContinueCount, - }) - return - } - - if ( - state.consecutiveAutoContinueCount >= 1 - && state.lastAutoContinuePermissionPhrase === permissionPhrase - ) { - state.lastHandledMessageID = messageID - log(`[${HOOK_NAME}] Skipped: repeated permission phrase after auto-continue`, { - sessionID, - messageID, - permissionPhrase, - }) - return - } - - state.inFlight = true - await promptContinuation(ctx, sessionID, assistantText) - state.lastHandledMessageID = messageID - state.consecutiveAutoContinueCount += 1 - state.awaitingAutoContinuationResponse = true - state.lastAutoContinuePermissionPhrase = permissionPhrase - 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 deleted file mode 100644 index a87295635..000000000 --- a/src/hooks/gpt-permission-continuation/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/prompt-builder.ts b/src/hooks/gpt-permission-continuation/prompt-builder.ts deleted file mode 100644 index 905a48ae9..000000000 --- a/src/hooks/gpt-permission-continuation/prompt-builder.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { CONTINUATION_PROMPT } from "./constants" - -const CONTEXT_LINE_COUNT = 5 - -export function buildContextualContinuationPrompt(assistantText: string): string { - const lines = assistantText.split("\n").map((line) => line.trim()).filter(Boolean) - const contextLines = lines.slice(-CONTEXT_LINE_COUNT) - - if (contextLines.length === 0) { - return CONTINUATION_PROMPT - } - - return `${CONTINUATION_PROMPT}\n\n[Your last response ended with:]\n${contextLines.join("\n")}` -} diff --git a/src/hooks/gpt-permission-continuation/session-state.ts b/src/hooks/gpt-permission-continuation/session-state.ts deleted file mode 100644 index 9414692e4..000000000 --- a/src/hooks/gpt-permission-continuation/session-state.ts +++ /dev/null @@ -1,39 +0,0 @@ -type SessionState = { - inFlight: boolean - consecutiveAutoContinueCount: number - awaitingAutoContinuationResponse: boolean - lastHandledMessageID?: string - lastAutoContinuePermissionPhrase?: 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, - consecutiveAutoContinueCount: 0, - awaitingAutoContinuationResponse: 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 deleted file mode 100644 index 8e0e54bcf..000000000 --- a/src/hooks/gpt-permission-continuation/todo-coordination.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -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).toHaveLength(1) - expect(promptCalls[0].startsWith("continue")).toBe(true) - expect(promptCalls[0]).toContain("If you want, I can implement the fix next.") - expect(toastCalls).toEqual([]) - }) -}) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 73fbb652d..abbf79bb7 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -30,7 +30,6 @@ 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 a3eb71bf7..716c1a2e8 100644 --- a/src/hooks/todo-continuation-enforcer/handler.ts +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -17,7 +17,6 @@ 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, @@ -25,7 +24,6 @@ export function createTodoContinuationHandler(args: { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped, - shouldSkipContinuation, } = args return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { @@ -58,7 +56,6 @@ 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 b8824e302..a4d7ea83a 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -30,7 +30,6 @@ export async function handleSessionIdle(args: { backgroundManager?: BackgroundManager skipAgents?: string[] isContinuationStopped?: (sessionID: string) => boolean - shouldSkipContinuation?: (sessionID: string) => boolean }): Promise { const { ctx, @@ -39,7 +38,6 @@ export async function handleSessionIdle(args: { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped, - shouldSkipContinuation, } = args log(`[${HOOK_NAME}] session.idle`, { sessionID }) @@ -174,11 +172,6 @@ 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 edeba85b5..5fcda2495 100644 --- a/src/hooks/todo-continuation-enforcer/index.ts +++ b/src/hooks/todo-continuation-enforcer/index.ts @@ -17,7 +17,6 @@ export function createTodoContinuationEnforcer( backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped, - shouldSkipContinuation, } = options const sessionStateStore = createSessionStateStore() @@ -43,7 +42,6 @@ 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 e22f7c629..508cef6a4 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -1706,27 +1706,6 @@ 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 0f40cec28..dbc79d4d7 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -5,7 +5,6 @@ export interface TodoContinuationEnforcerOptions { backgroundManager?: BackgroundManager skipAgents?: string[] isContinuationStopped?: (sessionID: string) => boolean - shouldSkipContinuation?: (sessionID: string) => boolean } export interface TodoContinuationEnforcer { diff --git a/src/plugin/AGENTS.md b/src/plugin/AGENTS.md index d2a67a2ae..8ce81d499 100644 --- a/src/plugin/AGENTS.md +++ b/src/plugin/AGENTS.md @@ -4,7 +4,7 @@ ## OVERVIEW -Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 46 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type. +Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 45 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type. ## HANDLER FILES diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 45765c025..d55783477 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -195,7 +195,6 @@ 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 5dee1724c..c44247af9 100644 --- a/src/plugin/hooks/create-continuation-hooks.ts +++ b/src/plugin/hooks/create-continuation-hooks.ts @@ -3,7 +3,6 @@ import type { BackgroundManager } from "../../features/background-agent" import type { PluginContext } from "../types" import { - createGptPermissionContinuationHook, createTodoContinuationEnforcer, createBackgroundNotificationHook, createStopContinuationGuardHook, @@ -15,7 +14,6 @@ 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 @@ -57,13 +55,6 @@ 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 })) @@ -78,8 +69,6 @@ export function createContinuationHooks(args: { createTodoContinuationEnforcer(ctx, { backgroundManager, isContinuationStopped: stopContinuationGuard?.isStopped, - shouldSkipContinuation: (sessionID: string) => - gptPermissionContinuation?.wasRecentlyInjected(sessionID) ?? false, })) : null @@ -122,15 +111,12 @@ 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, diff --git a/src/shared/migration.test.ts b/src/shared/migration.test.ts index 1d88861b7..e02fa4356 100644 --- a/src/shared/migration.test.ts +++ b/src/shared/migration.test.ts @@ -289,6 +289,19 @@ describe("migrateHookNames", () => { expect(removed).toHaveLength(1) }) + test("removes gpt-permission-continuation from disabled hooks", () => { + // given: Config with removed GPT permission continuation hook + const hooks = ["gpt-permission-continuation", "comment-checker"] + + // when: Migrate hook names + const { migrated, changed, removed } = migrateHookNames(hooks) + + // then: Removed hook should be filtered out + expect(changed).toBe(true) + expect(migrated).toEqual(["comment-checker"]) + expect(removed).toEqual(["gpt-permission-continuation"]) + }) + test("handles mixed migration and removal", () => { // given: Config with both legacy rename and removed hooks const hooks = ["anthropic-auto-compact", "preemptive-compaction", "sisyphus-orchestrator"] @@ -413,6 +426,20 @@ describe("migrateConfigFile", () => { expect(rawConfig.disabled_hooks).toEqual(["comment-checker"]) }) + test("removes gpt-permission-continuation from disabled_hooks", () => { + // given: Config with removed GPT permission continuation hook + const rawConfig: Record = { + disabled_hooks: ["gpt-permission-continuation", "comment-checker"], + } + + // when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // then: Removed hook should be filtered out + expect(needsWrite).toBe(true) + expect(rawConfig.disabled_hooks).toEqual(["comment-checker"]) + }) + test("does not write if no migration needed", () => { // given: Config with current names const rawConfig: Record = { diff --git a/src/shared/migration/hook-names.ts b/src/shared/migration/hook-names.ts index 342206049..09dde113a 100644 --- a/src/shared/migration/hook-names.ts +++ b/src/shared/migration/hook-names.ts @@ -10,6 +10,7 @@ export const HOOK_NAME_MAP: Record = { // Removed hooks (v3.0.0) - will be filtered out and user warned "empty-message-sanitizer": null, "delegate-task-english-directive": null, + "gpt-permission-continuation": null, } export function migrateHookNames(