diff --git a/.config/opencode/2026-03-11-123734_hyprshot.png b/.config/opencode/2026-03-11-123734_hyprshot.png new file mode 100644 index 0000000..0285739 Binary files /dev/null and b/.config/opencode/2026-03-11-123734_hyprshot.png differ diff --git a/.config/opencode/2026-03-11-123743_hyprshot.png b/.config/opencode/2026-03-11-123743_hyprshot.png new file mode 100644 index 0000000..7d7a1bd Binary files /dev/null and b/.config/opencode/2026-03-11-123743_hyprshot.png differ diff --git a/.config/opencode/AGENTS.md b/.config/opencode/AGENTS.md index d5a20c9..c0d7266 100644 --- a/.config/opencode/AGENTS.md +++ b/.config/opencode/AGENTS.md @@ -211,6 +211,16 @@ Use stable identifiers so agents can pass note references between delegations. All agents except `lead`, `coder`, and `librarian` are code/source read-only. Agents with `permission.edit: allow` may update basic-memory notes for their recording duties; they must not edit implementation source files. +### Explorer Scope Boundary + +- **Explorer is local-only.** Use `explorer` only for mapping files, directories, symbols, dependencies, configuration, and edit points that already exist inside the current repository/worktree. +- **Do not use `explorer` for external research.** Repository discovery on GitHub, upstream project behavior, package/library docs, web content, or competitor/tool comparisons belong to `researcher` or direct Lead research tools (`gh`, `webfetch`, docs lookup). +- **Do not mix local and external discovery in one explorer prompt.** If a task needs both, split it explicitly: + 1. `explorer` → local file map only + 2. `researcher` or Lead tools → external behavior/references only + 3. Lead → synthesize the results +- Explorer outputs should stay concrete: local file paths, likely edit points, dependency chains, and risks inside this repo only. + ## Parallelization - **Always parallelize independent work.** Any tool calls that do not depend on each other's output must be issued in the same message as parallel calls — never sequentially. This applies to bash commands, file reads, and subagent delegations alike. diff --git a/.config/opencode/agents/explorer.md b/.config/opencode/agents/explorer.md index 89308ae..28d68f5 100644 --- a/.config/opencode/agents/explorer.md +++ b/.config/opencode/agents/explorer.md @@ -9,6 +9,7 @@ permission: webfetch: deny websearch: deny codesearch: deny + playwright_*: deny permalink: opencode-config/agents/explorer --- @@ -47,4 +48,5 @@ DEPENDENCIES: RISKS: - -``` \ No newline at end of file +``` + diff --git a/.config/opencode/plugins/tmux-panes.ts b/.config/opencode/plugins/tmux-panes.ts index a77b476..5d9d367 100644 --- a/.config/opencode/plugins/tmux-panes.ts +++ b/.config/opencode/plugins/tmux-panes.ts @@ -18,6 +18,24 @@ import { spawn } from "bun" 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 {} @@ -28,6 +46,64 @@ const plugin: Plugin = async (ctx) => { // 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 }) => { @@ -36,66 +112,77 @@ const plugin: Plugin = async (ctx) => { 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 + const sessionId: string = info.id + if (sessions.has(sessionId)) return - const cmd = `opencode attach ${serverUrl} --session ${sessionId}` + await enqueuePaneOp(async () => { + if (sessions.has(sessionId)) return - 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 cmd = `opencode attach ${serverUrl} --session ${sessionId}` + let args: string[] - const proc = spawn(args, { stdout: "pipe", stderr: "pipe" }) - const paneId = (await new Response(proc.stdout).text()).trim() - const exitCode = await proc.exited - if (exitCode === 0 && paneId) { - sessions.set(sessionId, paneId) - rightColumnPanes.push(paneId) - } + 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 - 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) - } + 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)) } }, }