import type { Plugin } from "@opencode-ai/plugin" import { spawn } from "bun" /** * 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). */ const isInsideTmux = () => Boolean(process.env.TMUX) const getCurrentPaneId = () => process.env.TMUX_PANE const runTmux = async (args: string[]) => { const proc = spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" }) const stdout = await new Response(proc.stdout).text() const stderr = await new Response(proc.stderr).text() const exitCode = await proc.exited return { exitCode, stdout: stdout.trim(), stderr: stderr.trim(), } } const removeItem = (items: string[], value: string) => { const idx = items.indexOf(value) if (idx !== -1) items.splice(idx, 1) } const plugin: Plugin = async (ctx) => { if (!isInsideTmux()) return {} const sessions = new Map() // sessionId → tmux paneId const sourcePaneId = getCurrentPaneId() const serverUrl = (ctx.serverUrl?.toString() ?? "").replace(/\/$/, "") // Ordered list of pane IDs in the right column. // Empty = no right column yet; length > 0 = right column exists. const rightColumnPanes: string[] = [] let paneOps = Promise.resolve() const getWindowInfo = async () => { const targetPane = sourcePaneId ?? rightColumnPanes[0] if (!targetPane) return null const result = await runTmux([ "display-message", "-p", "-t", targetPane, "#{window_id} #{window_width}", ]) if (result.exitCode !== 0 || !result.stdout) return null const [windowId, widthText] = result.stdout.split(/\s+/, 2) const width = Number(widthText) if (!windowId || Number.isNaN(width)) return null return { windowId, width } } const applyLayout = async () => { if (rightColumnPanes.length === 0) return const windowInfo = await getWindowInfo() if (!windowInfo) return const mainWidth = Math.max(1, Math.round(windowInfo.width * 0.6)) await runTmux([ "set-window-option", "-t", windowInfo.windowId, "main-pane-width", String(mainWidth), ]) await runTmux(["select-layout", "-t", sourcePaneId ?? rightColumnPanes[0], "main-vertical"]) } const closeSessionPane = async (sessionId: string) => { const paneId = sessions.get(sessionId) if (!paneId) return await runTmux(["kill-pane", "-t", paneId]) sessions.delete(sessionId) removeItem(rightColumnPanes, paneId) await applyLayout() } const enqueuePaneOp = (operation: () => Promise) => { paneOps = paneOps.then(operation).catch(() => {}) return paneOps } const isTerminalSessionUpdate = (info: any) => Boolean(info?.time?.archived || info?.time?.compacting) 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 await enqueuePaneOp(async () => { if (sessions.has(sessionId)) return const cmd = `opencode attach ${serverUrl} --session ${sessionId}` let args: string[] if (rightColumnPanes.length === 0) { const windowInfo = await getWindowInfo() const rightWidth = windowInfo ? Math.max(1, Math.round(windowInfo.width * 0.4)) : 40 args = [ "split-window", "-h", "-l", String(rightWidth), "-d", "-P", "-F", "#{pane_id}", ...(sourcePaneId ? ["-t", sourcePaneId] : []), cmd, ] } else { const lastRightPane = rightColumnPanes[rightColumnPanes.length - 1] args = [ "split-window", "-v", "-d", "-P", "-F", "#{pane_id}", "-t", lastRightPane, cmd, ] } const result = await runTmux(args) const paneId = result.stdout if (result.exitCode === 0 && paneId) { sessions.set(sessionId, paneId) rightColumnPanes.push(paneId) await applyLayout() } }) } // Kill the pane when the subagent session ends if (event.type === "session.deleted") { const info = (event as any).properties?.info if (!info?.id) return await enqueuePaneOp(() => closeSessionPane(info.id)) } if (event.type === "session.updated") { const info = (event as any).properties?.info if (!info?.id || !sessions.has(info.id) || !isTerminalSessionUpdate(info)) return await enqueuePaneOp(() => closeSessionPane(info.id)) } if (event.type === "session.status") { const sessionID = (event as any).properties?.sessionID const statusType = (event as any).properties?.status?.type if (!sessionID || !sessions.has(sessionID) || statusType !== "idle") return await enqueuePaneOp(() => closeSessionPane(sessionID)) } }, } } export default plugin