import * as fs from "node:fs/promises" import * as os from "node:os" import * as path from "node:path" import { tool, type Config, type Hooks, type PluginInput, type PluginModule, type PluginOptions } from "@opencode-ai/plugin" type Msg = { info: { role: string }; parts: Array<{ type: string; text?: string }> } type ModelRef = { providerID: string; modelID: string; ref: string } type DelegationStatus = "running" | "complete" | "error" | "cancelled" | "timeout" export type ForkPluginInput = PluginInput & { listSessionMessages?: (input: { sessionID: string }) => Promise } export type SubagentsPluginOptions = PluginOptions & { fork?: { default?: boolean reviewDefault?: boolean agents?: Record } model?: { agents?: Record } background?: { timeoutMs?: number allowWriteCapable?: boolean } transcript?: { maxChars?: number redact?: boolean } } const SVC = "plugin.opencode-forking-agents" const DEFAULT_CAP = 400_000 const DEFAULT_TIMEOUT = 15 * 60 * 1000 function plog( client: PluginInput["client"], level: "debug" | "info" | "error" | "warn", message: string, extra?: Record, ) { void client.app.log({ body: { service: SVC, level, message, extra } }).catch(() => {}) } function asOptions(options?: PluginOptions): SubagentsPluginOptions { return (options ?? {}) as SubagentsPluginOptions } function clip(txt: string, cap: number) { if (txt.length <= cap) return txt return `${txt.slice(0, cap)}\n\n[truncated ${txt.length - cap} chars]` } function redact(txt: string) { return txt .replace(/(api[_-]?key|token|secret|password)\s*[:=]\s*[^\s]+/gi, "$1=[REDACTED]") .replace(/\b(sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g, "[REDACTED_TOKEN]") } function line(msg: Msg) { const chunks = msg.parts .filter((p) => p.type === "text" && typeof p.text === "string" && p.text.trim()) .map((p) => p.text!) if (chunks.length === 0) return "" return `${msg.info.role.toUpperCase()}:\n${chunks.join("\n\n")}\n` } function transcript(msgs: Msg[], opts: { maxChars: number; redact: boolean }) { const text = clip(msgs.map(line).filter(Boolean).join("\n"), opts.maxChars) return opts.redact ? redact(text) : text } function promptWithTask(prompt: string) { return `\n${prompt}\n` } function promptWithParent(parentTranscript: string, prompt: string) { if (!parentTranscript.trim()) return promptWithTask(prompt) return `\n${parentTranscript}\n\n\n${promptWithTask(prompt)}` } function isReviewAgent(agent: string) { return agent === "review" || agent === "code-review" || agent.includes("review") } function resolveFork(agent: string, explicit: boolean | undefined, options: SubagentsPluginOptions) { if (explicit !== undefined) return explicit const agentDefault = options.fork?.agents?.[agent] if (agentDefault !== undefined) return agentDefault if (isReviewAgent(agent)) return options.fork?.reviewDefault ?? false return options.fork?.default ?? true } function parseModelString(model: string): ModelRef { const index = model.indexOf("/") if (index <= 0 || index === model.length - 1) throw new Error(`Model must use provider/model format: ${model}`) const providerID = model.slice(0, index) const modelID = model.slice(index + 1) return { providerID, modelID, ref: `${providerID}/${modelID}` } } function normalizeConfiguredModel(value: string | { providerID: string; modelID: string } | undefined): ModelRef | undefined { if (!value) return undefined if (typeof value === "string") return parseModelString(value) return { providerID: value.providerID, modelID: value.modelID, ref: `${value.providerID}/${value.modelID}` } } function resolveRequestedModel(args: { model?: string; providerID?: string; modelID?: string }, agent: string, options: SubagentsPluginOptions) { const hasExplicitPair = args.providerID !== undefined || args.modelID !== undefined if (hasExplicitPair) { if (!args.providerID || !args.modelID) throw new Error("providerID and modelID must be supplied together") const pair = { providerID: args.providerID, modelID: args.modelID, ref: `${args.providerID}/${args.modelID}` } if (args.model) { const parsed = parseModelString(args.model) if (parsed.ref !== pair.ref) throw new Error(`Conflicting model arguments: ${args.model} and ${pair.ref}`) } return pair } if (args.model) return parseModelString(args.model) return normalizeConfiguredModel(options.model?.agents?.[agent]) } async function providerData(client: PluginInput["client"], directory: string) { const c = client as any if (typeof c.provider?.list === "function") { const result = await c.provider.list({ query: { directory }, throwOnError: true }) return { providers: result.data.all ?? [], defaults: result.data.default ?? {}, connected: result.data.connected as string[] | undefined } } if (typeof c.config?.providers === "function") { const result = await c.config.providers({ query: { directory }, throwOnError: true }) return { providers: result.data.providers ?? [], defaults: result.data.default ?? {}, connected: undefined as string[] | undefined } } return { providers: [], defaults: {}, connected: undefined as string[] | undefined } } async function validateModel(client: PluginInput["client"], directory: string, model: ModelRef | undefined) { if (!model) return undefined const data = await providerData(client, directory) const provider = data.providers.find((p: any) => p.id === model.providerID) if (!provider) throw new Error(`Provider "${model.providerID}" not found. Use subagents_models to list available models.`) if (data.connected && !data.connected.includes(model.providerID)) throw new Error(`Provider "${model.providerID}" is not connected.`) if (!provider.models?.[model.modelID]) { const available = Object.keys(provider.models ?? {}).slice(0, 20).map((id) => `${model.providerID}/${id}`).join("\n") throw new Error(`Model "${model.ref}" not found.${available ? `\n\nAvailable models for ${model.providerID}:\n${available}` : ""}`) } return model } function extractText(result: any) { const parts = result?.data?.parts ?? result?.data?.parts ?? [] return parts.filter((p: any) => p.type === "text" && typeof p.text === "string").map((p: any) => p.text).join("\n") } function textFromMessages(messages: Msg[]) { const assistant = [...messages].reverse().find((m) => m.info.role === "assistant") if (!assistant) return "" return assistant.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("\n") } function readableId() { return `task-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` } type DelegationRecord = { id: string status: DelegationStatus agent: string model?: ModelRef fork: boolean parentSessionID: string childSessionID: string prompt: string filePath: string createdAt: Date completedAt?: Date result?: string error?: string timer?: ReturnType } function markdownArtifact(record: DelegationRecord, body: string) { return [ `# ${record.id}`, "", `**Agent:** ${record.agent}`, record.model ? `**Model:** ${record.model.ref}` : "**Model:** inherit", `**Fork:** ${record.fork}`, `**Status:** ${record.status}`, `**Parent session:** ${record.parentSessionID}`, `**Child session:** ${record.childSessionID}`, `**Started:** ${record.createdAt.toISOString()}`, `**Completed:** ${record.completedAt?.toISOString() ?? "N/A"}`, record.error ? `**Error:** ${record.error}` : "", "", "---", "", body, ].filter(Boolean).join("\n") } async function ensureDir(input: PluginInput, sessionID: string) { const safeProject = Buffer.from(input.directory).toString("base64url").slice(0, 32) const dir = path.join(os.homedir(), ".local", "share", "opencode", "delegations", safeProject, sessionID) await fs.mkdir(dir, { recursive: true }) return dir } async function isReadOnlyAgent(client: PluginInput["client"], directory: string, agent: string) { const denied = (entry: unknown) => entry === "deny" || (typeof entry === "object" && entry !== null && (entry as Record)["*"] === "deny") const c = client as any if (typeof c.app?.agents === "function") { const result = await c.app.agents({ query: { directory }, throwOnError: true }) const resolved = (result.data ?? []).find((item: any) => item.name === agent) if (resolved?.permission) return denied(resolved.permission.edit) && denied(resolved.permission.bash) } const result = await c.config?.get?.({ query: { directory }, throwOnError: true }) const permission = result?.data?.agent?.[agent]?.permission ?? {} return denied(permission.edit) && denied(permission.bash) } export async function server(input: PluginInput, rawOptions?: PluginOptions): Promise { if (process.env.OPENCODE_DISABLE_FORK_SUBAGENT_PLUGIN === "1") return {} const options = asOptions(rawOptions) const { client, directory } = input const listSessionMessages = (input as ForkPluginInput).listSessionMessages const delegations = new Map() async function parentMessages(sessionID: string) { if (typeof listSessionMessages === "function") return listSessionMessages({ sessionID }) return client.session.messages({ path: { id: sessionID }, query: { directory }, throwOnError: true }).then((x: any) => x.data as Msg[]) } async function buildPrompt(sessionID: string, prompt: string, fork: boolean) { if (!fork) return promptWithTask(prompt) try { const t = transcript(await parentMessages(sessionID), { maxChars: options.transcript?.maxChars ?? DEFAULT_CAP, redact: options.transcript?.redact ?? false, }) return promptWithParent(t, prompt) } catch (err) { plog(client, "warn", "parent transcript unavailable", { error: err instanceof Error ? err.message : String(err), sessionID }) return promptWithTask(prompt) } } async function createChild(parentSessionID: string, title: string) { const result = await client.session.create({ body: { title, parentID: parentSessionID }, query: { directory }, throwOnError: true } as any) if (!(result as any).data?.id) throw new Error("Failed to create child session") return (result as any).data.id as string } async function runPrompt(inputArgs: { parentSessionID: string prompt: string agent: string fork?: boolean model?: string providerID?: string modelID?: string title: string background: boolean }) { const fork = resolveFork(inputArgs.agent, inputArgs.fork, options) const model = await validateModel(client, directory, resolveRequestedModel(inputArgs, inputArgs.agent, options)) const finalPrompt = await buildPrompt(inputArgs.parentSessionID, inputArgs.prompt, fork) const childSessionID = await createChild(inputArgs.parentSessionID, inputArgs.title) const body: Record = { agent: inputArgs.agent, parts: [{ type: "text", text: finalPrompt }], tools: { subagents_run: false, subagents_delegate: false }, } if (model) body.model = { providerID: model.providerID, modelID: model.modelID } return { childSessionID, finalPrompt, fork, model, body } } async function finalize(record: DelegationRecord, status: DelegationStatus, result: string, error?: string) { if (record.status !== "running") return if (record.timer) clearTimeout(record.timer) record.status = status record.result = result record.error = error record.completedAt = new Date() await fs.writeFile(record.filePath, markdownArtifact(record, result), "utf8") try { await client.session.prompt({ path: { id: record.parentSessionID }, query: { directory }, body: { noReply: true, parts: [{ type: "text", text: `\n${record.id}\n${record.status}\nUse subagents_read with id ${record.id}\n` }], }, } as any) } catch (err) { plog(client, "warn", "delegation notification failed", { id: record.id, error: err instanceof Error ? err.message : String(err) }) } } async function finishFromSession(sessionID: string) { const record = [...delegations.values()].find((d) => d.childSessionID === sessionID && d.status === "running") if (!record) return try { const messages = await client.session.messages({ path: { id: sessionID }, query: { directory }, throwOnError: true }).then((x: any) => x.data as Msg[]) await finalize(record, "complete", textFromMessages(messages) || "Subagent completed without text output.") } catch (err) { await finalize(record, "error", `Error: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err.message : String(err)) } } return { tool: { subagents_run: tool({ description: "Run a subagent synchronously. Supports forked parent transcript context and explicit model selection.", args: { prompt: tool.schema.string().describe("Task prompt for the subagent."), agent: tool.schema.string().describe("Subagent name, such as explore or code-review."), fork: tool.schema.boolean().optional().describe("Whether to prepend the parent session transcript. Defaults from plugin config."), model: tool.schema.string().optional().describe("Optional provider/model string."), providerID: tool.schema.string().optional().describe("Optional explicit provider ID. Must be paired with modelID."), modelID: tool.schema.string().optional().describe("Optional explicit model ID. Must be paired with providerID."), }, async execute(args, ctx) { const run = await runPrompt({ ...args, parentSessionID: ctx.sessionID, title: `Subagent: ${args.agent}`, background: false }) const response = await client.session.prompt({ path: { id: run.childSessionID }, query: { directory }, body: run.body, throwOnError: true } as any) const text = extractText(response) return [ `Agent: ${args.agent}`, `Model: ${run.model?.ref ?? "inherit"}`, `Fork: ${run.fork}`, `Session: ${run.childSessionID}`, "", text || "Subagent completed without text output.", ].join("\n") }, }), subagents_delegate: tool({ description: "Run a read-only subagent in the background. Results are persisted and retrievable with subagents_read.", args: { prompt: tool.schema.string().describe("Task prompt for the subagent."), agent: tool.schema.string().describe("Subagent name, such as explore or code-review."), fork: tool.schema.boolean().optional().describe("Whether to prepend the parent session transcript. Defaults from plugin config."), model: tool.schema.string().optional().describe("Optional provider/model string."), providerID: tool.schema.string().optional().describe("Optional explicit provider ID. Must be paired with modelID."), modelID: tool.schema.string().optional().describe("Optional explicit model ID. Must be paired with providerID."), }, async execute(args, ctx) { if (!options.background?.allowWriteCapable) { const readOnly = await isReadOnlyAgent(client, directory, args.agent).catch(() => false) if (!readOnly) return `Background subagent "${args.agent}" is not read-only. Use subagents_run or configure background.allowWriteCapable explicitly.` } const run = await runPrompt({ ...args, parentSessionID: ctx.sessionID, title: `Delegation: ${args.agent}`, background: true }) const id = readableId() const dir = await ensureDir(input, ctx.sessionID) const record: DelegationRecord = { id, status: "running", agent: args.agent, model: run.model, fork: run.fork, parentSessionID: ctx.sessionID, childSessionID: run.childSessionID, prompt: args.prompt, filePath: path.join(dir, `${id}.md`), createdAt: new Date(), } record.timer = setTimeout(() => { void client.session.abort({ path: { id: record.childSessionID }, query: { directory }, throwOnError: true } as any) .catch(() => client.session.delete({ path: { id: record.childSessionID }, query: { directory }, throwOnError: true } as any).catch(() => undefined)) .finally(() => void finalize(record, "timeout", `Delegation timed out after ${options.background?.timeoutMs ?? DEFAULT_TIMEOUT}ms`)) }, options.background?.timeoutMs ?? DEFAULT_TIMEOUT) delegations.set(id, record) client.session.prompt({ path: { id: run.childSessionID }, query: { directory }, body: run.body, throwOnError: true } as any) .then((response: unknown) => { void finalize(record, "complete", extractText(response) || "Subagent completed without text output.") }) .catch((err: unknown) => { void finalize(record, "error", `Error: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err.message : String(err)) }) return `Delegation started: ${id}\nAgent: ${args.agent}\nModel: ${run.model?.ref ?? "inherit"}\nFork: ${run.fork}\nUse subagents_read with id ${id} after notification.` }, }), subagents_read: tool({ description: "Read a background subagent result by ID.", args: { id: tool.schema.string().describe("Delegation ID.") }, async execute(args) { const record = delegations.get(args.id) if (!record) return `Delegation not found: ${args.id}` try { return await fs.readFile(record.filePath, "utf8") } catch { return `Delegation ${record.id} is ${record.status}. Result is not persisted yet.` } }, }), subagents_list: tool({ description: "List background subagent delegations for this plugin instance.", args: {}, async execute() { if (delegations.size === 0) return "No delegations." return [...delegations.values()].map((d) => `- ${d.id} [${d.status}] ${d.agent} model=${d.model?.ref ?? "inherit"} fork=${d.fork}`).join("\n") }, }), subagents_cancel: tool({ description: "Cancel a running background subagent delegation.", args: { id: tool.schema.string().describe("Delegation ID.") }, async execute(args) { const record = delegations.get(args.id) if (!record) return `Delegation not found: ${args.id}` if (record.status !== "running") return `Delegation ${args.id} is already ${record.status}.` try { await client.session.delete({ path: { id: record.childSessionID }, query: { directory }, throwOnError: true } as any) } catch {} await finalize(record, "cancelled", "Delegation cancelled.") return `Cancelled ${args.id}.` }, }), subagents_models: tool({ description: "List available subagent models as providerID/modelID plus explicit providerID and modelID fields.", args: { agent: tool.schema.string().optional().describe("Optional agent name to show configured model defaults.") }, async execute(args) { const data = await providerData(client, directory) const lines = ["## Available Subagent Models", ""] if (args.agent) { const configured = normalizeConfiguredModel(options.model?.agents?.[args.agent]) lines.push(`Agent: ${args.agent}`) lines.push(`Configured model: ${configured?.ref ?? "inherit"}`) lines.push("") } for (const provider of data.providers as any[]) { const connected = data.connected ? data.connected.includes(provider.id) : true if (!connected) continue lines.push(`### ${provider.name ?? provider.id} (${provider.id})`) for (const [modelID, model] of Object.entries(provider.models ?? {})) { const m = model as any const marker = data.defaults?.[provider.id] === modelID ? " default" : "" const context = m.limit?.context ? ` context=${m.limit.context}` : "" const output = m.limit?.output ? ` output=${m.limit.output}` : "" const tools = m.capabilities?.toolcall ?? m.tool_call lines.push(`- ${provider.id}/${modelID}${marker} providerID=${provider.id} modelID=${modelID}${context}${output} tools=${tools === false ? "no" : "yes"}`) } lines.push("") } return lines.join("\n").trim() }, }), }, event: async ({ event }: { event: any }) => { if (event.type === "session.idle" && event.properties?.sessionID) await finishFromSession(event.properties.sessionID) if (event.type === "session.status" && event.properties?.status?.type === "idle" && event.properties?.sessionID) await finishFromSession(event.properties.sessionID) }, "experimental.session.compacting": async (_input, output) => { const active = [...delegations.values()].filter((d) => d.status === "running") const unread = [...delegations.values()].filter((d) => d.status !== "running").slice(-10) if (active.length === 0 && unread.length === 0) return output.context.push([ "", ...active.map((d) => `Running: ${d.id} agent=${d.agent} model=${d.model?.ref ?? "inherit"} fork=${d.fork}`), ...unread.map((d) => `Completed: ${d.id} status=${d.status}; use subagents_read with this id.`), "", ].join("\n")) }, } } const plugin: PluginModule = { id: "opencode-forking-agents", server, } export default plugin