feat(hooks): add UserPromptSubmit and Stop executors
- Port user-prompt-submit.ts from opencode-cc-plugin (118 lines) - Port stop.ts from opencode-cc-plugin (119 lines) - Preserve recursion prevention logic (<user-prompt-submit-hook> tags) - Preserve inject_prompt support (message injection, stop prompt injection) - Preserve stopHookActiveState management (per-session state) - Import path adjustments: ../types → ./types, ../../config → ./plugin-config - All exit code handling preserved (exitCode 2 → block, etc.) 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
118
src/hooks/claude-code-hooks/stop.ts
Normal file
118
src/hooks/claude-code-hooks/stop.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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) {
|
||||
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"
|
||||
// Determine inject_prompt: prefer explicit value, fallback to reason if blocking
|
||||
const injectPrompt = output.inject_prompt ?? (isBlock && output.reason ? output.reason : undefined)
|
||||
return {
|
||||
block: isBlock,
|
||||
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 }
|
||||
}
|
||||
117
src/hooks/claude-code-hooks/user-prompt-submit.ts
Normal file
117
src/hooks/claude-code-hooks/user-prompt-submit.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
UserPromptSubmitInput,
|
||||
PostToolUseOutput,
|
||||
ClaudeHooksConfig,
|
||||
} from "./types"
|
||||
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
|
||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
|
||||
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
|
||||
const USER_PROMPT_SUBMIT_TAG_CLOSE = "</user-prompt-submit-hook>"
|
||||
|
||||
export interface MessagePart {
|
||||
type: "text" | "tool_use" | "tool_result"
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UserPromptSubmitContext {
|
||||
sessionId: string
|
||||
parentSessionId?: string
|
||||
prompt: string
|
||||
parts: MessagePart[]
|
||||
cwd: string
|
||||
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
|
||||
}
|
||||
|
||||
export interface UserPromptSubmitResult {
|
||||
block: boolean
|
||||
reason?: string
|
||||
modifiedParts: MessagePart[]
|
||||
messages: string[]
|
||||
}
|
||||
|
||||
export async function executeUserPromptSubmitHooks(
|
||||
ctx: UserPromptSubmitContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
extendedConfig?: PluginExtendedConfig | null
|
||||
): Promise<UserPromptSubmitResult> {
|
||||
const modifiedParts = ctx.parts
|
||||
const messages: string[] = []
|
||||
|
||||
if (ctx.parentSessionId) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
if (
|
||||
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_OPEN) &&
|
||||
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_CLOSE)
|
||||
) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
const matchers = findMatchingHooks(config, "UserPromptSubmit")
|
||||
if (matchers.length === 0) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
const stdinData: UserPromptSubmitInput = {
|
||||
session_id: ctx.sessionId,
|
||||
cwd: ctx.cwd,
|
||||
permission_mode: ctx.permissionMode ?? "bypassPermissions",
|
||||
hook_event_name: "UserPromptSubmit",
|
||||
prompt: ctx.prompt,
|
||||
session: { id: ctx.sessionId },
|
||||
hook_source: "opencode-plugin",
|
||||
}
|
||||
|
||||
for (const matcher of matchers) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type !== "command") continue
|
||||
|
||||
if (isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) {
|
||||
log("UserPromptSubmit 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 }
|
||||
)
|
||||
|
||||
if (result.stdout) {
|
||||
const output = result.stdout.trim()
|
||||
if (output.startsWith(USER_PROMPT_SUBMIT_TAG_OPEN)) {
|
||||
messages.push(output)
|
||||
} else {
|
||||
messages.push(`${USER_PROMPT_SUBMIT_TAG_OPEN}\n${output}\n${USER_PROMPT_SUBMIT_TAG_CLOSE}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
|
||||
if (output.decision === "block") {
|
||||
return {
|
||||
block: true,
|
||||
reason: output.reason || result.stderr,
|
||||
modifiedParts,
|
||||
messages,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
Reference in New Issue
Block a user