Files
bench/manager/core/adapters/opencode/plugin.js
T
istosandClaude Fable 5 f8d1bf252d Launch headless agents with permissions that allow their own contract (task 05)
Work agents were launched under acceptEdits with no Bash allowlist, so
headless runs could edit files but never run tests or commit — the exact
contradiction card 05 documents. Each launch intent (work, act-pr,
review) now carries a permissions.allow list granting exactly what its
prompt demands, delivered through the same generated settings JSON as
the event hooks. Project test/check commands arrive as neutral prefixes
via BOARD_AGENT_COMMANDS; a new opencode adapter renders the same three
stances in its config language as the portability proof. A clean agent
exit with an empty branch no longer advances the card to review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:46:23 +02:00

97 lines
3.2 KiB
JavaScript

// opencode → board bridge (the opencode adapter's event edge).
//
// Subscribes to opencode's event bus and tool hooks, translates them into
// the board's NORMALIZED event schema (v1) — the fixed contract every
// adapter speaks, documented in core/adapters/README.md — and POSTs them
// to /api/events with the BOARD_* env forwarded.
//
// Coverage is deliberately coarse for now (session/idle/end plus one
// event per tool call, kinds mapped from the tool name); refine kinds
// before inventing new ones. Fails silently and fast: a session must
// never slow down or break because the board isn't running.
import fs from "node:fs"
import path from "node:path"
function boardPort(directory) {
if (process.env.BOARD_PORT) return process.env.BOARD_PORT
try {
const env = fs.readFileSync(
path.join(directory, ".task-manager/manager/local/.env"), "utf8")
for (const line of env.split("\n")) {
const m = line.match(/^\s*BOARD_PORT\s*=\s*['"]?(\d+)/)
if (m) return m[1]
}
} catch {}
return "26071"
}
// opencode tool name → normalized kind (anything else: "command").
const KINDS = {
read: "read", list: "read", glob: "search", grep: "search",
edit: "edit", write: "edit", patch: "edit",
bash: "command", webfetch: "web", todowrite: "plan", task: "subagent",
}
export const BenchBoard = async ({ directory }) => {
const port = boardPort(directory)
const seen = new Set()
const post = async (session, body) => {
try {
await fetch(`http://127.0.0.1:${port}/api/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
v: 1,
session: session || "unknown",
agent: process.env.BOARD_AGENT_ID,
task: process.env.BOARD_TASK,
...body,
}),
signal: AbortSignal.timeout(1000),
})
} catch {}
}
// The bus has no single "session started" moment we can rely on across
// versions, so announce a session the first time we see its id.
const announce = (id) => {
if (!id || seen.has(id)) return
seen.add(id)
post(id, { kind: "session", summary: "session started" })
}
return {
event: async ({ event }) => {
const id = event?.properties?.sessionID || event?.properties?.info?.id
if (event?.type === "session.idle") {
announce(id)
post(id, { kind: "idle", summary: "finished responding — idle" })
} else if (event?.type === "session.deleted") {
post(id, { kind: "end", summary: "session ended" })
} else if (event?.type === "session.updated") {
announce(id)
}
},
"tool.execute.before": async (input) => {
announce(input?.sessionID)
post(input?.sessionID, {
kind: "command", running: true,
summary: `running: ${input?.tool || "tool"}`,
})
},
"tool.execute.after": async (input, output) => {
const tool = input?.tool || "tool"
const kind = KINDS[tool] || "command"
const title = typeof output?.title === "string" ? output.title : ""
const body = {
kind,
summary: `${tool}${title ? `: ${title}` : ""}`.slice(0, 120),
}
if ((kind === "edit" || kind === "read") && title) body.file = title
post(input?.sessionID, body)
},
}
}