Skip to content

feat(background): run long-running shell commands without blocking the conversation - #40005

Open
openchat-ai wants to merge 3 commits into
anomalyco:devfrom
openchat-ai:background-shell-pr
Open

feat(background): run long-running shell commands without blocking the conversation#40005
openchat-ai wants to merge 3 commits into
anomalyco:devfrom
openchat-ai:background-shell-pr

Conversation

@openchat-ai

Copy link
Copy Markdown

Issue for this PR

Closes #39769

Type of change

  • New feature

What does this PR do?

Re-submission of #39978 (auto-closed by the compliance bot for a missing template section; no maintainer rejection).

Long-running shell commands (e.g. gh run watch, polling loops, builds) currently block the entire conversation until they finish. This PR lets commands run in the background:

  • A background flag on the shell tool so a command starts and returns immediately
  • HTTP API to list running jobs and cancel them
  • TUI badge showing background shell status inline
  • Failure notifications when a background job exits nonzero

I understand why this works: the shell tool call resolves as soon as the process is spawned, the job's output is streamed/polled separately, and the session is not held up waiting for the exit code.

How did you verify your code works?

The branch has been rebased and passes bun typecheck. The feature is exercised end-to-end locally: a background command returns immediately while the conversation stays responsive, jobs are listed and cancellable via the API, and a failing job surfaces a notification.

Screenshots / recordings

N/A

Checklist

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

Admin added 2 commits July 31, 2026 16:42
…ions

- Add GET /experimental/jobs and POST /experimental/jobs/:jobID endpoints with BackgroundJobInfo schema and regenerated SDK client methods

- Add TUI background-jobs plugin: running-job badge in app_bottom slot and a cancel dialog (background.jobs palette command)

- Render failed background commands as error state carrying the exit code; surface errors via notify

- Gate background shell behind the experimentalBackgroundShell runtime flag
- Drop the unrequested SummaryMode (head/error/tail) feature and its summary_mode parameter

- Collapse runShell/runTask/notify into inline run() calls; keep only renderBackgroundOutput and injectBackgroundResult helpers

- Inject the background result once via onPromote instead of a duplicate wait
Copilot AI review requested due to automatic review settings August 1, 2026 06:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds experimental support for running long-running shell tool commands in the background so the conversation can remain responsive, with supporting APIs and a TUI surface to view/cancel jobs.

Changes:

  • Add background?: boolean to the shell tool and wire it to the shared BackgroundJob registry.
  • Expose experimental HTTP endpoints to list jobs and cancel a job by id, plus regenerated JS SDK types/clients.
  • Add a TUI badge + dialog to show running background jobs and allow cancellation.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/tui/src/feature-plugins/system/background-jobs.tsx New TUI plugin: polls background jobs, shows a badge, and provides a cancel dialog.
packages/tui/src/feature-plugins/builtins.ts Registers the new background jobs plugin in the builtin plugin list.
packages/sdk/js/src/v2/gen/types.gen.ts Regenerated SDK types to include background job models and endpoint typings.
packages/sdk/js/src/v2/gen/sdk.gen.ts Regenerated SDK client adding experimental.background.list/cancel.
packages/opencode/test/tool/shell.test.ts Adds live tests covering background shell behavior (surviving abort, timeouts, failure state).
packages/opencode/src/tool/shell/prompt.ts Documents the new background parameter and updated timeout semantics in the shell prompt.
packages/opencode/src/tool/shell.ts Implements background execution path via BackgroundJob, plus timeout heuristics.
packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts Implements experimental jobs list and jobCancel handlers.
packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts Adds OpenAPI schema + endpoint definitions for /experimental/jobs and /experimental/jobs/:jobID.
packages/opencode/src/effect/runtime-flags.ts Adds OPENCODE_EXPERIMENTAL_BACKGROUND_SHELL runtime flag.
Suppressed comments (7)

packages/tui/src/feature-plugins/system/background-jobs.tsx:48

  • Polling is wired via createResource(async () => { ... setInterval ...; return () => clearInterval(timer) }), but createResource doesn't run effect cleanups; this interval will keep running after the dialog closes. Switch to createEffect with onCleanup (see e.g. packages/tui/src/component/prompt/autocomplete.tsx:115-128).
  createResource(async () => {
    await refresh()
    const timer = setInterval(refresh, POLL_MS)
    return () => clearInterval(timer)
  })

packages/tui/src/feature-plugins/system/background-jobs.tsx:132

  • Same interval leak as above: createResource won't reliably clean up the polling timer when this component unmounts. Use createEffect + onCleanup so the timer stops when the slot re-renders/unmounts.
  createResource(async () => {
    await refresh()
    const timer = setInterval(refresh, POLL_MS)
    return () => clearInterval(timer)
  })

