Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions packages/opencode/src/tool/bash_kill.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Parameters>) =>
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}.`,
}
}),
}
}),
)
4 changes: 4 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -220,6 +223,7 @@ export const layer = Layer.effect(
tool.invalid,
...(questionEnabled ? [tool.question] : []),
tool.shell,
tool.bashkill,
tool.read,
tool.glob,
tool.grep,
Expand Down
177 changes: 160 additions & 17 deletions packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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 [
`<bash_background job_id="${input.jobID}" state="${input.state}">`,
`<command>${input.command}</command>`,
...(input.exit === undefined ? [] : [`<exit_code>${input.exit}</exit_code>`]),
...(input.outputPath ? [`<output_path>${input.outputPath}</output_path>`] : []),
"<result>",
input.text,
"</result>",
"</bash_background>",
].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, {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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* () {
Expand All @@ -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)
}),
}
})
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/tool/shell/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
}),
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading