Repository navigation
tools: let long-running execute, MCP and plugin tool calls move to the background #54221
Description
Activity
Thanks, I reproduced this on
@opencode/cli@2.0.26, and it still happens on the latestv2(8eff035).Steps, using opencode-drive with a simulated model:
- Register a provider-backed tool
slow_lookup. It stands in for a long MCP or plugin tool; I didn't use a real MCP server or plugin. - Have the model call it and keep the call running.
- After a few seconds, press
ctrl+b(session.background).
Nothing happens: no "move to background" hint appears, the call keeps running and the session stays blocked. The model continues only after the tool returns its result. The recording and the screenshot below show this, and the script below reproduces it.
This fits what the issue describes: only
shellandsubagentcreateJobs, and the keybind and TUI hint act only on those.recording.mp4
Reproduction script (drive.ts)
// Repro for #54221: a long-running non-shell tool call (provider-backed dynamic tool, // standing in for an MCP/plugin tool) cannot be moved to the background with ctrl+b // (session.background), while a shell call can. import { Effect } from "effect" import { Llm, OpenCodeDriver } from "opencode-drive" export default OpenCodeDriver.use( { ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}), tui: { recording: true, keypressOverlay: true }, }, ({ tools, llm, ui }) => Effect.gen(function* () { yield* tools.attach({ tools: [ { name: "slow_lookup", description: "A slow lookup that takes minutes", inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, options: { codemode: false }, }, ], }) yield* llm.queue( Llm.toolCall({ index: 0, id: "call_slow", name: "slow_lookup", input: { query: "meaning" } }), Llm.finish("tool-calls"), ) yield* ui.submit("Run the slow lookup") const call = yield* tools.take("call_slow") yield* call.progress({ status: "still working..." }) yield* Effect.sleep(3000) yield* ui.screenshot("01-slow-tool-running") // Try to move it to the background (session.background keybind) yield* ui.press("b", { ctrl: true }) yield* Effect.sleep(5000) const frame = yield* ui.capture() const text = JSON.stringify(frame) yield* ui.screenshot("02-after-ctrl-b") yield* Effect.log(`Moved to background shown: ${/background/i.test(text)}`) // The call is still blocking the session: only finishing it lets the model continue. yield* llm.queue(Llm.text("Lookup finished: 42.")) yield* call.finish({ structured: { answer: 42 }, content: [{ type: "text", text: "42" }] }) yield* ui.waitFor("Lookup finished: 42.") yield* ui.screenshot("03-only-after-tool-finished") }), )
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.- Register a provider-backed tool
The key boundary is ownership of the running continuation. The runner currently owns scoped tool fibers and waits for them, while subagent jobs and completion observers deliberately outlive the launching plugin. I’d extend that job-backed pattern for this feature, not interrupt and restart a tool.
For an initial implementation:
- Wrap supported MCP/plugin calls and the whole
executecontinuation in a job before execution. A user background request atomically releases the foreground waiter, returning one placeholder with the job ID and “you will be notified; do not poll.” A nested plugin call must not return that placeholder into a Code Mode program expecting its actual value. - Keep the job’s scope, deadline, and permission context alive after the foreground tool fiber settles. Foreground interruption must not accidentally cancel detached work; explicit job cancellation should. Ignore progress directed at the settled tool part, but retain job progress/status.
- Deliver the truncated final output through a synthetic completion message, following the existing notification-ID/
completeBackgroundpattern. Repeated shortcut presses and completion racing with detachment must produce one job, one foreground result, and one completion notification. A later result must not rewrite the placeholder or become a second result for the original tool-call ID. - Add a
toolrecovery record so restart reports lost in-process execution instead of replaying potentially side-effecting code or leaving the call pending. Background permission requests must remain visible. Timeout still applies after detachment; cancellation requested is distinct from confirmed termination for plugins ignoring abort.
I’d initially deliver explicit user/API detachment and completion/recovery semantics. Model
execute.backgroundcan use the same path; automaticbackground_aftershould follow separately so its interaction with #54220’s deadline has one agreed policy. Foreign tool input schemas stay unchanged.Acceptance: whole-execute detachment with an awaited child, transfer/completion races, synthetic result delivery, permission visibility, job-specific cancellation, and restart recovery. This is source-based design, not a new run of the bot’s simulation.
Would the core team approve this first scope and ownership/result contract?
One implementation constraint from the current
devsource: Job.StartInput runs to a string, whereas Tool.NormalizedResult retains structured output, file content, and metadata. Wrapping a generic call in a string-only job must not flatten the result returned by an undetached call. The adapter needs to retain the normalized result for foreground delivery, with a separate bounded rendering for a detached completion. In particular, Code Mode's file collection and awaited child results must still see their original values.Recovery also needs an explicit tool branch in startup dispatch: it currently handles shell and otherwise assumes subagent recovery, so adding a schema variant alone is insufficient. This supports reporting interrupted in-process tool execution rather than replaying it.
On
devat7b3d4ce, the existingjob.test.ts,tool-execute.test.ts, andsession-tool-progress.test.tspass: 21 tests, 156 assertions. These validate the existing lifecycle and result/progress behavior; they do not demonstrate generic-tool backgrounding or a new TUI reproduction. No implementation changes are included in this investigation.- Wrap supported MCP/plugin calls and the whole

Summary
Only
shellandsubagentcalls can be moved to the background. A longexecutescript, MCP call or plugin tool call blocks the session until it finishes or the user interrupts it. Pressing the "move to background" keybind does nothing for these calls, and the hint never appears.Environment
v2at 8eff035)Reproduction
execute.session.backgroundkeybind (or callPOST /api/session/{id}/background).Expected Behavior
The call moves to the background, just as a shell command does. The model gets a placeholder result and continues, and it's notified with the result when the call finishes. The TUI shows the "move to background" hint for any long-running call.
Actual Behavior
Nothing happens.
Session.backgroundonly backgroundsJobs that are blocking the session (session.ts#L442-L457), and onlyshell(shell.ts#L236-L266) andsubagent(subagent.ts#L200-L243) create jobs. The TUI hint is limited to those two tools (index.tsx#L1778-L1791).Proposal
The machinery already exists in
Job(job.ts), and the shell tool shows the pattern: start a job,blockon it, and when it's backgrounded return a placeholder and fork a notifier that posts a synthetic message (shell.ts#L155-L188).execute, MCP and plugin tool calls as jobs:jobs.start({ id: callID, type: name, title, run }), thenjobs.block. This could happen inTool.Service.execute(tool.ts#L263-L284). Quick built-ins can stay as they are. Once a call is backgrounded, return "Moved to the background (job …). You will be notified when it finishes; do not poll." Post the truncated result as a synthetic message when the job ends.background?: booleanto theexecuteinput. That schema belongs to OpenCode, so foreign MCP and plugin schemas don't need new fields.tools.background_after: "2m"moves a foreground call to the background instead of failing it. A separate background ceiling then applies.toolrecovery kind next toshellandsubagent, so a server restart reports the lost call instead of leaving the model waiting.executecalls share one call ID, so background the wholeexecute. Child shells already get their own jobs.Related: #36230 and #9462 (earlier requests for background tool calls), #49842 (a broader proposal for background work).