feat(background): run long-running shell commands without blocking the conversation - #40005
feat(background): run long-running shell commands without blocking the conversation#40005openchat-ai wants to merge 3 commits into
Conversation
…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
There was a problem hiding this comment.
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?: booleanto the shell tool and wire it to the sharedBackgroundJobregistry. - 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) }), butcreateResourcedoesn't run effect cleanups; this interval will keep running after the dialog closes. Switch tocreateEffectwithonCleanup(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:
createResourcewon't reliably clean up the polling timer when this component unmounts. UsecreateEffect+onCleanupso 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 typicallyttl=; use a case-insensitive match so the test is portable.
expect(job.info?.output).toMatch(/TTL=/)
packages/opencode/src/tool/shell.ts:272
BUILD_COMMANDdoesn't match common forms likebun run test,npm run test, oryarn run lint, sodefaultTimeoutFor()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.startstoresonPromote, butonPromoteis only executed whenbackground.promote(id)is called. Because the job is started withmetadata.background: true, it will never be promotable andonPromotewill never run — soctx.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
onPromoteeffect (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
injectBackgroundResultwrapsinfo.outputinside 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 originalexitattribute (it’s currently always rendered asnull). Prefer emittinginfo.outputverbatim 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.
| 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", |
| 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 } : {}), | ||
| })) | ||
| }) |
| const res = yield* run( | ||
| { | ||
| command: `ping -n 18 127.0.0.1`, | ||
| background: true, | ||
| }, |
| import type { SessionPrompt } from "@/session/prompt" | ||
| import { SessionID } from "@/session/schema" |
| 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
|
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! |
Issue for this PR
Closes #39769
Type of change
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: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