Skip to content

tools: let long-running execute, MCP and plugin tool calls move to the background #54221

Description

@kitlangton

Summary

Only shell and subagent calls can be moved to the background. A long execute script, 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

  • opencode version: 0.0.0-dev-20788 (V2, v2 at 8eff035)
  • OS: macOS (Darwin 27.0.0, arm64)

Reproduction

  1. Use the slow plugin tool from codemode: execute and plugin tool calls have no timeout, so one slow call blocks the session until interrupted #54220, or any MCP tool that takes several minutes.
  2. Have the agent call it from execute.
  3. After a few seconds, press the session.background keybind (or call POST /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.background only backgrounds Jobs that are blocking the session (session.ts#L442-L457), and only shell (shell.ts#L236-L266) and subagent (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, block on it, and when it's backgrounded return a placeholder and fork a notifier that posts a synthetic message (shell.ts#L155-L188).

  1. Run execute, MCP and plugin tool calls as jobs: jobs.start({ id: callID, type: name, title, run }), then jobs.block. This could happen in Tool.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.
  2. There are three ways a call moves to the background:
    • User: the existing keybind and API work as soon as these calls are jobs. Widen the hint to every job-backed tool.
    • Model: add background?: boolean to the execute input. That schema belongs to OpenCode, so foreign MCP and plugin schemas don't need new fields.
    • Automatic (opt-in): a setting such as tools.background_after: "2m" moves a foreground call to the background instead of failing it. A separate background ceiling then applies.
  3. Details:

Related: #36230 and #9462 (earlier requests for background tool calls), #49842 (a broader proposal for background work).

Activity

  1. opencode-agent commented on Oct 9, 2026

    @opencode-agent
    Contributor

    Thanks, I reproduced this on @opencode/cli@2.0.26, and it still happens on the latest v2 (8eff035).

    Steps, using opencode-drive with a simulated model:

    1. 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.
    2. Have the model call it and keep the call running.
    3. 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 shell and subagent create Jobs, and the keybind and TUI hint act only on those.

    recording.mp4

    Screenshot

    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 drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  2. Dante-dan commented on Oct 10, 2026

    @Dante-dan

    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 execute continuation 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/completeBackground pattern. 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 tool recovery 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.background can use the same path; automatic background_after should 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 dev source: 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 dev at 7b3d4ce, the existing job.test.ts, tool-execute.test.ts, and session-tool-progress.test.ts pass: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions