feat(tui): allow backgrounding synchronous subagents (#30488)

This commit is contained in:
Kit Langton 2026-06-04 23:40:52 -04:00 committed by GitHub
parent 8c0edca175
commit 3003867c25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 527 additions and 35 deletions

View File

@ -25,6 +25,9 @@ type Active = {
pending: number
next: number
output?: { sequence: number; text: string }
tail: Deferred.Deferred<void>
promoted: Deferred.Deferred<Info>
onPromote?: Effect.Effect<void>
}
type State = {
@ -38,15 +41,31 @@ type FinishResult = {
scope?: Scope.Closeable
}
type PromoteResult = {
info?: Info
promoted?: Deferred.Deferred<Info>
onPromote?: Effect.Effect<void>
}
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
type ExtendResult = { extended: false } | { extended: true; scope: Scope.Closeable; token: object; sequence: number }
type ExtendResult =
| { extended: false }
| {
extended: true
previous: Deferred.Deferred<void>
scope: Scope.Closeable
tail: Deferred.Deferred<void>
token: object
sequence: number
}
export type StartInput = {
id?: string
type: string
title?: string
metadata?: Record<string, unknown>
onPromote?: Effect.Effect<void>
run: Effect.Effect<string, unknown>
}
@ -71,6 +90,8 @@ export interface Interface {
readonly start: (input: StartInput) => Effect.Effect<Info>
readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
readonly waitForPromotion: (id: string) => Effect.Effect<Info>
readonly promote: (id: string) => Effect.Effect<Info | undefined>
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
}
@ -128,6 +149,7 @@ export const make = Effect.gen(function* () {
: "error"
const next = {
...job,
onPromote: undefined,
pending: 0,
output,
info: {
@ -182,6 +204,8 @@ export const make = Effect.gen(function* () {
const id = input.id ?? Identifier.ascending("job")
const started_at = yield* Clock.currentTimeMillis
const done = yield* Deferred.make<Info>()
const promoted = yield* Deferred.make<Info>()
const tail = yield* Deferred.make<void>()
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs) {
@ -205,6 +229,9 @@ export const make = Effect.gen(function* () {
token,
pending: 1,
next: 1,
tail,
promoted,
onPromote: input.onPromote,
}
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
StartResult,
@ -212,7 +239,14 @@ export const make = Effect.gen(function* () {
]
}),
)
if ("scope" in result) yield* fork(result.scope, id, result.token, 0, restore(input.run))
if ("scope" in result)
yield* fork(
result.scope,
id,
result.token,
0,
restore(input.run).pipe(Effect.ensuring(Deferred.succeed(tail, undefined))),
)
return result.info
}),
)
@ -221,23 +255,34 @@ export const make = Effect.gen(function* () {
const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const tail = yield* Deferred.make<void>()
const result = yield* SynchronizedRef.modify(
state.jobs,
(jobs): readonly [ExtendResult, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job || job.info.status !== "running") return [{ extended: false }, jobs]
return [
{ extended: true, scope: job.scope, token: job.token, sequence: job.next },
{ extended: true, previous: job.tail, scope: job.scope, tail, token: job.token, sequence: job.next },
new Map(jobs).set(input.id, {
...job,
pending: job.pending + 1,
next: job.next + 1,
tail,
}),
]
},
)
if (!result.extended) return false
yield* fork(result.scope, input.id, result.token, result.sequence, restore(input.run))
yield* fork(
result.scope,
input.id,
result.token,
result.sequence,
Deferred.await(result.previous).pipe(
Effect.andThen(restore(input.run)),
Effect.ensuring(Deferred.succeed(result.tail, undefined)),
),
)
return true
}),
)
@ -254,6 +299,40 @@ export const make = Effect.gen(function* () {
return { info: snapshot(job), timedOut: true }
})
const waitForPromotion: Interface["waitForPromotion"] = Effect.fn("BackgroundJob.waitForPromotion")(function* (id) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
if (!job || job.info.status !== "running") return yield* Effect.never
if (job.info.metadata?.background === true) return snapshot(job)
return yield* Deferred.await(job.promoted)
})
const promote: Interface["promote"] = Effect.fn("BackgroundJob.promote")(function* (id) {
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs) {
const job = jobs.get(id)
if (!job || job.info.status !== "running") return [{}, jobs] as readonly [PromoteResult, Map<string, Active>]
if (job.info.metadata?.background === true)
return [{ info: snapshot(job) }, jobs] as readonly [PromoteResult, Map<string, Active>]
const next = {
...job,
onPromote: undefined,
info: {
...job.info,
metadata: { ...job.info.metadata, background: true },
},
}
return [
{ info: snapshot(next), onPromote: job.onPromote, promoted: job.promoted },
new Map(jobs).set(id, next),
] as readonly [PromoteResult, Map<string, Active>]
}),
)
if (result.info && result.promoted) yield* Deferred.succeed(result.promoted, result.info).pipe(Effect.ignore)
if (result.onPromote) yield* result.onPromote.pipe(Effect.ignore)
return result.info
})
const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
@ -262,6 +341,7 @@ export const make = Effect.gen(function* () {
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = {
...job,
onPromote: undefined,
pending: 0,
info: {
...job.info,
@ -276,7 +356,7 @@ export const make = Effect.gen(function* () {
return result.info
})
return Service.of({ list, get, start, extend, wait, cancel })
return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel })
})
export const layer = Layer.effect(Service, make)

