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
This commit is contained in:
YeonGyu-Kim
2026-02-08 16:22:25 +09:00
parent d3a3f0c3a6
commit c2efdb4334
4 changed files with 260 additions and 201 deletions

View File

@@ -1 +1,3 @@
export { createInteractiveBashSessionHook } from "./interactive-bash-session-hook"
export { createInteractiveBashSessionTracker } from "./interactive-bash-session-tracker"
export { parseTmuxCommand } from "./tmux-command-parser"

View File

@@ -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<string>(["-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<string, InteractiveBashSessionState>();
function getOrCreateState(sessionID: string): InteractiveBashSessionState {
if (!sessionStates.has(sessionID)) {
const persisted = loadInteractiveBashSessionState(sessionID);
const state: InteractiveBashSessionState = persisted ?? {
sessionID,
tmuxSessions: new Set<string>(),
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<void> {
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)
}
}
};

View File

@@ -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<unknown>
function isOmoSession(sessionName: string | null): sessionName is string {
return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX)
}
async function killAllTrackedSessions(
abortSession: AbortSession,
state: InteractiveBashSessionState,
): Promise<void> {
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<void>
handleTmuxCommand: (input: {
sessionID: string
subCommand: string
sessionName: string | null
toolOutput: string
}) => { reminderToAppend: string | null }
} {
const { abortSession } = options
const sessionStates = new Map<string, InteractiveBashSessionState>()
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<string>(),
updatedAt: Date.now(),
}
sessionStates.set(sessionID, state)
return state
}
async function handleSessionDeleted(sessionID: string): Promise<void> {
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 }
}

View File

@@ -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<string>(["-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 }
}