packages/opencode/test/tool/shell.test.ts:1272

  • The ping output assertion is Windows-shaped (TTL=). On Unix it’s typically ttl=; use a case-insensitive match so the test is portable.
          expect(job.info?.output).toMatch(/TTL=/)

packages/opencode/src/tool/shell.ts:272

  • BUILD_COMMAND doesn't match common forms like bun run test, npm run test, or yarn run lint, so defaultTimeoutFor() won't actually apply the “build/test operations get a longer default timeout” behavior described in the prompt. Expand the regex to cover * run <cmd> invocations.
const NETWORK_COMMAND = /^(curl|wget|ping|tracert|tracepath|ssh|gh)\b/i
const NETWORK_GIT = /^git (fetch|pull|clone|push|ls-remote)\b/i
const BUILD_COMMAND = /^(bun |npm |yarn |pnpm |mvn |gradle |cargo |make )?(build|test|typecheck|check|lint|install|ci)\b/i

packages/opencode/src/tool/shell.ts:720

  • background.start stores onPromote, but onPromote is only executed when background.promote(id) is called. Because the job is started with metadata.background: true, it will never be promotable and onPromote will never run — so ctx.metadata(...) and the completion injection fiber won’t be registered.
          const jobID = Identifier.ascending("job")
          const metadata = { command: params.command, cwd, background: true }

packages/opencode/src/tool/shell.ts:752

  • To ensure the onPromote effect (metadata update + completion injection) actually runs for background shell jobs, explicitly promote the job immediately after starting it.
          })

packages/opencode/src/tool/shell.ts:665

  • injectBackgroundResult wraps info.output inside a new <shell ...> envelope. But the background job already stores a fully formatted <shell job=... state=... exit=...> string, so this creates nested tags and drops the original exit attribute (it’s currently always rendered as null). Prefer emitting info.output verbatim when present.
          const state =
            info?.status === "error"
              ? "error"
              : /state="error"/.test(info?.output ?? "") ? "error" : "completed"
          const text = info?.status === "error" ? (info.error ?? "") : (info?.output ?? "")

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +11 to +13
function formatTime(ms: number | string) {
const elapsed = Date.now() - Number(ms)
if (Number.isNaN(elapsed)) return "?"
@@ -0,0 +1,186 @@
import type { BackgroundJobInfo } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createResource, createSignal, Show } from "solid-js"
)
props.api.ui.toast({
variant: result.data ? "success" : "error",
message: result.data ? `Cancelled background job ${jobLabel(jobs().find((x) => x.id === jobID)!)!}` : "Job already finished",
Comment on lines +174 to +188
const jobs = Effect.fn("ExperimentalHttpApi.jobs")(function* () {
const list = yield* background.list()
return list.map((job) => ({
id: job.id,
type: job.type,
...(job.title ? { title: job.title } : {}),
status: job.status,
startedAt: job.started_at,
...(job.completed_at ? { completedAt: job.completed_at } : {}),
...(job.output ? { output: job.output } : {}),
...(job.error ? { error: job.error } : {}),
...(typeof job.metadata?.command === "string" ? { command: job.metadata.command } : {}),
...(job.metadata?.background === true ? { background: true } : {}),
}))
})
Comment on lines +1255 to +1259
const res = yield* run(
{
command: `ping -n 18 127.0.0.1`,
background: true,
},
Comment thread packages/opencode/src/tool/shell.ts Outdated
Comment on lines +29 to +30
import type { SessionPrompt } from "@/session/prompt"
import { SessionID } from "@/session/schema"
Comment on lines +190 to +193
const jobCancel = Effect.fn("ExperimentalHttpApi.jobCancel")(function* (ctx: { params: { jobID: string } }) {
const cancelled = yield* background.cancel(ctx.params.jobID)
return cancelled !== undefined
})
- formatTime guards Infinity/-Infinity with Number.isFinite
- replace createResource polling with createEffect + onCleanup to stop interval leaks
- fall back to jobID in cancel toast when job list has refreshed
- omit output/error from /experimental/jobs list response (polled every 5s)
- make ping count flag platform-specific in background shell test
- drop unused SessionPrompt/SessionID imports
- jobCancel returns true only when the job is cancelled
@openchat-ai

Copy link
Copy Markdown
Author

Hi there! First-time contributor here, and I noticed the CI checks on this PR are currently waiting for approval to run (they show as action_required). I'd be grateful if you could approve the workflow run whenever you have a moment — no rush at all. Happy to make any adjustments if needed, thanks!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Long-running shell commands block the entire conversation

2 participants