View File

@ -12,12 +12,13 @@ export const tmpdir = async () => {
}
}
async function remove(dir: string, retries = 10): Promise<void> {
async function remove(dir: string, retries = 30): Promise<void> {
try {
await fs.rm(dir, { recursive: true, force: true })
} catch (error) {
if (retries === 0 || !error || typeof error !== "object" || !("code" in error) || error.code !== "EBUSY")
throw error
Bun.gc(true)
await Bun.sleep(100)
return remove(dir, retries - 1)
}

View File

@ -24,6 +24,8 @@ export const layer = Layer.effect(
start: (input) => InstanceState.useEffect(state, (jobs) => jobs.start(input)),
extend: (input) => InstanceState.useEffect(state, (jobs) => jobs.extend(input)),
wait: (input) => InstanceState.useEffect(state, (jobs) => jobs.wait(input)),
waitForPromotion: (id) => InstanceState.useEffect(state, (jobs) => jobs.waitForPromotion(id)),
promote: (id) => InstanceState.useEffect(state, (jobs) => jobs.promote(id)),
cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)),
})
}),

View File

@ -816,6 +816,7 @@ export const RunCommand = effectCmd({
initialInput,
createSession: createFreshSession,
thinking,
backgroundSubagents: flags.experimentalBackgroundSubagents,
demo: args.demo,
})
} catch (error) {
@ -849,6 +850,7 @@ export const RunCommand = effectCmd({
files,
initialInput,
thinking,
backgroundSubagents: flags.experimentalBackgroundSubagents,
demo: args.demo,
})
} catch (error) {

View File

@ -84,6 +84,7 @@ type RunFooterOptions = {
theme: RunTheme
keymap: Keymap<Renderable, KeyEvent>
tuiConfig: RunTuiConfig
backgroundSubagents: boolean
diffStyle: RunDiffStyle
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
@ -92,6 +93,7 @@ type RunFooterOptions = {
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onExit?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
treeSitterClient?: TreeSitterClient
@ -294,6 +296,7 @@ export class RunFooter implements FooterApi {
theme: options.theme,
diffStyle: options.diffStyle,
tuiConfig: options.tuiConfig,
backgroundSubagents: options.backgroundSubagents,
history: options.history,
agent: options.agentLabel,
onSubmit: footer.handlePrompt,
@ -302,6 +305,7 @@ export class RunFooter implements FooterApi {
onQuestionReject: footer.handleQuestionReject,
onCycle: footer.handleCycle,
onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground,
onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit,
onRequestExit: footer.setRequestExitHandler,

View File

@ -86,6 +86,7 @@ type RunFooterViewProps = {
theme?: RunTheme
diffStyle?: RunDiffStyle
tuiConfig: RunTuiConfig
backgroundSubagents: boolean
history?: RunPrompt[]
agent: string
onSubmit: (input: RunPrompt) => boolean
@ -94,6 +95,7 @@ type RunFooterViewProps = {
onQuestionReject: (input: QuestionReject) => void | Promise<void>
onCycle: () => void
onInterrupt: () => boolean
onBackground?: () => void
onInputClear: () => void
onExitRequest?: () => boolean
onRequestExit?: (fn: (() => boolean) | undefined) => void
@ -158,6 +160,9 @@ export function RunFooterView(props: RunFooterViewProps) {
label: count === 1 ? "agent" : "agents",
}
})
const foregroundSubagents = createMemo(
() => props.backgroundSubagents && tabs().some((item) => item.status === "running" && !item.background),
)
const queuedIndicator = createMemo(() => {
const count = queuedPrompts().length
if (count === 0) return
@ -214,6 +219,15 @@ export function RunFooterView(props: RunFooterViewProps) {
props.tuiConfig,
) ?? "",
)
const backgroundShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.background"] })
.get("session.background"),
props.tuiConfig,
) ?? "",
)
const hints = createMemo(() => hintFlags(term().width))
const busy = createMemo(() => props.state().phase === "running")
const armed = createMemo(() => props.state().interrupt > 0)
@ -375,6 +389,21 @@ export function RunFooterView(props: RunFooterViewProps) {
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(),
priority: 1,
commands: [
{
name: "session.background",
title: "Background subagents",
category: "Session",
run: () => props.onBackground?.(),
},
],
bindings: props.tuiConfig.keybinds.get("session.background"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
@ -774,6 +803,13 @@ export function RunFooterView(props: RunFooterViewProps) {
</text>
)}
</Show>
<Show when={foregroundSubagents() && backgroundShortcut()}>
<text id="run-direct-footer-background-label" fg={theme().text} wrapMode="none" truncate>
<span style={{ fg: theme().highlight }}> </span>
<span style={{ fg: theme().highlight }}>{backgroundShortcut()}</span>{" "}
<span style={{ fg: theme().muted }}>background</span>
</text>
</Show>
<Show when={queuedIndicator()}>
{(info) => (
<text id="run-direct-footer-queued-label" fg={theme().text} wrapMode="none" truncate>

View File

@ -63,6 +63,7 @@ export type LifecycleInput = {
model: RunInput["model"]
variant: string | undefined
tuiConfig: RunTuiConfig
backgroundSubagents: boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void>
@ -70,6 +71,7 @@ export type LifecycleInput = {
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
}
@ -237,6 +239,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
wrote,
keymap,
tuiConfig: input.tuiConfig,
backgroundSubagents: input.backgroundSubagents,
diffStyle: input.tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply,
onQuestionReply: input.onQuestionReply,
@ -245,6 +248,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onModelSelect: input.onModelSelect,
onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt,
onBackground: input.onBackground,
onSubagentSelect: input.onSubagentSelect,
})

View File

@ -52,6 +52,7 @@ type RunRuntimeInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
backgroundSubagents: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
@ -70,6 +71,7 @@ type RunLocalInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
backgroundSubagents: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
@ -253,6 +255,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
model: state.model,
variant: state.activeVariant,
tuiConfig,
backgroundSubagents: input.backgroundSubagents,
onPermissionReply: async (next) => {
if (state.demo?.permission(next)) {
return
@ -372,6 +375,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
state.aborting = false
})
},
onBackground: () => {
if (!hasSession(input, state)) return
void ctx.sdk.experimental.session.background({ sessionID: state.sessionID }).catch(() => {})
},
onSubagentSelect: (sessionID) => {
state.selectSubagent?.(sessionID)
log?.write("subagent.select", {
@ -794,6 +801,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
@ -848,6 +856,7 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,

View File

@ -83,6 +83,7 @@ export function sameSubagentTab(a: FooterSubagentTab | undefined, b: FooterSubag
a.label === b.label &&
a.description === b.description &&
a.status === b.status &&
a.background === b.background &&
a.title === b.title &&
a.toolCalls === b.toolCalls &&
a.lastUpdatedAt === b.lastUpdatedAt
@ -303,6 +304,7 @@ function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
label,
description,
status,
background: metadata(part, "background") === true,
title: stateTitle(part),
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
lastUpdatedAt: stateUpdatedAt(part),

View File

@ -68,6 +68,7 @@ export type RunInput = {
files: RunFilePart[]
initialInput?: string
thinking: boolean
backgroundSubagents: boolean
demo?: boolean
}
@ -184,6 +185,7 @@ export type FooterSubagentTab = {
label: string
description: string
status: "running" | "completed" | "error"
background?: boolean
title?: string
toolCalls?: number
lastUpdatedAt: number

View File

@ -92,6 +92,7 @@ export const Definitions = {
session_share: keybind("none", "Share current session"),
session_unshare: keybind("none", "Unshare current session"),
session_interrupt: keybind("escape", "Interrupt current session"),
session_background: keybind("ctrl+b", "Background synchronous subagents"),
session_compact: keybind("<leader>c", "Compact the session"),
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
@ -291,6 +292,7 @@ export const CommandMap = {
session_share: "session.share",
session_unshare: "session.unshare",
session_interrupt: "session.interrupt",
session_background: "session.background",
session_compact: "session.compact",
session_toggle_timestamps: "session.toggle.timestamps",
session_toggle_generic_tool_output: "session.toggle.generic_tool_output",

View File

@ -198,6 +198,17 @@ export function Session() {
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const foregroundTasks = createMemo(() =>
messages().flatMap((message) =>
(sync.data.part[message.id] ?? []).filter(
(part): part is ToolPart =>
part.type === "tool" &&
part.tool === "task" &&
part.state.status === "running" &&
part.state.metadata?.background !== true,
),
),
)
const permissions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
@ -1008,6 +1019,20 @@ export function Session() {
dialog.clear()
},
},
{
title: "Background subagents",
value: "session.background",
category: "Session",
hidden: true,
enabled: foregroundTasks().length > 0,
run: () => {
void sdk.client.experimental.session.background({
sessionID: route.sessionID,
workspace: project.workspace.current(),
})
dialog.clear()
},
},
{
title: "Go to child session",
value: "session.child.first",
@ -1088,6 +1113,13 @@ export function Session() {
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: foregroundTasks().length > 0,
priority: 1,
bindings: tuiConfig.keybinds.get("session.background"),
}))
const revertInfo = createMemo(() => session()?.revert)
const revertMessageID = createMemo(() => revertInfo()?.messageID)
@ -1453,6 +1485,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
})
const childShortcut = useCommandShortcut("session.child.first")
const backgroundShortcut = useCommandShortcut("session.background")
return (
<>
@ -1476,6 +1509,19 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<text fg={theme.text}>
{childShortcut()}
<span style={{ fg: theme.textMuted }}> view subagents</span>
<Show
when={props.parts.some(
(x) =>
x.type === "tool" &&
x.tool === "task" &&
x.state.status === "running" &&
x.state.metadata?.background !== true,
)}
>
<span style={{ fg: theme.textMuted }}> · </span>
{backgroundShortcut()}
<span style={{ fg: theme.textMuted }}> background</span>
</Show>
</text>
</box>
</Show>

View File

@ -2,6 +2,7 @@ import { AccountID, OrgID } from "@/account/schema"
import { MCP } from "@/mcp"
import { Session } from "@/session/session"
import { SessionID } from "@/session/schema"
import { Worktree } from "@/worktree"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { Schema } from "effect"
@ -91,6 +92,7 @@ export const ExperimentalPaths = {
worktree: "/experimental/worktree",
worktreeReset: "/experimental/worktree/reset",
session: "/experimental/session",
sessionBackground: "/experimental/session/:sessionID/background",
resource: "/experimental/resource",
} as const
@ -215,6 +217,19 @@ export const ExperimentalApi = HttpApi.make("experimental")
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
}),
),
HttpApiEndpoint.post("sessionBackground", ExperimentalPaths.sessionBackground, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Backgrounded subagents"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.session.background",
summary: "Background subagents",
description:
"Detach any synchronous subagents currently blocking the session and continue them in the background.",
}),
),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
query: WorkspaceRoutingQuery,
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),

View File

@ -1,10 +1,13 @@
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { BackgroundJob } from "@/background/job"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { MCP } from "@/mcp"
import { Project } from "@/project/project"
import { Session } from "@/session/session"
import type { SessionID } from "@/session/schema"
import { ToolJsonSchema } from "@/tool/json-schema"
import { ToolRegistry } from "@/tool/registry"
import { Worktree } from "@/worktree"
@ -30,6 +33,8 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
const sessions = yield* Session.Service
const background = yield* BackgroundJob.Service
const flags = yield* RuntimeFlags.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
@ -146,6 +151,21 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
})
})
const sessionBackground = Effect.fn("ExperimentalHttpApi.sessionBackground")(function* (ctx: {
params: { sessionID: SessionID }
}) {
if (!flags.experimentalBackgroundSubagents) return false
const jobs = (yield* background.list()).filter(
(job) =>
job.type === "task" &&
job.status === "running" &&
job.metadata?.parentSessionId === ctx.params.sessionID &&
job.metadata.background !== true,
)
const promoted = yield* Effect.forEach(jobs, (job) => background.promote(job.id), { concurrency: "unbounded" })
return promoted.some((job) => job !== undefined)
})
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
@ -161,6 +181,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.handle("sessionBackground", sessionBackground)
.handle("resource", resource)
}),
)

View File

@ -13,6 +13,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
import { BackgroundJob } from "@/background/job"
import { Config } from "@/config/config"
import { Command } from "@/command"
import * as Observability from "@opencode-ai/core/effect/observability"
@ -214,6 +215,7 @@ export function createRoutes(
Account.defaultLayer,
Agent.defaultLayer,
Auth.defaultLayer,
BackgroundJob.defaultLayer,
Command.defaultLayer,
Config.defaultLayer,
Format.defaultLayer,

View File

@ -20,7 +20,9 @@ export function isLocalWorkspaceRoute(method: string, path: string) {
export function getWorkspaceRouteSessionID(url: URL) {
if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
const id =
url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] ??
url.pathname.match(/^\/experimental\/session\/([^/]+)\/background$/)?.[1]
if (!id) return null
return SessionID.make(id)

View File

@ -4,7 +4,6 @@ import { Runner } from "@/effect/runner"
import { BackgroundJob } from "@/background/job"
import { Effect, Latch, Layer, Scope, Context } from "effect"
import { Session } from "./session"
import { MessageV2 } from "./message-v2"
import { SessionID } from "./schema"
import { SessionStatus } from "./status"

View File

@ -220,6 +220,17 @@ export const TaskTool = Tool.define(
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
})
const notify = Effect.fn("TaskTool.notifyBackgroundResult")(function* (jobID: string) {
yield* background.wait({ id: jobID }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed") return inject("completed", result.info.output ?? "")
if (result.info?.status === "error") return inject("error", result.info.error ?? "")
return Effect.void
}),
Effect.forkIn(scope, { startImmediately: true }),
)
})
if (yield* background.extend({ id: nextSession.id, run: runTask() })) {
return {
title: params.description,
@ -237,27 +248,27 @@ export const TaskTool = Tool.define(
}
}
if (runInBackground) {
const info = yield* background.start({
id: nextSession.id,
type: id,
title: params.description,
metadata,
run: runTask(),
})
yield* background.wait({ id: info.id }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed") return inject("completed", result.info.output ?? "")
if (result.info?.status === "error") return inject("error", result.info.error ?? "")
return Effect.void
const info = yield* background.start({
id: nextSession.id,
type: id,
title: params.description,
metadata,
onPromote: Effect.all([
ctx.metadata({
title: params.description,
metadata: { ...metadata, background: true, jobId: nextSession.id },
}),
Effect.forkIn(scope, { startImmediately: true }),
)
notify(nextSession.id),
]),
run: runTask().pipe(Effect.onInterrupt(() => ops.cancel(nextSession.id))),
})
function backgroundResult() {
return {
title: params.description,
metadata: {
...metadata,
background: true,
jobId: info.id,
},
output: renderOutput({
@ -269,6 +280,11 @@ export const TaskTool = Tool.define(
}
}
if (runInBackground) {
yield* notify(info.id)
return backgroundResult()
}
const runCancel = yield* EffectBridge.make()
const cancel = ops.cancel(nextSession.id)
@ -282,16 +298,23 @@ export const TaskTool = Tool.define(
}),
() =>
Effect.gen(function* () {
const text = yield* runTask()
const result = yield* Effect.raceFirst(
background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)),
background.waitForPromotion(nextSession.id),
)
if (result?.metadata?.background === true) return backgroundResult()
if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed"))
if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled"))
return {
title: params.description,
metadata,
output: renderOutput({ sessionID: nextSession.id, state: "completed", text }),
output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }),
}
}),
(_, exit) =>
Effect.gen(function* () {
if (Exit.hasInterrupts(exit)) yield* cancel
if (Exit.hasInterrupts(exit))
yield* Effect.all([cancel, background.cancel(nextSession.id)], { discard: true })
}).pipe(
Effect.ensuring(
Effect.sync(() => {

View File

@ -99,6 +99,31 @@ describe("background.job", () => {
}),
)
it.instance("runs extensions after earlier work completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const first = yield* Deferred.make<void>()
const order: string[] = []
const job = yield* jobs.start({
type: "test",
run: Effect.sync(() => order.push("start")).pipe(Effect.andThen(Deferred.await(first)), Effect.as("first")),
})
expect(
yield* jobs.extend({
id: job.id,
run: Effect.sync(() => order.push("extend")).pipe(Effect.as("second")),
}),
).toBe(true)
yield* Effect.yieldNow
expect(order).toEqual(["start"])
yield* Deferred.succeed(first, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("second")
expect(order).toEqual(["start", "extend"])
}),
)
it.instance("rejects extensions after a job completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
@ -160,25 +185,47 @@ describe("background.job", () => {
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const interrupted = yield* Deferred.make<void>()
const extendedInterrupted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
yield* jobs.extend({
id: job.id,
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(extendedInterrupted, undefined))),
run: Effect.never,
})
const cancelled = yield* jobs.cancel(job.id)
expect(cancelled?.status).toBe("cancelled")
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
yield* Deferred.await(extendedInterrupted).pipe(Effect.timeout("1 second"))
expect((yield* jobs.get(job.id))?.status).toBe("cancelled")
}),
)
it.instance("promotes running jobs without interrupting them", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const promoted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
metadata: { parentSessionId: "parent" },
onPromote: Deferred.succeed(promoted, undefined).pipe(Effect.asVoid),
run: Deferred.await(latch).pipe(Effect.as("done")),
})
const info = yield* jobs.promote(job.id)
expect(info?.status).toBe("running")
expect(info?.metadata?.background).toBe(true)
yield* Deferred.await(promoted)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(latch, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done")
}),
)
it.instance("returns immutable snapshots", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service

View File

@ -185,6 +185,7 @@ async function renderFooter(
subagent={subagents}
theme={RUN_THEME_FALLBACK}
tuiConfig={config}
backgroundSubagents={true}
agent="opencode"
onSubmit={input.onSubmit ?? (() => true)}
onPermissionReply={() => {}}
@ -613,6 +614,7 @@ test("direct footer shows editable prompts and additional queued work while runn
]}
theme={RUN_THEME_FALLBACK}
tuiConfig={tuiConfig}
backgroundSubagents={true}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
@ -647,7 +649,7 @@ test("direct footer shows editable prompts and additional queued work while runn
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • 1 queued ctrl+x q")
expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • ctrl+b background • 1 queued ctrl+x q")
expect(app.captureCharFrame()).toContain("2 queued")
expect(app.captureCharFrame()).not.toContain("to view")
expect(app.captureCharFrame()).not.toContain("edit/remove")

View File

@ -568,6 +568,17 @@ const scenarios: Scenario[] = [
.get("/experimental/session", "experimental.session.list")
.at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() }))
.json(200, array),
http.protected
.post("/experimental/session/{sessionID}/background", "experimental.session.background")
.mutating()
.seeded((ctx) => ctx.session({ title: "Background route owner" }))
.at((ctx) => ({
path: route("/experimental/session/{sessionID}/background", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, (body) => {
check(body === false, "background route should be a no-op without running subagents")
}),
http.protected.get("/experimental/resource", "experimental.resource.list").json(),
http.protected
.post("/sync/history", "sync.history.list")

View File

@ -90,4 +90,23 @@ describe("session action routes", () => {
}),
{ git: true },
)
it.instance(
"experimental background route is a no-op without synchronous subagents",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) =>
SessionNs.use.remove(created.id).pipe(Effect.ignore),
)
const res = yield* requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, {
method: "POST",
})
expect(res.status).toBe(200)
expect(yield* res.json).toBe(false)
}),
{ git: true },
)
})

