Skip to content
Closed
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
Binary file added packages/opencode/openapi.json
Binary file not shown.
1 change: 1 addition & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"),
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
experimentalBackgroundShell: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SHELL"),
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@ export const SessionListQuery = Schema.Struct({
archived: Schema.optional(QueryBoolean),
})

const BackgroundJobStatus = Schema.Union([
Schema.Literal("running"),
Schema.Literal("completed"),
Schema.Literal("error"),
Schema.Literal("cancelled"),
])

const BackgroundJobInfo = Schema.Struct({
id: Schema.String,
type: Schema.String,
title: Schema.optional(Schema.String),
status: BackgroundJobStatus,
startedAt: Schema.Number,
completedAt: Schema.optional(Schema.Number),
Comment on lines +102 to +103
output: Schema.optional(Schema.String),
error: Schema.optional(Schema.String),
command: Schema.optional(Schema.String),
background: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "BackgroundJobInfo" })

const BackgroundJobList = Schema.Array(BackgroundJobInfo).annotate({ identifier: "BackgroundJobList" })

export const ExperimentalPaths = {
capabilities: "/experimental/capabilities",
console: "/experimental/console",
Expand All @@ -98,6 +120,8 @@ export const ExperimentalPaths = {
worktreeReset: "/experimental/worktree/reset",
session: "/experimental/session",
sessionBackground: "/experimental/session/:sessionID/background",
jobs: "/experimental/jobs",
jobCancel: "/experimental/jobs/:jobID",
resource: "/experimental/resource",
} as const

Expand Down Expand Up @@ -245,6 +269,27 @@ export const ExperimentalApi = HttpApi.make("experimental")
"Detach any synchronous subagents currently blocking the session and continue them in the background.",
}),
),
HttpApiEndpoint.get("jobs", ExperimentalPaths.jobs, {
query: WorkspaceRoutingQuery,
success: described(BackgroundJobList, "List of background jobs"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.background.list",
summary: "List background jobs",
description: "Get all background jobs running in the current instance.",
}),
),
HttpApiEndpoint.post("jobCancel", ExperimentalPaths.jobCancel, {
params: { jobID: Schema.String },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Cancelled background job"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.background.cancel",
summary: "Cancel a background job",
description: "Cancel a running background job by id.",
}),
),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
query: WorkspaceRoutingQuery,
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,27 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
return promoted.some((job) => job !== undefined)
})

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 jobCancel = Effect.fn("ExperimentalHttpApi.jobCancel")(function* (ctx: { params: { jobID: string } }) {
const cancelled = yield* background.cancel(ctx.params.jobID)
return cancelled !== undefined
})

const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
Expand All @@ -188,6 +209,8 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.handle("sessionBackground", sessionBackground)
.handle("jobs", jobs)
.handle("jobCancel", jobCancel)
.handle("resource", resource)
}),
)
189 changes: 156 additions & 33 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,9 +21,22 @@ 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 { Identifier } from "@/id/id"
import { Database } from "@opencode-ai/core/database/database"
import { MessageV2 } from "@/session/message-v2"
import type { TaskPromptOps } from "./task"
import type { SessionPrompt } from "@/session/prompt"
import { SessionID } from "@/session/schema"

export { Parameters } from "./shell/prompt"

const BACKGROUND_STARTED = [
"The command is running in the background. You will be notified automatically when it finishes.",
"DO NOT sleep, poll for progress, or duplicate this command's work.",
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
].join("\n")

const MAX_METADATA_LENGTH = 30_000
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const FILES = new Set([
Expand Down Expand Up @@ -254,6 +267,17 @@ function tail(text: string, maxLines: number, maxBytes: number) {
}
}

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

function defaultTimeoutFor(command: string, fallback: number) {
if (NETWORK_COMMAND.test(command)) return Math.min(fallback, 15_000)
if (NETWORK_GIT.test(command)) return Math.min(fallback, 15_000)
if (BUILD_COMMAND.test(command)) return Math.max(fallback, 300_000)
return fallback
}

const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) {
const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))
if (!tree) throw new Error("Failed to parse command")
Expand Down Expand Up @@ -344,6 +368,9 @@ 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 database = yield* Database.Service
const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000

