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:
97
notepad.md
97
notepad.md
@@ -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분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|||||||
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