feat(tools): add interactive_bash tool for tmux session management
Add a new tool for managing tmux sessions with automatic tracking and cleanup: - interactive_bash tool: Accepts tmux commands via tmux_command parameter - Session tracking hook: Tracks omo-* prefixed tmux sessions per OpenCode session - System reminder: Appends active session list after create/delete operations - Auto cleanup: Kills all tracked tmux sessions on OpenCode session deletion - Output truncation: Registered in TRUNCATABLE_TOOLS for long capture-pane outputs 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -18,3 +18,4 @@ export { createAutoUpdateCheckerHook } from "./auto-update-checker";
|
||||
export { createAgentUsageReminderHook } from "./agent-usage-reminder";
|
||||
export { createKeywordDetectorHook } from "./keyword-detector";
|
||||
export { createNonInteractiveEnvHook } from "./non-interactive-env";
|
||||
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
|
||||
|
||||
15
src/hooks/interactive-bash-session/constants.ts
Normal file
15
src/hooks/interactive-bash-session/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const INTERACTIVE_BASH_SESSION_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"interactive-bash-session",
|
||||
);
|
||||
|
||||
export const OMO_SESSION_PREFIX = "omo-";
|
||||
|
||||
export function buildSessionReminderMessage(sessions: string[]): string {
|
||||
if (sessions.length === 0) return "";
|
||||
return `\n\n[System Reminder] Active omo-* tmux sessions: ${sessions.join(", ")}`;
|
||||
}
|
||||
137
src/hooks/interactive-bash-session/index.ts
Normal file
137
src/hooks/interactive-bash-session/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
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 extractSessionNameFromFlags(tmuxCommand: string): string | null {
|
||||
const sessionFlagMatch = tmuxCommand.match(/(?:-s|-t)\s+(\S+)/);
|
||||
return sessionFlagMatch?.[1] ?? null;
|
||||
}
|
||||
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const { tool, sessionID, args } = input;
|
||||
const toolLower = tool.toLowerCase();
|
||||
|
||||
if (toolLower !== "interactive_bash") {
|
||||
return;
|
||||
}
|
||||
|
||||
const tmuxCommand = (args?.tmux_command as string) ?? "";
|
||||
const state = getOrCreateState(sessionID);
|
||||
let stateChanged = false;
|
||||
|
||||
const hasNewSession = tmuxCommand.includes("new-session");
|
||||
const hasKillSession = tmuxCommand.includes("kill-session");
|
||||
const hasKillServer = tmuxCommand.includes("kill-server");
|
||||
|
||||
const sessionName = extractSessionNameFromFlags(tmuxCommand);
|
||||
|
||||
if (hasNewSession && isOmoSession(sessionName)) {
|
||||
state.tmuxSessions.add(sessionName!);
|
||||
stateChanged = true;
|
||||
} else if (hasKillSession && isOmoSession(sessionName)) {
|
||||
state.tmuxSessions.delete(sessionName!);
|
||||
stateChanged = true;
|
||||
} else if (hasKillServer) {
|
||||
state.tmuxSessions.clear();
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
if (stateChanged) {
|
||||
state.updatedAt = Date.now();
|
||||
saveInteractiveBashSessionState(state);
|
||||
}
|
||||
|
||||
const isSessionOperation = hasNewSession || hasKillSession || hasKillServer;
|
||||
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<string, unknown> | 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,
|
||||
};
|
||||
}
|
||||
59
src/hooks/interactive-bash-session/storage.ts
Normal file
59
src/hooks/interactive-bash-session/storage.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { INTERACTIVE_BASH_SESSION_STORAGE } from "./constants";
|
||||
import type {
|
||||
InteractiveBashSessionState,
|
||||
SerializedInteractiveBashSessionState,
|
||||
} from "./types";
|
||||
|
||||
function getStoragePath(sessionID: string): string {
|
||||
return join(INTERACTIVE_BASH_SESSION_STORAGE, `${sessionID}.json`);
|
||||
}
|
||||
|
||||
export function loadInteractiveBashSessionState(
|
||||
sessionID: string,
|
||||
): InteractiveBashSessionState | null {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (!existsSync(filePath)) return null;
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const serialized = JSON.parse(content) as SerializedInteractiveBashSessionState;
|
||||
return {
|
||||
sessionID: serialized.sessionID,
|
||||
tmuxSessions: new Set(serialized.tmuxSessions),
|
||||
updatedAt: serialized.updatedAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveInteractiveBashSessionState(
|
||||
state: InteractiveBashSessionState,
|
||||
): void {
|
||||
if (!existsSync(INTERACTIVE_BASH_SESSION_STORAGE)) {
|
||||
mkdirSync(INTERACTIVE_BASH_SESSION_STORAGE, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = getStoragePath(state.sessionID);
|
||||
const serialized: SerializedInteractiveBashSessionState = {
|
||||
sessionID: state.sessionID,
|
||||
tmuxSessions: Array.from(state.tmuxSessions),
|
||||
updatedAt: state.updatedAt,
|
||||
};
|
||||
writeFileSync(filePath, JSON.stringify(serialized, null, 2));
|
||||
}
|
||||
|
||||
export function clearInteractiveBashSessionState(sessionID: string): void {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
11
src/hooks/interactive-bash-session/types.ts
Normal file
11
src/hooks/interactive-bash-session/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface InteractiveBashSessionState {
|
||||
sessionID: string;
|
||||
tmuxSessions: Set<string>;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SerializedInteractiveBashSessionState {
|
||||
sessionID: string;
|
||||
tmuxSessions: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ const TRUNCATABLE_TOOLS = [
|
||||
"lsp_workspace_symbols",
|
||||
"lsp_diagnostics",
|
||||
"ast_grep_search",
|
||||
"interactive_bash",
|
||||
"Interactive_bash",
|
||||
]
|
||||
|
||||
export function createToolOutputTruncatorHook(ctx: PluginInput) {
|
||||
|
||||
Reference in New Issue
Block a user