diff --git a/notepad.md b/notepad.md index 4cacfda..de32730 100644 --- a/notepad.md +++ b/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: `` (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 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분 + +--- + diff --git a/src/hooks/claude-code-hooks/stop.ts b/src/hooks/claude-code-hooks/stop.ts new file mode 100644 index 0000000..f5371f6 --- /dev/null +++ b/src/hooks/claude-code-hooks/stop.ts @@ -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() + +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 { + 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 } +} diff --git a/src/hooks/claude-code-hooks/user-prompt-submit.ts b/src/hooks/claude-code-hooks/user-prompt-submit.ts new file mode 100644 index 0000000..b358ef3 --- /dev/null +++ b/src/hooks/claude-code-hooks/user-prompt-submit.ts @@ -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 = "" +const USER_PROMPT_SUBMIT_TAG_CLOSE = "" + +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 { + 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 } +}