diff --git a/packages/opencode/src/tool/bash_kill.ts b/packages/opencode/src/tool/bash_kill.ts new file mode 100644 index 000000000000..3013cddc51b6 --- /dev/null +++ b/packages/opencode/src/tool/bash_kill.ts @@ -0,0 +1,53 @@ +import { Effect, Schema } from "effect" +import { Tool } from "./tool" +import { BackgroundJob } from "@/background/job" + +const Parameters = Schema.Struct({ + job_id: Schema.String.annotate({ description: "The background bash job id to cancel" }), +}) + +type Metadata = { + jobId: string + status?: BackgroundJob.Status + truncated: boolean +} + +export const BashKillTool = Tool.define( + "bash_kill", + Effect.gen(function* () { + const background = yield* BackgroundJob.Service + + return { + description: "Cancel a running background bash command by job id.", + parameters: Parameters, + execute: (params: Schema.Schema.Type) => + Effect.gen(function* () { + const existing = yield* background.get(params.job_id) + if (existing && existing.type !== "bash") { + return { + title: "Background bash job not found", + metadata: { jobId: params.job_id, truncated: false } as Metadata, + output: `No background bash job found with id ${params.job_id}.`, + } + } + const info = existing ? yield* background.cancel(params.job_id) : undefined + if (!info) { + return { + title: "Background bash job not found", + metadata: { jobId: params.job_id, truncated: false } as Metadata, + output: `No background bash job found with id ${params.job_id}.`, + } + } + return { + title: `Cancelled ${params.job_id}`, + metadata: { + jobId: info.id, + status: info.status, + truncated: false, + } as Metadata, + output: `Background bash job ${info.id} is ${info.status}.`, + } + }), + } + }), +) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 541d1f4bbbd0..7a619f7e7a88 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -5,6 +5,7 @@ import { PlanExitTool } from "./plan" import { Session } from "@/session/session" import { QuestionTool } from "./question" import { ShellTool } from "./shell" +import { BashKillTool } from "./bash_kill" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" @@ -99,6 +100,7 @@ export const layer = Layer.effect( const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool const shell = yield* ShellTool + const bashkill = yield* BashKillTool const globtool = yield* GlobTool const writetool = yield* WriteTool const edit = yield* EditTool @@ -198,6 +200,7 @@ export const layer = Layer.effect( const tool = yield* Effect.all({ invalid: Tool.init(invalid), shell: Tool.init(shell), + bashkill: Tool.init(bashkill), read: Tool.init(read), glob: Tool.init(globtool), grep: Tool.init(greptool), @@ -220,6 +223,7 @@ export const layer = Layer.effect( tool.invalid, ...(questionEnabled ? [tool.question] : []), tool.shell, + tool.bashkill, tool.read, tool.glob, tool.grep, diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1e4423e01774..85b0232a3521 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -1,4 +1,4 @@ -import { Effect, Stream } from "effect" +import { Effect, Scope, Stream } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" @@ -21,10 +21,16 @@ import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ShellPrompt, type Parameters } from "./shell/prompt" import { BashArity } from "@/permission/arity" +import { BackgroundJob } from "@/background/job" +import type { TaskPromptOps } from "./task" export { Parameters } from "./shell/prompt" const MAX_METADATA_LENGTH = 30_000 +const BACKGROUND_STARTED = [ + "The command is running in the background. You will be notified automatically when it finishes.", + "Do not sleep or poll for progress. Continue with other work, or tell the user what was launched.", +].join("\n") const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, @@ -308,6 +314,26 @@ function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv detached: process.platform !== "win32", }) } + +function renderBackgroundOutput(input: { + jobID: string + command: string + state: "running" | "completed" | "cancelled" | "error" + exit?: number | null + outputPath?: string + text: string +}) { + return [ + ``, + `${input.command}`, + ...(input.exit === undefined ? [] : [`${input.exit}`]), + ...(input.outputPath ? [`${input.outputPath}`] : []), + "", + input.text, + "", + "", + ].join("\n") +} const parser = lazy(async () => { const { Parser } = await import("web-tree-sitter") const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { @@ -344,6 +370,8 @@ export const ShellTool = Tool.define( const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service const flags = yield* RuntimeFlags.Service + const background = yield* BackgroundJob.Service + const scope = yield* Scope.Scope const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000 const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) { @@ -431,7 +459,8 @@ export const ShellTool = Tool.define( command: string cwd: string env: NodeJS.ProcessEnv - timeout: number + timeout?: number + description: string }, ctx: Tool.Context, ) { @@ -537,13 +566,19 @@ export const ShellTool = Tool.define( return Effect.sync(() => ctx.abort.removeEventListener("abort", handler)) }) - const timeout = Effect.sleep(`${input.timeout + 100} millis`) + const timeout = + input.timeout === undefined + ? undefined + : Effect.sleep(`${input.timeout + 100} millis`).pipe( + Effect.map(() => ({ kind: "timeout" as const, code: null })), + ) - const exit = yield* Effect.raceAll([ + const racers = [ handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))), - timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))), - ]) + ...(timeout ? [timeout] : []), + ] + const exit = yield* Effect.raceAll(racers) if (exit.kind === "abort") { aborted = true @@ -594,6 +629,112 @@ export const ShellTool = Tool.define( } }) + const runBackground = Effect.fn("ShellTool.runBackground")(function* ( + input: { + shell: string + command: string + cwd: string + env: NodeJS.ProcessEnv + timeout?: number + description: string + }, + ctx: Tool.Context, + ) { + const outputPath = yield* trunc.write("") + const backgroundCtx: Tool.Context = { + ...ctx, + abort: AbortSignal.any([]), + metadata: () => Effect.void, + } + const info = yield* background.start({ + type: "bash", + title: input.description, + metadata: { + background: true, + command: input.command, + cwd: input.cwd, + outputPath, + sessionID: ctx.sessionID, + }, + run: run(input, backgroundCtx).pipe( + Effect.flatMap((result) => + Effect.gen(function* () { + if (!result.metadata.outputPath) yield* fs.writeFileString(outputPath, result.output).pipe(Effect.orDie) + return JSON.stringify({ + output: result.output, + exit: result.metadata.exit, + outputPath: result.metadata.outputPath ?? outputPath, + }) + }), + ), + ), + }) + + yield* background + .wait({ id: info.id }) + .pipe( + Effect.flatMap((result) => + Effect.gen(function* () { + const final = result.info + const ops = ctx.extra?.promptOps as TaskPromptOps | undefined + if (!final || !ops) return + const parsed = final.output ? JSON.parse(final.output) : {} + yield* ops.prompt({ + sessionID: ctx.sessionID, + agent: ctx.agent, + parts: [ + { + type: "text", + synthetic: true, + text: renderBackgroundOutput({ + jobID: info.id, + command: input.command, + state: final.status, + exit: typeof parsed.exit === "number" || parsed.exit === null ? parsed.exit : undefined, + outputPath: typeof parsed.outputPath === "string" ? parsed.outputPath : outputPath, + text: typeof parsed.output === "string" ? parsed.output : (final.error ?? ""), + }), + }, + ], + }) + }).pipe(Effect.ignore), + ), + Effect.forkIn(scope, { startImmediately: true }), + ) + + yield* ctx.metadata({ + title: input.description, + metadata: { + background: true, + jobId: info.id, + outputPath, + description: input.description, + output: "", + exit: null, + }, + }) + + return { + title: input.description, + metadata: { + background: true, + jobId: info.id, + outputPath, + description: input.description, + output: "", + exit: null, + truncated: false, + }, + output: renderBackgroundOutput({ + jobID: info.id, + command: input.command, + state: "running", + outputPath, + text: BACKGROUND_STARTED, + }), + } + }) + return () => Effect.gen(function* () { const cfg = yield* config.get() @@ -615,7 +756,6 @@ export const ShellTool = Tool.define( if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } - const timeout = params.timeout ?? defaultTimeoutMs const ps = Shell.ps(shell) yield* Effect.scoped( Effect.gen(function* () { @@ -628,16 +768,19 @@ export const ShellTool = Tool.define( }), ) - return yield* run( - { - shell, - command: params.command, - cwd, - env: yield* shellEnv(ctx, cwd), - timeout, - }, - ctx, - ) + const input = { + shell, + command: params.command, + cwd, + env: yield* shellEnv(ctx, cwd), + timeout: + params.run_in_background === true || params.background === true + ? params.timeout + : (params.timeout ?? defaultTimeoutMs), + description: params.command, + } + if (params.run_in_background === true || params.background === true) return yield* runBackground(input, ctx) + return yield* run(input, ctx) }), } }) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index b576b7729767..4d7fa8d29d0d 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -16,6 +16,11 @@ export function parameterSchema() { return Schema.Struct({ command: Schema.String.annotate({ description: "The command to execute" }), timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), + run_in_background: Schema.optional(Schema.Boolean).annotate({ + description: + "Run the command asynchronously and return immediately with a background job id. You will be notified when it completes.", + }), + background: Schema.optional(Schema.Boolean).annotate({ description: "Alias for run_in_background." }), workdir: Schema.optional(Schema.String).annotate({ description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, }), @@ -95,6 +100,7 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. + - Use \`run_in_background: true\` for long-running commands that can continue while you do other work. The tool returns a background job id and output path immediately, and you will be notified when the command completes. Do not poll or sleep while waiting for the notification. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 51ff867ea44d..356532e23a8c 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -20,10 +20,18 @@ exports[`tool parameters JSON Schema (wire shape) bash 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "background": { + "description": "Alias for run_in_background.", + "type": "boolean", + }, "command": { "description": "The command to execute", "type": "string", }, + "run_in_background": { + "description": "Run the command asynchronously and return immediately with a background job id. You will be notified when it completes.", + "type": "boolean", + }, "timeout": { "description": "Optional timeout in milliseconds", "exclusiveMinimum": 0, diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 7b98e721d369..113d65b98b2e 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -20,6 +20,8 @@ import { testEffect } from "../lib/effect" import { Tool } from "@/tool/tool" import { RuntimeFlags } from "@/effect/runtime-flags" import { InstanceStore } from "@/project/instance-store" +import { BackgroundJob } from "@/background/job" +import { BashKillTool } from "@/tool/bash_kill" const shellLayer = Layer.mergeAll( CrossSpawnSpawner.defaultLayer, @@ -29,6 +31,7 @@ const shellLayer = Layer.mergeAll( Config.defaultLayer, Agent.defaultLayer, RuntimeFlags.defaultLayer, + BackgroundJob.defaultLayer, testInstanceStoreLayer, ) const it = testEffect(shellLayer) @@ -43,6 +46,10 @@ const initShell = Effect.fn("ShellToolTest.init")(function* () { }) const initBash = initShell +const initBashKill = Effect.fn("ShellToolTest.initBashKill")(function* () { + const info = yield* BashKillTool + return yield* info.init() +}) const run = Effect.fn("ShellToolTest.run")(function* ( args: Tool.InferParameters, @@ -189,6 +196,48 @@ describe("tool.shell", () => { ), ) + it.live("runs command in background", () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: `${bin} -e ${evalarg('setTimeout(() => console.log("background done"), 50)')}`, + run_in_background: true, + }) + const metadata = result.metadata as Record + expect(metadata.background).toBe(true) + expect(metadata.jobId).toBeString() + expect(result.metadata.outputPath).toBeString() + expect(result.output).toContain("running in the background") + const jobs = yield* BackgroundJob.Service + const waited = yield* jobs.wait({ id: metadata.jobId as string, timeout: 1_000 }) + expect(waited.info?.status).toBe("completed") + const saved = yield* (yield* FSUtil.Service).readFileString(result.metadata.outputPath!) + expect(saved).toContain("background done") + }), + ), + ) + + it.live("bash_kill does not cancel non-bash jobs", () => + runIn( + projectRoot, + Effect.gen(function* () { + const background = yield* BackgroundJob.Service + const kill = yield* initBashKill() + const job = yield* background.start({ + id: "job_nonbash", + type: "task", + run: Effect.never, + }) + const result = yield* kill.execute({ job_id: job.id }, ctx) + const current = yield* background.get(job.id) + expect(result.output).toContain("No background bash job found") + expect(current?.status).toBe("running") + yield* background.cancel(job.id) + }), + ), + ) + it.live("falls back from terminal-only configured shell", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ config: { shell: "fish" } })