Files
dotfiles/.config/opencode/plugins/tmux-panes.ts
alex 1145ce2be4 debug(tmux-panes): log serverUrl and wrap attach cmd to expose errors
On attach failure the pane stays open for 30s showing the error.
All events logged to /tmp/opencode-tmux-debug.log.
2026-03-11 12:23:36 +00:00

118 lines
3.9 KiB
TypeScript

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<string, string>() // 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