diff --git a/notepad.md b/notepad.md index da9395b..64b5cfc 100644 --- a/notepad.md +++ b/notepad.md @@ -466,3 +466,75 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025 --- +## [2025-12-09 17:39] - Task 3: tool-input-cache.ts 포팅 + +### DISCOVERED ISSUES +- None - straightforward file copy task + +### IMPLEMENTATION DECISIONS +- Copied tool-input-cache.ts (48 lines) from opencode-cc-plugin → oh-my-opencode +- Preserved cache structure: + * Key format: `${sessionId}:${toolName}:${invocationId}` + * TTL: 60000ms (1 minute) as CACHE_TTL constant + * Periodic cleanup: setInterval every CACHE_TTL (60000ms) +- Preserved original comments from source file (lines 12, 39) +- Functions: cacheToolInput(), getToolInput() +- Cache behavior: getToolInput() deletes entry immediately after retrieval (single-use cache) + +### PROBLEMS FOR NEXT TASKS +- Task 4 (pre-tool-use.ts) will call cacheToolInput() to store tool inputs +- Task 5 (post-tool-use.ts) will call getToolInput() to retrieve cached inputs for transcript building +- No import path changes needed - this file has no external dependencies + +### VERIFICATION RESULTS +- File created: `src/hooks/claude-code-hooks/tool-input-cache.ts` (48 lines) +- Functions exported: cacheToolInput(), getToolInput() +- TTL verified: CACHE_TTL = 60000 (1 minute) +- Cleanup interval verified: setInterval(cleanup, CACHE_TTL) + +### LEARNINGS +- Tool input cache is a temporary storage for PreToolUse → PostToolUse communication +- Single-use pattern: getToolInput() deletes entry after first retrieval (line 33) +- TTL check happens after deletion, so expired entries still return null +- setInterval runs in background for periodic cleanup of abandoned entries +- Source location: `~/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/tool-input-cache.ts` + +소요 시간: ~2분 + +--- + +## [2025-12-09 17:39] - Task 2: config.ts + transcript.ts + todo.ts 포팅 + +### DISCOVERED ISSUES +- transcript.ts had unused imports (ClaudeCodeMessage, ClaudeCodeContent) - same as source file +- LSP warned about unused types - removed from import to clean up + +### IMPLEMENTATION DECISIONS +- Copied config.ts (101 lines) - no import path changes needed (only uses `./types` and Node.js builtins) +- Copied transcript.ts (256 lines) - changed import path: + * Line 10: `../shared/tool-name` → `../../shared/tool-name` (opencode-cc-plugin depth 1, oh-my-opencode depth 2) +- Copied todo.ts (78 lines) - no import path changes needed (only uses `./types` and Node.js builtins) +- Removed unused imports from transcript.ts: ClaudeCodeMessage, ClaudeCodeContent (not used in function bodies) +- Preserved ALL original comments from source files - these are pre-existing comments + +### PROBLEMS FOR NEXT TASKS +- Task 3 will import cacheToolInput/getToolInput for cache functionality +- Task 4 will import loadClaudeHooksConfig, buildTranscriptFromSession +- Task 5 will import transcript building functions for PostToolUse hook + +### VERIFICATION RESULTS +- Ran: `bun run typecheck` → exit 0, no errors +- Files created: config.ts (101 lines), transcript.ts (256 lines), todo.ts (78 lines) +- Functions available: loadClaudeHooksConfig(), buildTranscriptFromSession(), appendTranscriptEntry(), loadTodoFile(), saveTodoFile() +- Import paths verified: transcript.ts successfully imports transformToolName from ../../shared + +### LEARNINGS +- Import path depth difference: opencode-cc-plugin `src/claude-compat/` (1 level up) → oh-my-opencode `src/hooks/claude-code-hooks/` (2 levels up) +- transcript.ts unused imports were present in original source - cleaning them is optional but improves code hygiene +- config.ts uses Bun.file() for async file reading - compatible with oh-my-opencode's Bun runtime +- Bun.file().text() automatically handles encoding + +소요 시간: ~3분 + +--- + diff --git a/src/hooks/claude-code-hooks/config.ts b/src/hooks/claude-code-hooks/config.ts new file mode 100644 index 0000000..c4f1eaf --- /dev/null +++ b/src/hooks/claude-code-hooks/config.ts @@ -0,0 +1,100 @@ +import { homedir } from "os" +import { join } from "path" +import { existsSync } from "fs" +import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types" + +interface RawHookMatcher { + matcher?: string + pattern?: string + hooks: HookCommand[] +} + +interface RawClaudeHooksConfig { + PreToolUse?: RawHookMatcher[] + PostToolUse?: RawHookMatcher[] + UserPromptSubmit?: RawHookMatcher[] + Stop?: RawHookMatcher[] +} + +function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher { + return { + matcher: raw.matcher ?? raw.pattern ?? "*", + hooks: raw.hooks, + } +} + +function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig { + const result: ClaudeHooksConfig = {} + const eventTypes: (keyof RawClaudeHooksConfig)[] = [ + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "Stop", + ] + + for (const eventType of eventTypes) { + if (raw[eventType]) { + result[eventType] = raw[eventType].map(normalizeHookMatcher) + } + } + + return result +} + +export function getClaudeSettingsPaths(customPath?: string): string[] { + const home = homedir() + const paths = [ + join(home, ".claude", "settings.json"), + join(process.cwd(), ".claude", "settings.json"), + join(process.cwd(), ".claude", "settings.local.json"), + ] + + if (customPath && existsSync(customPath)) { + paths.unshift(customPath) + } + + return paths +} + +function mergeHooksConfig( + base: ClaudeHooksConfig, + override: ClaudeHooksConfig +): ClaudeHooksConfig { + const result: ClaudeHooksConfig = { ...base } + const eventTypes: (keyof ClaudeHooksConfig)[] = [ + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "Stop", + ] + for (const eventType of eventTypes) { + if (override[eventType]) { + result[eventType] = [...(base[eventType] || []), ...override[eventType]] + } + } + return result +} + +export async function loadClaudeHooksConfig( + customSettingsPath?: string +): Promise { + const paths = getClaudeSettingsPaths(customSettingsPath) + let mergedConfig: ClaudeHooksConfig = {} + + for (const settingsPath of paths) { + if (existsSync(settingsPath)) { + try { + const content = await Bun.file(settingsPath).text() + const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig } + if (settings.hooks) { + const normalizedHooks = normalizeHooksConfig(settings.hooks) + mergedConfig = mergeHooksConfig(mergedConfig, normalizedHooks) + } + } catch { + continue + } + } + } + + return Object.keys(mergedConfig).length > 0 ? mergedConfig : null +} diff --git a/src/hooks/claude-code-hooks/todo.ts b/src/hooks/claude-code-hooks/todo.ts new file mode 100644 index 0000000..ed53c2a --- /dev/null +++ b/src/hooks/claude-code-hooks/todo.ts @@ -0,0 +1,76 @@ +import { join } from "path" +import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "fs" +import { homedir } from "os" +import type { TodoFile, TodoItem, ClaudeCodeTodoItem } from "./types" + +const TODO_DIR = join(homedir(), ".claude", "todos") + +export function getTodoPath(sessionId: string): string { + return join(TODO_DIR, `${sessionId}-agent-${sessionId}.json`) +} + +function ensureTodoDir(): void { + if (!existsSync(TODO_DIR)) { + mkdirSync(TODO_DIR, { recursive: true }) + } +} + +export interface OpenCodeTodo { + content: string + status: string + priority: string + id: string +} + +function toClaudeCodeFormat(item: OpenCodeTodo | TodoItem): ClaudeCodeTodoItem { + return { + content: item.content, + status: item.status === "cancelled" ? "completed" : item.status, + activeForm: item.content, + } +} + +export function loadTodoFile(sessionId: string): TodoFile | null { + const path = getTodoPath(sessionId) + if (!existsSync(path)) return null + try { + const content = JSON.parse(readFileSync(path, "utf-8")) + if (Array.isArray(content)) { + return { + session_id: sessionId, + items: content.map((item: ClaudeCodeTodoItem, idx: number) => ({ + id: String(idx), + content: item.content, + status: item.status as TodoItem["status"], + created_at: new Date().toISOString(), + })), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + } + return content + } catch { + return null + } +} + +export function saveTodoFile(sessionId: string, file: TodoFile): void { + ensureTodoDir() + const path = getTodoPath(sessionId) + const claudeCodeFormat: ClaudeCodeTodoItem[] = file.items.map(toClaudeCodeFormat) + writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2)) +} + +export function saveOpenCodeTodos(sessionId: string, todos: OpenCodeTodo[]): void { + ensureTodoDir() + const path = getTodoPath(sessionId) + const claudeCodeFormat: ClaudeCodeTodoItem[] = todos.map(toClaudeCodeFormat) + writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2)) +} + +export function deleteTodoFile(sessionId: string): void { + const path = getTodoPath(sessionId) + if (existsSync(path)) { + unlinkSync(path) + } +} diff --git a/src/hooks/claude-code-hooks/transcript.ts b/src/hooks/claude-code-hooks/transcript.ts new file mode 100644 index 0000000..776a828 --- /dev/null +++ b/src/hooks/claude-code-hooks/transcript.ts @@ -0,0 +1,255 @@ +/** + * Transcript Manager + * Creates and manages Claude Code compatible transcript files + */ +import { join } from "path" +import { mkdirSync, appendFileSync, existsSync, writeFileSync, unlinkSync } from "fs" +import { homedir, tmpdir } from "os" +import { randomUUID } from "crypto" +import type { TranscriptEntry } from "./types" +import { transformToolName } from "../../shared/tool-name" + +const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts") + +export function getTranscriptPath(sessionId: string): string { + return join(TRANSCRIPT_DIR, `${sessionId}.jsonl`) +} + +function ensureTranscriptDir(): void { + if (!existsSync(TRANSCRIPT_DIR)) { + mkdirSync(TRANSCRIPT_DIR, { recursive: true }) + } +} + +export function appendTranscriptEntry( + sessionId: string, + entry: TranscriptEntry +): void { + ensureTranscriptDir() + const path = getTranscriptPath(sessionId) + const line = JSON.stringify(entry) + "\n" + appendFileSync(path, line) +} + +export function recordToolUse( + sessionId: string, + toolName: string, + toolInput: Record +): void { + appendTranscriptEntry(sessionId, { + type: "tool_use", + timestamp: new Date().toISOString(), + tool_name: toolName, + tool_input: toolInput, + }) +} + +export function recordToolResult( + sessionId: string, + toolName: string, + toolInput: Record, + toolOutput: Record +): void { + appendTranscriptEntry(sessionId, { + type: "tool_result", + timestamp: new Date().toISOString(), + tool_name: toolName, + tool_input: toolInput, + tool_output: toolOutput, + }) +} + +export function recordUserMessage( + sessionId: string, + content: string +): void { + appendTranscriptEntry(sessionId, { + type: "user", + timestamp: new Date().toISOString(), + content, + }) +} + +export function recordAssistantMessage( + sessionId: string, + content: string +): void { + appendTranscriptEntry(sessionId, { + type: "assistant", + timestamp: new Date().toISOString(), + content, + }) +} + +// ============================================================================ +// Claude Code Compatible Transcript Builder (PORT FROM DISABLED) +// ============================================================================ + +/** + * OpenCode API response type (loosely typed) + */ +interface OpenCodeMessagePart { + type: string + tool?: string + state?: { + status?: string + input?: Record + } +} + +interface OpenCodeMessage { + info?: { + role?: string + } + parts?: OpenCodeMessagePart[] +} + +/** + * Claude Code compatible transcript entry (from disabled file) + */ +interface DisabledTranscriptEntry { + type: "assistant" + message: { + role: "assistant" + content: Array<{ + type: "tool_use" + name: string + input: Record + }> + } +} + +/** + * Build Claude Code compatible transcript from session messages + * + * PORT FROM DISABLED: This calls client.session.messages() API to fetch + * the full session history and builds a JSONL file in Claude Code format. + * + * @param client OpenCode client instance + * @param sessionId Session ID + * @param directory Working directory + * @param currentToolName Current tool being executed (added as last entry) + * @param currentToolInput Current tool input + * @returns Temp file path (caller must call deleteTempTranscript!) + */ +export async function buildTranscriptFromSession( + client: { + session: { + messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise + } + }, + sessionId: string, + directory: string, + currentToolName: string, + currentToolInput: Record +): Promise { + try { + const response = await client.session.messages({ + path: { id: sessionId }, + query: { directory }, + }) + + // Handle various response formats + const messages = (response as { "200"?: unknown[]; data?: unknown[] })["200"] + ?? (response as { data?: unknown[] }).data + ?? (Array.isArray(response) ? response : []) + + const entries: string[] = [] + + if (Array.isArray(messages)) { + for (const msg of messages as OpenCodeMessage[]) { + if (msg.info?.role !== "assistant") continue + + for (const part of msg.parts || []) { + if (part.type !== "tool") continue + if (part.state?.status !== "completed") continue + if (!part.state?.input) continue + + const rawToolName = part.tool as string + const toolName = transformToolName(rawToolName) + + const entry: DisabledTranscriptEntry = { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + name: toolName, + input: part.state.input, + }, + ], + }, + } + entries.push(JSON.stringify(entry)) + } + } + } + + // Always add current tool call as the last entry + const currentEntry: DisabledTranscriptEntry = { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + name: transformToolName(currentToolName), + input: currentToolInput, + }, + ], + }, + } + entries.push(JSON.stringify(currentEntry)) + + // Write to temp file + const tempPath = join( + tmpdir(), + `opencode-transcript-${sessionId}-${randomUUID()}.jsonl` + ) + writeFileSync(tempPath, entries.join("\n") + "\n") + + return tempPath + } catch { + // CRITICAL FIX: Even on API failure, create file with current tool entry only + // (matching original disabled behavior - never return null with incompatible format) + try { + const currentEntry: DisabledTranscriptEntry = { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + name: transformToolName(currentToolName), + input: currentToolInput, + }, + ], + }, + } + const tempPath = join( + tmpdir(), + `opencode-transcript-${sessionId}-${randomUUID()}.jsonl` + ) + writeFileSync(tempPath, JSON.stringify(currentEntry) + "\n") + return tempPath + } catch { + // If even this fails, return null (truly catastrophic failure) + return null + } + } +} + +/** + * Delete temp transcript file (call in finally block) + * + * PORT FROM DISABLED: Cleanup mechanism to avoid disk accumulation + */ +export function deleteTempTranscript(path: string | null): void { + if (!path) return + try { + unlinkSync(path) + } catch { + // Ignore deletion errors + } +}