#13261 · @tomjw64 · opened Feb 12, 2026 at 5:02 AM UTC · last updated Mar 21, 2026 at 9:53 PM UTC

feat(opencode): Support background subagents

appfeat
65
+210159 files

Score breakdown

Impact

9.0

Clarity

8.0

Urgency

4.0

Ease Of Review

8.0

Guidelines

8.0

Readiness

5.0

Size

4.0

Trust

5.0

Traction

7.0

Summary

This PR introduces support for asynchronous background subagents, allowing the main agent to remain interactive while subtasks run. It addresses community interest in concurrency by calling SessionPrompt.prompt async and emitting a BackgroundTaskCompleted event. The PR includes detailed verification steps.

Open in GitHub

Description

Issue for this PR

Closes #5887

Type of change

  • [ ] Bug fix
  • [x] New feature
  • [ ] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

The goal of this PR is to support fire-and-forget/async/background tasks in my best approximation of how Claude Code handles it.

This is done by calling SessionPrompt.prompt async and emitting a new BackgroundTaskCompleted event when the promise resolves. The new SessionBackground component listens for these events (and the idle status event) and takes care of flushing the queue of finished task information to the parent session and forcing it to take it a turn with the results of the completed task(s).

This attempts to be a bit more minimal than https://github.com/anomalyco/opencode/pull/7206 to increase the chances of acceptance. I am a first-time contributor to opencode and I almost certainly overlooked something here. I'm happy to receive any suggestions or review.

How did you verify your code works?

I opened opencode and entered the following prompt:

Run 4 general background subagents in parallel. Each one should use bash to
sleep for an amount of time and then print out a random number. Have them sleep
for 10, 18, 18.2, and 18.2 seconds, respectively. Then print out a chart to be
updated as each task notifies you of its completion.

And verified that the agent (tested with Kimi K2.5 Free):

  • Could handle additional user interaction before the first subagent completed.
  • Woke after the first subagent completed to update the chart.
  • Woke a second time after the second subagent completed to update the chart.
  • Woke a third and final time after the third and fourth subagents completed (to test that we buffer the results of two completions but still only give the primary agent a single turn once idled again)

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

If you do not follow this template your PR will be automatically rejected.

Linked Issues

#5887 [feat] True Async/Background Sub-Agent Delegation

View issue

Comments

PR comments

tomjw64

Received some review expressing some concern over task cancellation:

  1. subagents prompts not cancelled on session delete
  2. no way of individually cancelling a session in the TUI

We can address (1) in this PR (update: done), but I will open a separate issue for individual issue cancellation (update: done), so that background tasks can be cancelled without e.g. closing out the entire app.

Changed Files

packages/opencode/src/project/bootstrap.ts

