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:
@@ -61,6 +61,7 @@ export const HookNameSchema = z.enum([
|
|||||||
"keyword-detector",
|
"keyword-detector",
|
||||||
"agent-usage-reminder",
|
"agent-usage-reminder",
|
||||||
"non-interactive-env",
|
"non-interactive-env",
|
||||||
|
"interactive-bash-session",
|
||||||
])
|
])
|
||||||
|
|
||||||
export const AgentOverrideConfigSchema = z.object({
|
export const AgentOverrideConfigSchema = z.object({
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ export { createAutoUpdateCheckerHook } from "./auto-update-checker";
|
|||||||
export { createAgentUsageReminderHook } from "./agent-usage-reminder";
|
export { createAgentUsageReminderHook } from "./agent-usage-reminder";
|
||||||
export { createKeywordDetectorHook } from "./keyword-detector";
|
export { createKeywordDetectorHook } from "./keyword-detector";
|
||||||
export { createNonInteractiveEnvHook } from "./non-interactive-env";
|
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_workspace_symbols",
|
||||||
"lsp_diagnostics",
|
"lsp_diagnostics",
|
||||||
"ast_grep_search",
|
"ast_grep_search",
|
||||||
|
"interactive_bash",
|
||||||
|
"Interactive_bash",
|
||||||
]
|
]
|
||||||
|
|
||||||
export function createToolOutputTruncatorHook(ctx: PluginInput) {
|
export function createToolOutputTruncatorHook(ctx: PluginInput) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
createKeywordDetectorHook,
|
createKeywordDetectorHook,
|
||||||
createAgentUsageReminderHook,
|
createAgentUsageReminderHook,
|
||||||
createNonInteractiveEnvHook,
|
createNonInteractiveEnvHook,
|
||||||
|
createInteractiveBashSessionHook,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||||
import {
|
import {
|
||||||
@@ -242,6 +243,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
const nonInteractiveEnv = isHookEnabled("non-interactive-env")
|
const nonInteractiveEnv = isHookEnabled("non-interactive-env")
|
||||||
? createNonInteractiveEnvHook(ctx)
|
? createNonInteractiveEnvHook(ctx)
|
||||||
: null;
|
: null;
|
||||||
|
const interactiveBashSession = isHookEnabled("interactive-bash-session")
|
||||||
|
? createInteractiveBashSessionHook(ctx)
|
||||||
|
: null;
|
||||||
|
|
||||||
updateTerminalTitle({ sessionId: "main" });
|
updateTerminalTitle({ sessionId: "main" });
|
||||||
|
|
||||||
@@ -387,6 +391,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
await anthropicAutoCompact?.event(input);
|
await anthropicAutoCompact?.event(input);
|
||||||
await keywordDetector?.event(input);
|
await keywordDetector?.event(input);
|
||||||
await agentUsageReminder?.event(input);
|
await agentUsageReminder?.event(input);
|
||||||
|
await interactiveBashSession?.event(input);
|
||||||
|
|
||||||
const { event } = input;
|
const { event } = input;
|
||||||
const props = event.properties as Record<string, unknown> | undefined;
|
const props = event.properties as Record<string, unknown> | undefined;
|
||||||
@@ -508,6 +513,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
await rulesInjector?.["tool.execute.after"](input, output);
|
await rulesInjector?.["tool.execute.after"](input, output);
|
||||||
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
|
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
|
||||||
await agentUsageReminder?.["tool.execute.after"](input, output);
|
await agentUsageReminder?.["tool.execute.after"](input, output);
|
||||||
|
await interactiveBashSession?.["tool.execute.after"](input, output);
|
||||||
|
|
||||||
if (input.sessionID === getMainSessionID()) {
|
if (input.sessionID === getMainSessionID()) {
|
||||||
updateTerminalTitle({
|
updateTerminalTitle({
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { grep } from "./grep"
|
|||||||
import { glob } from "./glob"
|
import { glob } from "./glob"
|
||||||
import { slashcommand } from "./slashcommand"
|
import { slashcommand } from "./slashcommand"
|
||||||
import { skill } from "./skill"
|
import { skill } from "./skill"
|
||||||
|
import { interactive_bash } from "./interactive-bash"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createBackgroundTask,
|
createBackgroundTask,
|
||||||
@@ -62,4 +63,5 @@ export const builtinTools = {
|
|||||||
glob,
|
glob,
|
||||||
slashcommand,
|
slashcommand,
|
||||||
skill,
|
skill,
|
||||||
|
interactive_bash,
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/tools/interactive-bash/constants.ts
Normal file
21
src/tools/interactive-bash/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
export const INTERACTIVE_BASH_DESCRIPTION = `Execute tmux commands for interactive terminal session management.
|
||||||
|
|
||||||
|
This tool provides access to tmux for creating and managing persistent terminal sessions.
|
||||||
|
Use it to run interactive CLI applications, maintain long-running processes, or work with multiple terminal sessions.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- tmux_command: The tmux command to execute (e.g., "new-session -d -s omo-dev", "send-keys -t omo-dev 'ls' Enter")
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Create session: "new-session -d -s omo-test"
|
||||||
|
- Send keys: "send-keys -t omo-test 'npm run dev' Enter"
|
||||||
|
- Capture output: "capture-pane -t omo-test -p"
|
||||||
|
- List sessions: "list-sessions"
|
||||||
|
- Kill session: "kill-session -t omo-test"
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Session names should follow the pattern "omo-{name}" for automatic tracking
|
||||||
|
- Use -d flag with new-session to create detached sessions
|
||||||
|
- Use capture-pane -p to retrieve terminal output`
|
||||||
3
src/tools/interactive-bash/index.ts
Normal file
3
src/tools/interactive-bash/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { interactive_bash } from "./tools"
|
||||||
|
|
||||||
|
export { interactive_bash }
|
||||||
43
src/tools/interactive-bash/tools.ts
Normal file
43
src/tools/interactive-bash/tools.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { tool } from "@opencode-ai/plugin/tool"
|
||||||
|
import { DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
|
||||||
|
|
||||||
|
export const interactive_bash = tool({
|
||||||
|
description: INTERACTIVE_BASH_DESCRIPTION,
|
||||||
|
args: {
|
||||||
|
tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"),
|
||||||
|
},
|
||||||
|
execute: async (args) => {
|
||||||
|
try {
|
||||||
|
const parts = args.tmux_command.split(/\s+/).filter((p) => p.length > 0)
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "Error: Empty tmux command"
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = Bun.spawn(["tmux", ...parts], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
proc.kill()
|
||||||
|
reject(new Error(`Timeout after ${DEFAULT_TIMEOUT_MS}ms`))
|
||||||
|
}, DEFAULT_TIMEOUT_MS)
|
||||||
|
proc.exited.then(() => clearTimeout(id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
||||||
|
const stderr = await new Response(proc.stderr).text()
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
|
||||||
|
if (exitCode !== 0 && stderr.trim()) {
|
||||||
|
return `Error: ${stderr.trim()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout || "(no output)"
|
||||||
|
} catch (e) {
|
||||||
|
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
3
src/tools/interactive-bash/types.ts
Normal file
3
src/tools/interactive-bash/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface InteractiveBashArgs {
|
||||||
|
tmux_command: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user