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
43 changes: 43 additions & 0 deletions packages/opencode/.opencode/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 33 additions & 7 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ export const BashTool = Tool.define(
const fs = yield* AppFileSystem.Service
const plugin = yield* Plugin.Service

const exempt = new Set((yield* plugin.trigger("bash.commands", {}, { noTimeout: [] as string[] })).noTimeout)

const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
const lines = yield* spawner
.lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))
Expand Down Expand Up @@ -378,6 +380,7 @@ export const BashTool = Tool.define(
env: NodeJS.ProcessEnv
timeout: number
description: string
raw?: boolean
},
ctx: Tool.Context,
) {
Expand Down Expand Up @@ -415,13 +418,23 @@ export const BashTool = Tool.define(
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
})

const timeout = Effect.sleep(`${input.timeout + 100} millis`)

const exit = yield* Effect.raceAll([
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
// timeout === 0 means no timeout (scripts with plugin callbacks can run indefinitely)
const races: Effect.Effect<{ kind: "exit" | "abort" | "timeout"; code: number | null }>[] = [
handle.exitCode.pipe(
Effect.map((code) => ({ kind: "exit" as const, code })),
Effect.orElseSucceed(() => ({ kind: "exit" as const, code: -1 })),
),
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])
]
if (input.timeout > 0) {
races.push(
Effect.sleep(`${input.timeout + 100} millis`).pipe(
Effect.map(() => ({ kind: "timeout" as const, code: null })),
),
)
}

const exit = yield* Effect.raceAll(races)

if (exit.kind === "abort") {
aborted = true
Expand Down Expand Up @@ -449,6 +462,8 @@ export const BashTool = Tool.define(
output: preview(output),
exit: code,
description: input.description,
// Signal Tool.wrap to skip truncation for unbounded plugin commands
...(input.raw && { truncated: false }),
},
output,
}
Expand Down Expand Up @@ -479,13 +494,23 @@ export const BashTool = 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 ?? DEFAULT_TIMEOUT
const ps = PS.has(name)
const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
yield* ask(ctx, scan)

// Plugin-registered commands that trigger long-running callbacks need no timeout.
// The bash.commands hook lets plugins declare which command names should disable
// the timeout (e.g., a plugin shipping a CLI binary that calls back into the AI).
const unbounded =
exempt.size > 0 &&
commands(root).some((node) => {
const bin = node.childForFieldName("name") ?? node.firstChild
return bin !== null && exempt.has(bin.text)
})
const timeout = unbounded ? 0 : (params.timeout ?? DEFAULT_TIMEOUT)

return yield* run(
{
shell,
Expand All @@ -495,6 +520,7 @@ export const BashTool = Tool.define(
env: yield* shellEnv(ctx, cwd),
timeout,
description: params.description,
raw: unbounded,
},
ctx,
)
Expand Down
7 changes: 7 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@ export interface Hooks {
input: { cwd: string; sessionID?: string; callID?: string },
output: { env: Record<string, string> },
) => Promise<void>
/**
* Register command names that should disable bash timeout.
* Plugins shipping CLI binaries that call back into OpenCode should
* register their command names here so scripts using them can run
* indefinitely instead of being killed after the default 2-minute timeout.
*/
"bash.commands"?: (input: {}, output: { noTimeout: string[] }) => Promise<void>
"tool.execute.after"?: (
input: { tool: string; sessionID: string; callID: string; args: any },
output: {
Expand Down
Loading