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:
YeonGyu-Kim
2025-12-09 18:00:16 +09:00
parent 3fcfedcec0
commit dca98121ac
3 changed files with 332 additions and 0 deletions

View File

@@ -642,3 +642,100 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
---
## [2025-12-09 17:58] - Task 6: user-prompt-submit.ts + stop.ts 포팅
### DISCOVERED ISSUES
- None - straightforward file copy with import path adjustments
### IMPLEMENTATION DECISIONS
- Copied user-prompt-submit.ts (118 lines) from opencode-cc-plugin → oh-my-opencode
- Copied stop.ts (119 lines) from opencode-cc-plugin → oh-my-opencode
- Import path adjustments (both files):
* `../types``./types`
* `../../shared``../../shared` (unchanged)
* `../../config``./plugin-config`
* `../../config-loader``./config-loader`
* `../todo``./todo` (stop.ts only)
- Preserved recursion prevention logic in user-prompt-submit.ts:
* Tags: `<user-prompt-submit-hook>` (open/close)
* Check if prompt already contains tags → return early
* Wrap hook stdout with tags to prevent infinite recursion
- Preserved inject_prompt support:
* user-prompt-submit: messages array collection for injection
* stop: injectPrompt field in result (from output.inject_prompt or output.reason)
- Preserved stopHookActiveState management in stop.ts:
* Module-level Map<string, boolean> for per-session state
* setStopHookActive(), getStopHookActive() exported
* State persists across hook invocations
- Preserved exit code handling:
* stop.ts: exitCode === 2 → block with reason
* user-prompt-submit.ts: exitCode !== 0 → check JSON for decision: "block"
### PROBLEMS FOR NEXT TASKS
- Task 7 (hook-message-injector) will use the message injection pattern
- Task 8 (Factory + Integration) will wire these hooks to OpenCode lifecycle events
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, successful
- Files created:
* `src/hooks/claude-code-hooks/user-prompt-submit.ts` (115 lines)
* `src/hooks/claude-code-hooks/stop.ts` (119 lines)
- Functions available:
* executeUserPromptSubmitHooks() with UserPromptSubmitContext → UserPromptSubmitResult
* executeStopHooks() with StopContext → StopResult
* setStopHookActive(), getStopHookActive()
- Recursion prevention verified: lines 47-52 check for tag presence
- inject_prompt field verified: stop.ts line 102 sets injectPrompt from output
### LEARNINGS
- user-prompt-submit uses tag wrapping pattern to prevent infinite hook loops
- stop hook can inject prompts into session via injectPrompt result field
- stopHookActiveState Map persists across hook invocations (module-level state)
- getTodoPath() from ./todo provides todo file path for Stop hook context
- Source files:
* `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/user-prompt-submit.ts` (118 lines)
* `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/stop.ts` (119 lines)
소요 시간: ~3분
---
## [2025-12-09 17:58] - Task 7: hook-message-injector 포팅
### DISCOVERED ISSUES
- None - straightforward file copy task
### IMPLEMENTATION DECISIONS
- Created `src/features/hook-message-injector/` directory
- Copied 4 files from opencode-cc-plugin → oh-my-opencode:
* constants.ts (9 lines): XDG-based path definitions (MESSAGE_STORAGE, PART_STORAGE)
* types.ts (46 lines): MessageMeta, OriginalMessageContext, TextPart interfaces
* injector.ts (142 lines): injectHookMessage() implementation with message/part storage
* index.ts (3 lines): Barrel export
- No import path changes needed - module is self-contained
- Preserved XDG_DATA_HOME environment variable support
- Preserved message fallback logic: finds nearest message with agent/model/tools if not provided
### PROBLEMS FOR NEXT TASKS
- Task 8 (Factory + Integration) will import injectHookMessage from this module
- Hook executors (user-prompt-submit, stop) can use injectHookMessage to store hook messages
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Files created: src/features/hook-message-injector/ (4 files)
- Functions exported: injectHookMessage()
- Types exported: MessageMeta, OriginalMessageContext, TextPart
- Constants exported: MESSAGE_STORAGE, PART_STORAGE (XDG-based paths)
### LEARNINGS
- Message injector uses XDG_DATA_HOME for storage (~/.local/share/opencode/storage/)
- Message storage structure: sessionID → messageID.json (meta) + partID.json (content)
- Fallback logic: searches recent messages for agent/model/tools if originalMessage is incomplete
- Part-based storage allows incremental message building
- Source: `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/features/hook-message-injector/`
소요 시간: ~2분
---

View 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 }
}

View 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 }
}