From c2efdb43340fafde83dbddc29de0d5ad026a119b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:22:25 +0900 Subject: [PATCH] refactor(interactive-bash-session): extract tracker and command parser Split hook into focused modules: - interactive-bash-session-tracker.ts: session tracking logic - tmux-command-parser.ts: tmux command parsing utilities --- src/hooks/interactive-bash-session/index.ts | 2 + .../interactive-bash-session-hook.ts | 216 ++---------------- .../interactive-bash-session-tracker.ts | 118 ++++++++++ .../tmux-command-parser.ts | 125 ++++++++++ 4 files changed, 260 insertions(+), 201 deletions(-) create mode 100644 src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts create mode 100644 src/hooks/interactive-bash-session/tmux-command-parser.ts diff --git a/src/hooks/interactive-bash-session/index.ts b/src/hooks/interactive-bash-session/index.ts index b9be8e12..22b925ff 100644 --- a/src/hooks/interactive-bash-session/index.ts +++ b/src/hooks/interactive-bash-session/index.ts @@ -1 +1,3 @@ export { createInteractiveBashSessionHook } from "./interactive-bash-session-hook" +export { createInteractiveBashSessionTracker } from "./interactive-bash-session-tracker" +export { parseTmuxCommand } from "./tmux-command-parser" diff --git a/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts b/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts index dd0a8700..b3ba0976 100644 --- a/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts +++ b/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts @@ -1,12 +1,6 @@ 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"; +import { createInteractiveBashSessionTracker } from "./interactive-bash-session-tracker"; +import { parseTmuxCommand } from "./tmux-command-parser"; interface ToolExecuteInput { tool: string; @@ -28,162 +22,10 @@ interface EventInput { }; } -/** - * 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 tracker = createInteractiveBashSessionTracker({ + abortSession: (args) => ctx.client.session.abort(args), + }) const toolExecuteAfter = async ( input: ToolExecuteInput, @@ -201,46 +43,21 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) { } const tmuxCommand = args.tmux_command; - const tokens = tokenizeCommand(tmuxCommand); - const subCommand = findSubcommand(tokens); - const state = getOrCreateState(sessionID); - let stateChanged = false; + const { subCommand, sessionName } = parseTmuxCommand(tmuxCommand) 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 { reminderToAppend } = tracker.handleTmuxCommand({ + sessionID, + subCommand, + sessionName, + toolOutput, + }) + if (reminderToAppend) { + output.output += reminderToAppend } }; @@ -252,10 +69,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) { const sessionID = sessionInfo?.id; if (sessionID) { - const state = getOrCreateState(sessionID); - await killAllTrackedSessions(state); - sessionStates.delete(sessionID); - clearInteractiveBashSessionState(sessionID); + await tracker.handleSessionDeleted(sessionID) } } }; diff --git a/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts b/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts new file mode 100644 index 00000000..428d6bba --- /dev/null +++ b/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts @@ -0,0 +1,118 @@ +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"; + +type AbortSession = (args: { path: { id: string } }) => Promise + +function isOmoSession(sessionName: string | null): sessionName is string { + return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX) +} + +async function killAllTrackedSessions( + abortSession: AbortSession, + 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 { + // best-effort cleanup + } + } + + for (const sessionId of subagentSessions) { + abortSession({ path: { id: sessionId } }).catch(() => {}) + } +} + +export function createInteractiveBashSessionTracker(options: { + abortSession: AbortSession +}): { + getOrCreateState: (sessionID: string) => InteractiveBashSessionState + handleSessionDeleted: (sessionID: string) => Promise + handleTmuxCommand: (input: { + sessionID: string + subCommand: string + sessionName: string | null + toolOutput: string + }) => { reminderToAppend: string | null } +} { + const { abortSession } = options + const sessionStates = new Map() + + function getOrCreateState(sessionID: string): InteractiveBashSessionState { + const existing = sessionStates.get(sessionID) + if (existing) return existing + + const persisted = loadInteractiveBashSessionState(sessionID) + const state: InteractiveBashSessionState = persisted ?? { + sessionID, + tmuxSessions: new Set(), + updatedAt: Date.now(), + } + sessionStates.set(sessionID, state) + return state + } + + async function handleSessionDeleted(sessionID: string): Promise { + const state = getOrCreateState(sessionID) + await killAllTrackedSessions(abortSession, state) + sessionStates.delete(sessionID) + clearInteractiveBashSessionState(sessionID) + } + + function handleTmuxCommand(input: { + sessionID: string + subCommand: string + sessionName: string | null + toolOutput: string + }): { reminderToAppend: string | null } { + const { sessionID, subCommand, sessionName, toolOutput } = input + + const state = getOrCreateState(sessionID) + let stateChanged = false + + if (toolOutput.startsWith("Error:")) { + return { reminderToAppend: null } + } + + const isNewSession = subCommand === "new-session" + const isKillSession = subCommand === "kill-session" + const isKillServer = subCommand === "kill-server" + + 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) { + return { reminderToAppend: null } + } + + const reminder = buildSessionReminderMessage(Array.from(state.tmuxSessions)) + return { reminderToAppend: reminder || null } + } + + return { getOrCreateState, handleSessionDeleted, handleTmuxCommand } +} diff --git a/src/hooks/interactive-bash-session/tmux-command-parser.ts b/src/hooks/interactive-bash-session/tmux-command-parser.ts new file mode 100644 index 00000000..7587c9ac --- /dev/null +++ b/src/hooks/interactive-bash-session/tmux-command-parser.ts @@ -0,0 +1,125 @@ +/** + * 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` + */ +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 parseTmuxCommand(tmuxCommand: string): { + subCommand: string + sessionName: string | null +} { + const tokens = tokenizeCommand(tmuxCommand) + const subCommand = findSubcommand(tokens) + const sessionName = extractSessionNameFromTokens(tokens, subCommand) + return { subCommand, sessionName } +}