+20
@@ -11,6 +11,7 @@ import { Command } from "../command"
import { Instance } from "./instance"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { SessionBackground } from "../session/background"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
@@ -22,6 +23,7 @@ export async function InstanceBootstrap() {
FileWatcher.init()
Vcs.init()
Snapshot.init()
SessionBackground.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {

packages/opencode/src/session/background.ts

+990
@@ -0,0 +1,99 @@
import { Bus } from "@/bus"
import { Session } from "./index"
import { SessionStatus } from "./status"
import { Instance } from "@/project/instance"
import { Log } from "@/util/log"
import { SessionID, MessageID, PartID } from "./schema"
import { SessionPrompt } from "./prompt"
export namespace SessionBackground {
const log = Log.create({ service: "session.background" })
const state = Instance.state(
() => {
const wakeable = new Set<SessionID>()
const unsubscribes = [
Bus.subscribe(Session.Event.BackgroundTaskCompleted, async (event) => {
const sessionID = event.properties.sessionID
const task = event.properties.task
await updateTaskMessage(sessionID, task).then(() => {
state().wakeable.add(sessionID)
}).catch((err) => {
log.error("failed to update background task session message", { sessionID, error: err })
})
await wake(sessionID).catch((err) => {
log.error("failed to wake for finished tasks", { sessionID, error: err })
})
}),
Bus.subscribe(SessionStatus.Event.Status, async (event) =>

packages/opencode/src/session/index.ts

+210
@@ -181,6 +181,19 @@ export namespace Session {
})
export type GlobalInfo = z.output<typeof GlobalInfo>
export const BackgroundTask = z.object({
sessionID: SessionID.zod,
result: z.string(),
status: z.enum(["success", "error"]),
description: z.string(),
agent: z.string(),
model: z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
}),
})
export type BackgroundTask = z.output<typeof BackgroundTask>
export const Event = {
Created: BusEvent.define(
"session.created",
@@ -214,6 +227,13 @@ export namespace Session {
error: MessageV2.Assistant.shape.error,
}),
),
BackgroundTaskCompleted: BusEvent.define(
"session.background_task_completed",
z.object({
sessionID: SessionID.zod,
task: BackgroundTask,
}),
),
}
export const create = fn(
@@ -668,6 +688,7 @@ export namespace Session {
for (const child of await children(sessionID)) {
await remove(child.id)
}
SessionPrompt.cancel(sessionID)
await unshare(sessionID).catch(() => {})
// CASCADE delete handles messages and parts automatical

packages/opencode/src/session/message-v2.ts

+10
@@ -219,6 +219,7 @@ export namespace MessageV2 {
})
.optional(),
command: z.string().optional(),
background: z.boolean().optional(),
}).meta({
ref: "SubtaskPart",
})

packages/opencode/src/session/prompt.ts

+10
@@ -406,6 +406,7 @@ export namespace SessionPrompt {
description: task.description,
subagent_type: task.agent,
command: task.command,
background: task.background,
}
await Plugin.trigger(
"tool.execute.before",

packages/opencode/src/session/status.ts

+12
@@ -60,6 +60,7 @@ export namespace SessionStatus {
}
export function set(sessionID: SessionID, status: Info) {
state()[sessionID] = status
Bus.publish(Event.Status, {
sessionID,
status,
@@ -70,8 +71,6 @@ export namespace SessionStatus {
sessionID,
})
delete state()[sessionID]
return
}
state()[sessionID] = status
}
}

packages/opencode/src/tool/task.ts

+5913
@@ -11,6 +11,7 @@ import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Permission } from "@/permission"
import { Bus } from "@/bus"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
@@ -23,6 +24,13 @@ const parameters = z.object({
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
background: z
.boolean()
.default(false)
.describe(
"If true, the task runs in the background and the main agent can continue working. Results will be reported back when done.",
)
.optional(),
})
export const TaskTool = Tool.define("task", async (ctx) => {
@@ -119,21 +127,11 @@ export const TaskTool = Tool.define("task", async (ctx) => {
})
const messageID = MessageID.ascending()
function cancel() {
SessionPrompt.cancel(session.id)
}
ctx.abort.addEventListener("abort", cancel)
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
const promptParts = await SessionPrompt.resolvePromptParts(params.

packages/opencode/src/tool/task.txt

+50
@@ -23,6 +23,11 @@ Usage notes:
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands).
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
Background tasks:
- You can set background=true to run a task asynchronously if it will take a long time, or if you are requested to do so.
- Background tasks wake you upon completion, so you should wait passively and idly for output of background tasks.
- It is forbidden to track completion of tasks yourself via sleeping, or to delegate that tracking to other subagents.
Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above):
<example_agent_descriptions>

packages/sdk/js/src/v2/gen/types.gen.ts

+210
@@ -290,6 +290,7 @@ export type SubtaskPart = {
modelID: string
}
command?: string
background?: boolean
}
export type ReasoningPart = {
@@ -882,6 +883,24 @@ export type EventSessionError = {
}
}
export type EventSessionBackgroundTaskCompleted = {
type: "session.background_task_completed"
properties: {
sessionID: string
task: {
sessionID: string
result: string
status: "success" | "error"
description: string
agent: string
model: {
providerID: string
modelID: string
}
}
}
}
export type EventVcsBranchUpdated = {
type: "vcs.branch.updated"
properties: {
@@ -994,6 +1013,7 @@ export type Event =
| EventSessionDeleted
| EventSessionDiff
| EventSessionError
| EventSessionBackgroundTaskCompleted
| EventVcsBranchUpdated
| EventWorkspaceReady
| EventWorkspaceFailed
@@ -1764,6 +1784,7 @@ export type SubtaskPartInput = {
modelID: string
}
command?: string
background?: boolean
}
export type ProviderAuthMethod = {