From 9377c7eba97ad9a6226bc121b43c0f11cd95ddbd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:51:48 +0900 Subject: [PATCH] refactor(hooks/interactive-bash-session): split monolithic hook into modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert index.ts to clean barrel export - Extract hook implementation to hook.ts - Extract terminal parsing to parser.ts - Extract state management to state-manager.ts - Reduce index.ts from ~276 to ~5 lines - Follow modular code architecture principles 🤖 Generated with assistance of OhMyOpenCode --- src/hooks/interactive-bash-session/hook.ts | 125 ++++++++ src/hooks/interactive-bash-session/index.ts | 271 +----------------- src/hooks/interactive-bash-session/parser.ts | 118 ++++++++ .../interactive-bash-session/state-manager.ts | 40 +++ 4 files changed, 287 insertions(+), 267 deletions(-) create mode 100644 src/hooks/interactive-bash-session/hook.ts create mode 100644 src/hooks/interactive-bash-session/parser.ts create mode 100644 src/hooks/interactive-bash-session/state-manager.ts diff --git a/src/hooks/interactive-bash-session/hook.ts b/src/hooks/interactive-bash-session/hook.ts new file mode 100644 index 000000000..1d45a6b0a --- /dev/null +++ b/src/hooks/interactive-bash-session/hook.ts @@ -0,0 +1,125 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import { loadInteractiveBashSessionState, saveInteractiveBashSessionState, clearInteractiveBashSessionState } from "./storage"; +import { buildSessionReminderMessage } from "./constants"; +import type { InteractiveBashSessionState } from "./types"; +import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser"; +import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager"; +import { subagentSessions } from "../../features/claude-code-session-state"; + +interface ToolExecuteInput { + tool: string; + sessionID: string; + callID: string; + args?: Record; +} + +interface ToolExecuteOutput { + title: string; + output: string; + metadata: unknown; +} + +interface EventInput { + event: { + type: string; + properties?: unknown; + }; +} + +export function createInteractiveBashSessionHook(ctx: PluginInput) { + const sessionStates = new Map(); + + function getOrCreateStateLocal(sessionID: string): InteractiveBashSessionState { + return getOrCreateState(sessionID, sessionStates); + } + + async function killAllTrackedSessionsLocal( + state: InteractiveBashSessionState, + ): Promise { + await killAllTrackedSessions(state); + + for (const sessionId of subagentSessions) { + ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {}) + } + } + + const toolExecuteAfter = async ( + input: ToolExecuteInput, + output: ToolExecuteOutput, + ) => { + const { tool, sessionID, args } = input; + const toolLower = tool.toLowerCase(); + + if (toolLower !== "interactive_bash") { + return; + } + + if (typeof args?.tmux_command !== "string") { + return; + } + + const tmuxCommand = args.tmux_command; + const tokens = tokenizeCommand(tmuxCommand); + const subCommand = findSubcommand(tokens); + const state = getOrCreateStateLocal(sessionID); + let stateChanged = false; + + const toolOutput = output?.output ?? "" + if (toolOutput.startsWith("Error:")) { + return + } + + const isNewSession = subCommand === "new-session"; + const isKillSession = subCommand === "kill-session"; + const isKillServer = subCommand === "kill-server"; + + const sessionName = extractSessionNameFromTokens(tokens, subCommand); + + if (isNewSession && isOmoSession(sessionName)) { + state.tmuxSessions.add(sessionName!); + stateChanged = true; + } else if (isKillSession && isOmoSession(sessionName)) { + state.tmuxSessions.delete(sessionName!); + stateChanged = true; + } else if (isKillServer) { + state.tmuxSessions.clear(); + stateChanged = true; + } + + if (stateChanged) { + state.updatedAt = Date.now(); + saveInteractiveBashSessionState(state); + } + + const isSessionOperation = isNewSession || isKillSession || isKillServer; + if (isSessionOperation) { + const reminder = buildSessionReminderMessage( + Array.from(state.tmuxSessions), + ); + if (reminder) { + output.output += reminder; + } + } + }; + + const eventHandler = async ({ event }: EventInput) => { + const props = event.properties as Record | undefined; + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + const sessionID = sessionInfo?.id; + + if (sessionID) { + const state = getOrCreateStateLocal(sessionID); + await killAllTrackedSessionsLocal(state); + sessionStates.delete(sessionID); + clearInteractiveBashSessionState(sessionID); + } + } + }; + + return { + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + }; +} diff --git a/src/hooks/interactive-bash-session/index.ts b/src/hooks/interactive-bash-session/index.ts index 307441629..f5c8ce8db 100644 --- a/src/hooks/interactive-bash-session/index.ts +++ b/src/hooks/interactive-bash-session/index.ts @@ -1,267 +1,4 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import { - loadInteractiveBashSessionState, - saveInteractiveBashSessionState, - clearInteractiveBashSessionState, -} from "./storage"; -import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants"; -import type { InteractiveBashSessionState } from "./types"; -import { subagentSessions } from "../../features/claude-code-session-state"; - -interface ToolExecuteInput { - tool: string; - sessionID: string; - callID: string; - args?: Record; -} - -interface ToolExecuteOutput { - title: string; - output: string; - metadata: unknown; -} - -interface EventInput { - event: { - type: string; - properties?: unknown; - }; -} - -/** - * Quote-aware command tokenizer with escape handling - * Handles single/double quotes and backslash escapes - */ -function tokenizeCommand(cmd: string): string[] { - const tokens: string[] = [] - let current = "" - let inQuote = false - let quoteChar = "" - let escaped = false - - for (let i = 0; i < cmd.length; i++) { - const char = cmd[i] - - if (escaped) { - current += char - escaped = false - continue - } - - if (char === "\\") { - escaped = true - continue - } - - if ((char === "'" || char === '"') && !inQuote) { - inQuote = true - quoteChar = char - } else if (char === quoteChar && inQuote) { - inQuote = false - quoteChar = "" - } else if (char === " " && !inQuote) { - if (current) { - tokens.push(current) - current = "" - } - } else { - current += char - } - } - - if (current) tokens.push(current) - return tokens -} - -/** - * Normalize session name by stripping :window and .pane suffixes - * e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x" - */ -function normalizeSessionName(name: string): string { - return name.split(":")[0].split(".")[0] -} - -function findFlagValue(tokens: string[], flag: string): string | null { - for (let i = 0; i < tokens.length - 1; i++) { - if (tokens[i] === flag) return tokens[i + 1] - } - return null -} - -/** - * Extract session name from tokens, considering the subCommand - * For new-session: prioritize -s over -t - * For other commands: use -t - */ -function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null { - if (subCommand === "new-session") { - const sFlag = findFlagValue(tokens, "-s") - if (sFlag) return normalizeSessionName(sFlag) - const tFlag = findFlagValue(tokens, "-t") - if (tFlag) return normalizeSessionName(tFlag) - } else { - const tFlag = findFlagValue(tokens, "-t") - if (tFlag) return normalizeSessionName(tFlag) - } - return null -} - -/** - * Find the tmux subcommand from tokens, skipping global options. - * tmux allows global options before the subcommand: - * e.g., `tmux -L socket-name new-session -s omo-x` - * Global options with args: -L, -S, -f, -c, -T - * Standalone flags: -C, -v, -V, etc. - * Special: -- (end of options marker) - */ -function findSubcommand(tokens: string[]): string { - // Options that require an argument: -L, -S, -f, -c, -T - const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"]) - - let i = 0 - while (i < tokens.length) { - const token = tokens[i] - - // Handle end of options marker - if (token === "--") { - // Next token is the subcommand - return tokens[i + 1] ?? "" - } - - if (globalOptionsWithArgs.has(token)) { - // Skip the option and its argument - i += 2 - continue - } - - if (token.startsWith("-")) { - // Skip standalone flags like -C, -v, -V - i++ - continue - } - - // Found the subcommand - return token - } - - return "" -} - -export function createInteractiveBashSessionHook(ctx: PluginInput) { - const sessionStates = new Map(); - - function getOrCreateState(sessionID: string): InteractiveBashSessionState { - if (!sessionStates.has(sessionID)) { - const persisted = loadInteractiveBashSessionState(sessionID); - const state: InteractiveBashSessionState = persisted ?? { - sessionID, - tmuxSessions: new Set(), - updatedAt: Date.now(), - }; - sessionStates.set(sessionID, state); - } - return sessionStates.get(sessionID)!; - } - - function isOmoSession(sessionName: string | null): boolean { - return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX); - } - - async function killAllTrackedSessions( - state: InteractiveBashSessionState, - ): Promise { - for (const sessionName of state.tmuxSessions) { - try { - const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { - stdout: "ignore", - stderr: "ignore", - }); - await proc.exited; - } catch {} - } - - for (const sessionId of subagentSessions) { - ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {}) - } - } - - const toolExecuteAfter = async ( - input: ToolExecuteInput, - output: ToolExecuteOutput, - ) => { - const { tool, sessionID, args } = input; - const toolLower = tool.toLowerCase(); - - if (toolLower !== "interactive_bash") { - return; - } - - if (typeof args?.tmux_command !== "string") { - return; - } - - const tmuxCommand = args.tmux_command; - const tokens = tokenizeCommand(tmuxCommand); - const subCommand = findSubcommand(tokens); - const state = getOrCreateState(sessionID); - let stateChanged = false; - - const toolOutput = output?.output ?? "" - if (toolOutput.startsWith("Error:")) { - return - } - - const isNewSession = subCommand === "new-session"; - const isKillSession = subCommand === "kill-session"; - const isKillServer = subCommand === "kill-server"; - - const sessionName = extractSessionNameFromTokens(tokens, subCommand); - - if (isNewSession && isOmoSession(sessionName)) { - state.tmuxSessions.add(sessionName!); - stateChanged = true; - } else if (isKillSession && isOmoSession(sessionName)) { - state.tmuxSessions.delete(sessionName!); - stateChanged = true; - } else if (isKillServer) { - state.tmuxSessions.clear(); - stateChanged = true; - } - - if (stateChanged) { - state.updatedAt = Date.now(); - saveInteractiveBashSessionState(state); - } - - const isSessionOperation = isNewSession || isKillSession || isKillServer; - if (isSessionOperation) { - const reminder = buildSessionReminderMessage( - Array.from(state.tmuxSessions), - ); - if (reminder) { - output.output += reminder; - } - } - }; - - const eventHandler = async ({ event }: EventInput) => { - const props = event.properties as Record | undefined; - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - const sessionID = sessionInfo?.id; - - if (sessionID) { - const state = getOrCreateState(sessionID); - await killAllTrackedSessions(state); - sessionStates.delete(sessionID); - clearInteractiveBashSessionState(sessionID); - } - } - }; - - return { - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - }; -} +export { createInteractiveBashSessionHook } from "./hook"; +export * from "./types"; +export * from "./constants"; +export * from "./storage"; diff --git a/src/hooks/interactive-bash-session/parser.ts b/src/hooks/interactive-bash-session/parser.ts new file mode 100644 index 000000000..0002d9312 --- /dev/null +++ b/src/hooks/interactive-bash-session/parser.ts @@ -0,0 +1,118 @@ +/** + * Quote-aware command tokenizer with escape handling + * Handles single/double quotes and backslash escapes + */ +export function tokenizeCommand(cmd: string): string[] { + const tokens: string[] = [] + let current = "" + let inQuote = false + let quoteChar = "" + let escaped = false + + for (let i = 0; i < cmd.length; i++) { + const char = cmd[i] + + if (escaped) { + current += char + escaped = false + continue + } + + if (char === "\\") { + escaped = true + continue + } + + if ((char === "'" || char === '"') && !inQuote) { + inQuote = true + quoteChar = char + } else if (char === quoteChar && inQuote) { + inQuote = false + quoteChar = "" + } else if (char === " " && !inQuote) { + if (current) { + tokens.push(current) + current = "" + } + } else { + current += char + } + } + + if (current) tokens.push(current) + return tokens +} + +/** + * Normalize session name by stripping :window and .pane suffixes + * e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x" + */ +export function normalizeSessionName(name: string): string { + return name.split(":")[0].split(".")[0] +} + +export function findFlagValue(tokens: string[], flag: string): string | null { + for (let i = 0; i < tokens.length - 1; i++) { + if (tokens[i] === flag) return tokens[i + 1] + } + return null +} + +/** + * Extract session name from tokens, considering the subCommand + * For new-session: prioritize -s over -t + * For other commands: use -t + */ +export function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null { + if (subCommand === "new-session") { + const sFlag = findFlagValue(tokens, "-s") + if (sFlag) return normalizeSessionName(sFlag) + const tFlag = findFlagValue(tokens, "-t") + if (tFlag) return normalizeSessionName(tFlag) + } else { + const tFlag = findFlagValue(tokens, "-t") + if (tFlag) return normalizeSessionName(tFlag) + } + return null +} + +/** + * Find the tmux subcommand from tokens, skipping global options. + * tmux allows global options before the subcommand: + * e.g., `tmux -L socket-name new-session -s omo-x` + * Global options with args: -L, -S, -f, -c, -T + * Standalone flags: -C, -v, -V, etc. + * Special: -- (end of options marker) + */ +export function findSubcommand(tokens: string[]): string { + // Options that require an argument: -L, -S, -f, -c, -T + const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"]) + + let i = 0 + while (i < tokens.length) { + const token = tokens[i] + + // Handle end of options marker + if (token === "--") { + // Next token is the subcommand + return tokens[i + 1] ?? "" + } + + if (globalOptionsWithArgs.has(token)) { + // Skip the option and its argument + i += 2 + continue + } + + if (token.startsWith("-")) { + // Skip standalone flags like -C, -v, -V + i++ + continue + } + + // Found the subcommand + return token + } + + return "" +} diff --git a/src/hooks/interactive-bash-session/state-manager.ts b/src/hooks/interactive-bash-session/state-manager.ts new file mode 100644 index 000000000..109e85e99 --- /dev/null +++ b/src/hooks/interactive-bash-session/state-manager.ts @@ -0,0 +1,40 @@ +import type { InteractiveBashSessionState } from "./types"; +import { loadInteractiveBashSessionState, saveInteractiveBashSessionState } from "./storage"; +import { OMO_SESSION_PREFIX } from "./constants"; +import { subagentSessions } from "../../features/claude-code-session-state"; + +export function getOrCreateState(sessionID: string, sessionStates: Map): InteractiveBashSessionState { + if (!sessionStates.has(sessionID)) { + const persisted = loadInteractiveBashSessionState(sessionID); + const state: InteractiveBashSessionState = persisted ?? { + sessionID, + tmuxSessions: new Set(), + updatedAt: Date.now(), + }; + sessionStates.set(sessionID, state); + } + return sessionStates.get(sessionID)!; +} + +export function isOmoSession(sessionName: string | null): boolean { + return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX); +} + +export async function killAllTrackedSessions( + state: InteractiveBashSessionState, +): Promise { + for (const sessionName of state.tmuxSessions) { + try { + const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; + } catch {} + } + + for (const sessionId of subagentSessions) { + // Note: ctx is not available here, so we can't call ctx.client.session.abort + // This will need to be handled in the hook where ctx is available + } +}