Repository navigation
codemode: execute and plugin tool calls have no timeout, so one slow call blocks the session until interrupted #54220
Description
Summary
Code Mode execute and plugin tool calls have no time limit in V2. A script that awaits a slow plugin tool keeps the session busy, showing only a spinner, until the user interrupts it. MCP calls do have a limit, but it defaults to 12 hours.
We hit this with a plugin tool that scans chat history. An execute script awaited five calls in sequence, each taking about 3.5 minutes, and ran for 16.5 minutes before it was interrupted. Nothing was stuck; there was just nothing to bound the call or show that it was still making progress.
Environment
- opencode version: 0.0.0-dev-20788 (V2,
v2at 8eff035) - OS: macOS (Darwin 27.0.0, arm64)
- Install/channel: dev
- Active plugins: a local Promise plugin that provides the slow tool (minimal version below)
Reproduction
-
Add a local plugin with a slow tool:
import { Plugin } from "@opencode/plugin" export default Plugin.define({ id: "slow-tool", async setup(ctx) { await ctx.tool.transform((editor) => { editor.add({ name: "slow_wait", description: "Waits for the given number of seconds.", input: { type: "object", properties: { seconds: { type: "number" } }, required: ["seconds"] }, options: { codemode: true }, execute: async ({ seconds }) => { await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) return { content: `waited ${seconds}s` } }, }) }) }, })
-
Ask the agent to run this through
execute:await tools.slow_wait({ seconds: 600 }) return await tools.slow_wait({ seconds: 600 })
-
Watch the session.
Expected Behavior
execute and non-builtin tool calls have a default deadline (for example 10 minutes) that can be configured globally and per tool. When the deadline passes, the call ends as a tool error the model can act on, the step continues, and results from nested calls that finished are kept. Long calls show elapsed time and progress.
Actual Behavior
The session stays busy for 20 minutes with a spinner and no elapsed time. The only way out is to interrupt, which ends the whole step with Tool execution interrupted.
Where this comes from
executecreates its runtime withoutlimits(codemode/tool.ts#L239), sotimeoutMsis undefined. It's documented as "absent means no timeout" (codemode.ts#L23-L27). The interpreter already implements the deadline and returns aTimeoutExceededdiagnostic with logs (execute.ts#L56-L90).maxToolCallsandmaxOutputBytesare unset too.- Scripts can't set their own deadline, because the
executedescription says timers are unavailable (codemode/tool.ts#L64), and a tool call accepts only one input object (tool-runtime.ts#L400-L403). - The tool service runs tools with no deadline (tool.ts#L113-L157, tool/runtime.ts#L29-L61). Only some built-ins bound themselves (shell, webfetch, grep, websearch).
- MCP:
timeout.executiondefaults to 12 hours (mcp/client.ts#L36).callToolpassesonprogress: () => {}withoutresetTimeoutOnProgress(mcp/client.ts#L352-L362). So progress never extends the timer, and progress notifications are dropped instead of reaching the UI. - Cancellation itself works. Interrupting aborts
context.signalfor Promise plugin tools (adapter.ts#L624-L631), and the MCP SDK sendsnotifications/cancelled. The problem is that nothing triggers cancellation except the user ending the whole step (step.ts#L154). - A plugin tool called from
executecan't report its own progress. It receives theexecutecall's context, socontext.progressreplaces theexecuterow'stoolCallsmetadata (codemode/tool.ts#L95, publish-llm-event.ts#L566-L569).
Proposal
- Pass
limits.timeoutMstoCodeMode.make, with a configurable default (for examplecodemode.timeout, 10 minutes). Tell the model thatconsole.logoutput survives a timeout. - Add a generic deadline in
Tool.Service.executeTool. Use a per-tool registration option first, then atools.timeoutconfig default. Exclude built-ins that manage their own time (shell,subagent,question). A timeout becomes aTool.Errorsuch as "timed out after 10m", so the step continues. - For MCP, lower the 12 hour default to the same value. Add an idle timeout with
resetTimeoutOnProgress: trueandmaxTotalTimeout = execution, and forwardonprogresstocontext.progress. - Store a start and end time for each nested
executecall, and show elapsed time for running tools and nested rows in the TUI. Give nested calls their own progress slot instead of letting them overwrite the parent's metadata.
Related: #51223 (an invisible permission ask makes execute wait forever; a deadline would bound it), #51856 (an MCP call that waits forever on elicitation), #43717 (MCP activity in the TUI), #20096 (the V1 version of this problem). #20103 and #36869 attempted V1 tool timeouts, but neither was merged into V2.
Activity
opencode-agent commentedon Oct 10, 2026
Thanks for the detailed write-up. I reproduced this with opencode-drive and the simulated model. As a stand-in for your plugin, I attached a Code Mode tool
slow_wait(options.codemode: true) that never returns. The model then callsexecutewith your two-call script.execute › slow_wait [seconds=600]with a spinner and no elapsed time. No deadline fired. Pressing Esc ended the whole step asinterrupted, with both rows marked as errors.v2from source (8eff035): same result.The code matches your analysis.
createCodeModeinpackages/core/src/codemode/tool.tscallsCodeMode.makewithoutlimits, and the MCPcallTooldrops progress and uses the 12-hour default.The screenshots below come from the 150 s run on source. The recording is a shorter 5 s run of the same script on 2.0.26: it shows the flow but not the long wait. In the 150 s runs the drive tool got stuck after the interrupt and never saved the video.
recording.mp4
Reproduction script (drive.ts)
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.Dante-dan commentedon Oct 10, 2026
I’d build on the existing interpreter timeout, rather than add a second competing timeout around
execute. On currentv2,executeProgramalready preserves logs/tool-call records onTimeoutExceeded; the separate gap is bounding ordinary plugin invocations at the tool boundary.Proposed contract:
Tool.Error, so the runner’s existing per-call failure publication settles that call rather than interrupting the whole step. Keep shell/subagent/question timing semantics. Parentexecutecancellation must still interrupt its pending children. Promise plugins already expose an AbortSignal; ignoring it must not allow a late success or progress update to overwrite the timeout.executemetadata rather than replacing itstoolCalls. Expose elapsed time independently of progress messages.Acceptance: timeout without ending the whole step, retained completed-child evidence, cooperative and abort-ignoring plugins, late-output suppression, deadline/completion races, nested progress, and permission waits. These are proposed checks, not tests I have run; the reproduction above is the bot’s.
Would the core team approve this scope and the deadline/permission policy?