View File

@ -41,6 +41,11 @@ describe("getWorkspaceRouteSessionID", () => {
expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_xyz"))
})
test("extracts session ID from experimental background path", () => {
const url = new URL("http://localhost/experimental/session/ses_bg/background")
expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_bg"))
})
test("returns null for /session/status", () => {
const url = new URL("http://localhost/session/status")
expect(getWorkspaceRouteSessionID(url)).toBeNull()

View File

@ -1,14 +1,13 @@
import { afterEach, describe, expect } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { Effect, Exit, Fiber, Layer } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Config } from "@/config/config"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Session } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionRunState } from "@/session/run-state"
@ -484,6 +483,72 @@ describe("tool.task", () => {
}),
)
it.instance("promotes a running foreground task without restarting it", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const ready = yield* Deferred.make<void>()
const done = yield* Deferred.make<void>()
const injected = yield* Deferred.make<SessionPrompt.PromptInput>()
let runs = 0
const promptOps: TaskPromptOps = {
cancel: () => Effect.void,
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) => {
if (input.sessionID === chat.id) {
return Deferred.succeed(injected, input).pipe(Effect.as(reply(input, "injected")))
}
return Effect.gen(function* () {
runs += 1
yield* Deferred.succeed(ready, undefined)
yield* Deferred.await(done)
return reply(input, "background done")
})
},
}
const fiber = yield* def
.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
.pipe(Effect.forkChild)
yield* Deferred.await(ready)
const job = (yield* jobs.list())[0]
expect(job).toBeDefined()
if (!job) throw new Error("task job not found")
expect(job.metadata?.parentSessionId).toBe(chat.id)
yield* jobs.promote(job.id)
const result = yield* Fiber.join(fiber)
expect(result.metadata.background).toBe(true)
expect(result.output).toContain(`state="running"`)
expect((yield* jobs.get(result.metadata.sessionId))?.status).toBe("running")
expect(runs).toBe(1)
yield* Deferred.succeed(done, undefined)
expect((yield* jobs.wait({ id: result.metadata.sessionId })).info?.output).toBe("background done")
expect((yield* Deferred.await(injected)).parts[0]?.type).toBe("text")
expect(runs).toBe(1)
}),
)
background.instance("execute launches background tasks without waiting for completion", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
@ -576,14 +641,14 @@ describe("tool.task", () => {
context,
)
expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([
{ type: "text", text: "also inspect cancellation" },
])
expect(result.metadata.sessionId).toBe(started.metadata.sessionId)
expect(result.metadata.background).toBe(true)
expect(result.output).toContain("Background task updated")
first.resolve()
expect((yield* jobs.get(started.metadata.sessionId))?.status).toBe("running")
expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([
{ type: "text", text: "also inspect cancellation" },
])
second.resolve()
const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 })
@ -784,6 +849,27 @@ describe("tool.task", () => {
}),
)
it.instance("cancelling a child run cancels its own pre-runner task job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const runState = yield* SessionRunState.Service
const sessions = yield* Session.Service
const { chat } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
yield* jobs.start({
id: child.id,
type: "task",
metadata: { parentSessionId: chat.id, sessionId: child.id },
run: Effect.never,
})
yield* runState.cancel(child.id)
expect((yield* jobs.get(child.id))?.status).toBe("cancelled")
}),
)
it.instance("cancelling a parent run recursively cancels descendant background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service

View File

@ -44,6 +44,8 @@ import type {
ExperimentalProjectCopyRemoveResponses,
ExperimentalResourceListErrors,
ExperimentalResourceListResponses,
ExperimentalSessionBackgroundErrors,
ExperimentalSessionBackgroundResponses,
ExperimentalSessionListErrors,
ExperimentalSessionListResponses,
ExperimentalWorkspaceAdapterListErrors,
@ -733,6 +735,42 @@ export class Session extends HeyApiClient {
...params,
})
}
/**
* Background subagents
*
* Detach any synchronous subagents currently blocking the session and continue them in the background.
*/
public background<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<
ExperimentalSessionBackgroundResponses,
ExperimentalSessionBackgroundErrors,
ThrowOnError
>({
url: "/experimental/session/{sessionID}/background",
...options,
...params,
})
}
}
export class Resource extends HeyApiClient {

View File

@ -5850,6 +5850,38 @@ export type ExperimentalSessionListResponses = {
export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses]
export type ExperimentalSessionBackgroundData = {
body?: never
path: {
sessionID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/experimental/session/{sessionID}/background"
}
export type ExperimentalSessionBackgroundErrors = {
/**
* BadRequest | InvalidRequestError
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
}
export type ExperimentalSessionBackgroundError =
ExperimentalSessionBackgroundErrors[keyof ExperimentalSessionBackgroundErrors]
export type ExperimentalSessionBackgroundResponses = {
/**
* Backgrounded subagents
*/
200: boolean
}
export type ExperimentalSessionBackgroundResponse =
ExperimentalSessionBackgroundResponses[keyof ExperimentalSessionBackgroundResponses]
export type ExperimentalResourceListData = {
body?: never
path?: never