const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
Expand Down Expand Up @@ -446,6 +473,7 @@ export const ShellTool = Tool.define(
let cut = false
let expired = false
let aborted = false
let exited = false

const closeSink = Effect.fnUntraced(function* () {
const stream = sink
Expand Down Expand Up @@ -482,6 +510,9 @@ export const ShellTool = Tool.define(
Effect.gen(function* () {
yield* Effect.addFinalizer(closeSink)
const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))
yield* Effect.addFinalizer(() =>
exited ? Effect.void : handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie),
)

yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
Expand Down Expand Up @@ -545,6 +576,8 @@ export const ShellTool = Tool.define(
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])

if (exit.kind === "exit") exited = true

if (exit.kind === "abort") {
aborted = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
Expand Down Expand Up @@ -603,42 +636,132 @@ export const ShellTool = Tool.define(
const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs)
yield* Effect.logInfo("shell tool using shell", { shell })

const renderBackgroundOutput = (
jobID: string,
state: "running" | "completed" | "error",
exit?: number | null,
text?: string,
) => {
const header = `<shell job="${jobID}" state="${state}" exit="${exit ?? "null"}">`
const tag = state === "error" ? "shell_error" : "shell_result"
return [header, `<${tag}>`, text ?? "", `</${tag}>`, "</shell>"].join("\n")
}

const injectBackgroundResult = Effect.fn("ShellTool.injectBackgroundResult")(function* (
ctx: Tool.Context,
ops: TaskPromptOps,
jobID: string,
info: BackgroundJob.Info | undefined,
) {
const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe(
Effect.provideService(Database.Service, database),
Effect.orDie,
)
const variant = msg.info.role !== "assistant" ? undefined : msg.info.variant
const state =
info?.status === "error"
? "error"
: /state="error"/.test(info?.output ?? "") ? "error" : "completed"
const text = info?.status === "error" ? (info.error ?? "") : (info?.output ?? "")
yield* ops
.prompt({
sessionID: ctx.sessionID,
agent: ctx.agent,
variant,
parts: [{ type: "text", synthetic: true, text: renderBackgroundOutput(jobID, state, null, text) }],
})
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
})

const executeShell = Effect.fn("ShellTool.execute")(function* (params: Parameters, ctx: Tool.Context) {
const instanceCtx = yield* InstanceState.context
const cwd = params.workdir
? yield* resolvePath(params.workdir, instanceCtx.directory, shell)
: instanceCtx.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const ps = Shell.ps(shell)
yield* Effect.scoped(
Effect.gen(function* () {
const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>
Effect.sync(() => tree.delete()),
)
const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx)
if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd)
yield* ask(ctx, scan, params)
}),
)

const runInBackground = params.background === true
const timeout =
params.timeout ?? (runInBackground ? defaultTimeoutMs : defaultTimeoutFor(params.command, defaultTimeoutMs))

if (!runInBackground) {
return yield* run(
{ shell, command: params.command, cwd, env: yield* shellEnv(ctx, cwd), timeout },
ctx,
)
}

if (!flags.experimentalBackgroundShell) {
return yield* Effect.fail(
new Error("Background shell requires OPENCODE_EXPERIMENTAL_BACKGROUND_SHELL=true"),
)
}

const ops = ctx.extra?.promptOps as TaskPromptOps | undefined
if (!ops) {
return yield* Effect.fail(new Error("ShellTool background requires promptOps in ctx.extra"))
}

const jobID = Identifier.ascending("job")
const metadata = { command: params.command, cwd, background: true }

// The background run must outlive the originating tool call: the AI SDK
// aborts the tool-call signal when the call's lifecycle ends, which would
// kill a child process still listening on `ctx.abort`. Give the background
// task its own signal so only an explicit `background.cancel` interrupts it.
const bgAbort = new AbortController()
const bgCtx = { ...ctx, abort: bgAbort.signal }

yield* background.start({
id: jobID,
type: ShellID.ToolID,
title: params.command,
metadata,
onPromote: Effect.all([
ctx.metadata({ title: params.command, metadata: { ...metadata, jobId: jobID } }),
background.wait({ id: jobID }).pipe(
Effect.flatMap((waited) => injectBackgroundResult(ctx, ops, jobID, waited.info)),
Effect.forkIn(scope, { startImmediately: true }),
),
]),
run: run({ shell, command: params.command, cwd, env: yield* shellEnv(ctx, cwd), timeout }, bgCtx).pipe(
Effect.map((result) =>
renderBackgroundOutput(
jobID,
result.metadata.exit === 0 ? "completed" : "error",
result.metadata.exit,
result.output,
),
),
Effect.onInterrupt(() => background.cancel(jobID)),
),
})

return {
title: params.command,
metadata: { ...metadata, jobId: jobID, output: "", exit: null, truncated: false },
output: renderBackgroundOutput(jobID, "running", null, BACKGROUND_STARTED),
}
})

return {
description: prompt.description,
parameters: prompt.parameters,
execute: (params: Parameters, ctx: Tool.Context) =>
Effect.gen(function* () {
const instanceCtx = yield* InstanceState.context
const cwd = params.workdir
? yield* resolvePath(params.workdir, instanceCtx.directory, shell)
: instanceCtx.directory
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* () {
const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>
Effect.sync(() => tree.delete()),
)
const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx)
if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd)
yield* ask(ctx, scan, params)
}),
)

return yield* run(
{
shell,
command: params.command,
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
},
ctx,
)
}),
executeShell(params, ctx).pipe(Effect.orDie),
}
})
}),
Expand Down
Loading
Loading