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:
@@ -1 +1,3 @@
|
||||
export { createInteractiveBashSessionHook } from "./interactive-bash-session-hook"
|
||||
export { createInteractiveBashSessionTracker } from "./interactive-bash-session-tracker"
|
||||
export { parseTmuxCommand } from "./tmux-command-parser"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
125
src/hooks/interactive-bash-session/tmux-command-parser.ts
Normal file
125
src/hooks/interactive-bash-session/tmux-command-parser.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user