Files
oh-my-opencode/src/hooks/claude-code-hooks/stop.ts
T
raki-1203 5c8d694491 fix: execute all Stop hooks instead of returning after first non-blocking result
Previously, executeStopHooks returned immediately after the first hook
that produced valid JSON stdout, even if it was non-blocking. This
prevented subsequent hooks from executing.

This was problematic when users had multiple Stop hooks (e.g.,
check-console-log.js + task-complete-notify.sh in settings.json),
because the first hook's stdout (which echoed stdin data as JSON)
caused an early return, silently skipping all remaining hooks.

Now only explicitly blocking results (exit code 2 or decision=block)
cause an early return, matching Claude Code's behavior of executing
all Stop hooks sequentially.

Closes #1707
2026-02-12 16:09:13 +09:00

123 lines
3.7 KiB
TypeScript

import type {
StopInput,
StopOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { getTodoPath } from "./todo"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
// Module-level state to track stop_hook_active per session
const stopHookActiveState = new Map<string, boolean>()
export function setStopHookActive(sessionId: string, active: boolean): void {
stopHookActiveState.set(sessionId, active)
}
export function getStopHookActive(sessionId: string): boolean {
return stopHookActiveState.get(sessionId) ?? false
}
export interface StopContext {
sessionId: string
parentSessionId?: string
cwd: string
transcriptPath?: string
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
stopHookActive?: boolean
}
export interface StopResult {
block: boolean
reason?: string
stopHookActive?: boolean
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
injectPrompt?: string
}
export async function executeStopHooks(
ctx: StopContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<StopResult> {
if (ctx.parentSessionId) {
return { block: false }
}
if (!config) {
return { block: false }
}
const matchers = findMatchingHooks(config, "Stop")
if (matchers.length === 0) {
return { block: false }
}
const stdinData: StopInput = {
session_id: ctx.sessionId,
transcript_path: ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "Stop",
stop_hook_active: stopHookActiveState.get(ctx.sessionId) ?? false,
todo_path: getTodoPath(ctx.sessionId),
hook_source: "opencode-plugin",
}
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) {
log("Stop hook command skipped (disabled by config)", { command: hook.command })
continue
}
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
// Check exit code first - exit code 2 means block
if (result.exitCode === 2) {
const reason = result.stderr || result.stdout || "Blocked by stop hook"
return {
block: true,
reason,
injectPrompt: reason,
}
}
if (result.stdout) {
try {
const output = JSON.parse(result.stdout || "{}") as StopOutput
if (output.stop_hook_active !== undefined) {
stopHookActiveState.set(ctx.sessionId, output.stop_hook_active)
}
const isBlock = output.decision === "block"
// Only return early if the hook explicitly blocks - non-blocking hooks
// should not prevent subsequent hooks from executing (matches Claude Code behavior)
if (isBlock) {
const injectPrompt = output.inject_prompt ?? (output.reason || undefined)
return {
block: true,
reason: output.reason,
stopHookActive: output.stop_hook_active,
permissionMode: output.permission_mode,
injectPrompt,
}
}
} catch {
// Ignore JSON parse errors - hook may return non-JSON output
}
}
}
}
return { block: false }
}