This commit is contained in:
alex
2026-03-11 12:49:11 +00:00
parent 5aae551edd
commit cac00d21c3
5 changed files with 152 additions and 53 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

View File

@@ -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.

View File

@@ -9,6 +9,7 @@ permission:
webfetch: deny
websearch: deny
codesearch: deny
playwright_*: deny
permalink: opencode-config/agents/explorer
---
@@ -47,4 +48,5 @@ DEPENDENCIES:
RISKS:
- <risk description>
```
```

View File

@@ -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<void>) => {
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))
}
},
}