2025-12-27 17:56:40 +08:00
|
|
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
2025-12-15 23:01:02 +09:00
|
|
|
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
|
2026-02-08 13:57:26 +09:00
|
|
|
import { getCachedTmuxPath } from "./tmux-path-resolver"
|
2025-12-15 19:02:31 +09:00
|
|
|
|
2025-12-15 19:02:31 +09:00
|
|
|
/**
|
|
|
|
|
* Quote-aware command tokenizer with escape handling
|
|
|
|
|
* Handles single/double quotes and backslash escapes without external dependencies
|
|
|
|
|
*/
|
|
|
|
|
export 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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-27 17:56:40 +08:00
|
|
|
export const interactive_bash: ToolDefinition = tool({
|
2025-12-15 19:02:31 +09:00
|
|
|
description: INTERACTIVE_BASH_DESCRIPTION,
|
|
|
|
|
args: {
|
|
|
|
|
tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"),
|
|
|
|
|
},
|
|
|
|
|
execute: async (args) => {
|
|
|
|
|
try {
|
2025-12-15 19:02:31 +09:00
|
|
|
const tmuxPath = getCachedTmuxPath() ?? "tmux"
|
|
|
|
|
|
2025-12-15 19:02:31 +09:00
|
|
|
const parts = tokenizeCommand(args.tmux_command)
|
2025-12-15 19:02:31 +09:00
|
|
|
|
|
|
|
|
if (parts.length === 0) {
|
|
|
|
|
return "Error: Empty tmux command"
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-15 23:01:02 +09:00
|
|
|
const subcommand = parts[0].toLowerCase()
|
|
|
|
|
if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) {
|
2026-01-15 19:40:50 +09:00
|
|
|
const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t"))
|
|
|
|
|
let sessionName = "omo-session"
|
|
|
|
|
if (sessionIdx !== -1) {
|
|
|
|
|
if (parts[sessionIdx] === "-t" && parts[sessionIdx + 1]) {
|
|
|
|
|
sessionName = parts[sessionIdx + 1]
|
|
|
|
|
} else if (parts[sessionIdx].startsWith("-t")) {
|
|
|
|
|
sessionName = parts[sessionIdx].slice(2)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `Error: '${parts[0]}' is blocked in interactive_bash.
|
|
|
|
|
|
|
|
|
|
**USE BASH TOOL INSTEAD:**
|
|
|
|
|
|
|
|
|
|
\`\`\`bash
|
|
|
|
|
# Capture terminal output
|
|
|
|
|
tmux capture-pane -p -t ${sessionName}
|
|
|
|
|
|
|
|
|
|
# Or capture with history (last 1000 lines)
|
|
|
|
|
tmux capture-pane -p -t ${sessionName} -S -1000
|
|
|
|
|
\`\`\`
|
|
|
|
|
|
|
|
|
|
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
|
2025-12-15 23:01:02 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-15 19:02:31 +09:00
|
|
|
const proc = Bun.spawn([tmuxPath, ...parts], {
|
2025-12-15 19:02:31 +09:00
|
|
|
stdout: "pipe",
|
|
|
|
|
stderr: "pipe",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
|
|
|
const id = setTimeout(() => {
|
2026-01-31 14:01:19 +07:00
|
|
|
const timeoutError = new Error(`Timeout after ${DEFAULT_TIMEOUT_MS}ms`)
|
|
|
|
|
try {
|
|
|
|
|
proc.kill()
|
|
|
|
|
// Fire-and-forget: wait for process exit in background to avoid zombies
|
|
|
|
|
void proc.exited.catch(() => {})
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore kill errors; we'll still reject with timeoutError below
|
|
|
|
|
}
|
|
|
|
|
reject(timeoutError)
|
2025-12-15 19:02:31 +09:00
|
|
|
}, DEFAULT_TIMEOUT_MS)
|
2026-01-31 14:01:19 +07:00
|
|
|
proc.exited
|
|
|
|
|
.then(() => clearTimeout(id))
|
|
|
|
|
.catch(() => clearTimeout(id))
|
2025-12-15 19:02:31 +09:00
|
|
|
})
|
|
|
|
|
|
2025-12-15 19:02:31 +09:00
|
|
|
// Read stdout and stderr in parallel to avoid race conditions
|
|
|
|
|
const [stdout, stderr, exitCode] = await Promise.race([
|
|
|
|
|
Promise.all([
|
|
|
|
|
new Response(proc.stdout).text(),
|
|
|
|
|
new Response(proc.stderr).text(),
|
|
|
|
|
proc.exited,
|
|
|
|
|
]),
|
|
|
|
|
timeoutPromise,
|
|
|
|
|
])
|
2025-12-15 19:02:31 +09:00
|
|
|
|
2025-12-15 19:02:31 +09:00
|
|
|
// Check exitCode properly - return error even if stderr is empty
|
|
|
|
|
if (exitCode !== 0) {
|
|
|
|
|
const errorMsg = stderr.trim() || `Command failed with exit code ${exitCode}`
|
|
|
|
|
return `Error: ${errorMsg}`
|
2025-12-15 19:02:31 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return stdout || "(no output)"
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})
|