import type { Plugin } from "@opencode-ai/plugin" import { spawn } from "bun" import { appendFileSync } from "fs" /** * tmux-panes plugin * * When opencode spawns a background subagent, this plugin automatically opens * a new tmux pane showing that subagent's live TUI via `opencode attach`. * * Layout: * - First subagent: horizontal 60/40 split — main pane on left, subagent on right * - Additional subagents: stacked vertically in the right column * - Panes close automatically when subagent sessions end * * Only activates when running inside a tmux session (TMUX env var is set). * * Debug log: /tmp/opencode-tmux-debug.log */ const DEBUG_LOG = "/tmp/opencode-tmux-debug.log" const log = (msg: string) => appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`) const isInsideTmux = () => Boolean(process.env.TMUX) const getCurrentPaneId = () => process.env.TMUX_PANE const plugin: Plugin = async (ctx) => { if (!isInsideTmux()) return {} const sessions = new Map() // sessionId → tmux paneId const sourcePaneId = getCurrentPaneId() const serverUrl = ctx.serverUrl?.toString() ?? "" log(`plugin init — serverUrl=${serverUrl} sourcePaneId=${sourcePaneId}`) // Ordered list of pane IDs in the right column. // Empty = no right column yet; length > 0 = right column exists. const rightColumnPanes: string[] = [] return { event: async ({ event }) => { // Spawn a new pane when a subagent session is created if (event.type === "session.created") { const info = (event as any).properties?.info // parentID presence distinguishes subagents from the root session if (!info?.id || !info?.parentID) return const sessionId: string = info.id if (sessions.has(sessionId)) return // Wrap the attach command: on failure, show the error and keep the // pane open for 30 s so we can read it before it disappears. const attachCmd = `opencode attach ${serverUrl} --session ${sessionId}` const cmd = `bash -c '${attachCmd}; _exit=$?; echo "--- exit: $_exit ---" >> ${DEBUG_LOG}; [ $_exit -ne 0 ] && sleep 30'` log(`spawning pane — cmd: ${attachCmd}`) let args: string[] if (rightColumnPanes.length === 0) { // First subagent: open a horizontal split, giving the new (right) // pane 40% of the window width so the main pane keeps 60%. args = [ "tmux", "split-window", "-h", // horizontal split — new pane appears on the right "-p", "40", // new pane gets 40% of window width "-d", // don't focus the new pane "-P", "-F", "#{pane_id}", ...(sourcePaneId ? ["-t", sourcePaneId] : []), cmd, ] } else { // Additional subagents: stack vertically within the right column // by splitting the last right-column pane horizontally. const lastRightPane = rightColumnPanes[rightColumnPanes.length - 1] args = [ "tmux", "split-window", "-v", // vertical split — new pane stacks below the target "-d", // don't focus the new pane "-P", "-F", "#{pane_id}", "-t", lastRightPane, cmd, ] } const proc = spawn(args, { stdout: "pipe", stderr: "pipe" }) const paneId = (await new Response(proc.stdout).text()).trim() const exitCode = await proc.exited log(`split-window exit=${exitCode} paneId=${paneId}`) if (exitCode === 0 && paneId) { sessions.set(sessionId, paneId) rightColumnPanes.push(paneId) } } // Kill the pane when the subagent session ends if (event.type === "session.deleted") { const info = (event as any).properties?.info const paneId = sessions.get(info?.id) if (paneId) { spawn(["tmux", "kill-pane", "-t", paneId], { stdout: "ignore", stderr: "ignore", }) sessions.delete(info.id) const idx = rightColumnPanes.indexOf(paneId) if (idx !== -1) rightColumnPanes.splice(idx, 1) } } }, } } export default plugin