Skip to content

Commit 3db028b

Browse files
committed
feat(plugin): bash.commands hook for plugin CLI timeout exemption
Plugins that ship CLI binaries calling back into OpenCode can register their command names via the bash.commands hook. Matching commands run with timeout=0 (no kill) and skip output truncation so long-running script callbacks aren't terminated prematurely. The exempt set is cached once at BashTool init.
1 parent 3729fd5 commit 3db028b

3 files changed

Lines changed: 83 additions & 7 deletions

File tree

packages/opencode/.opencode/package-lock.json

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/opencode/src/tool/bash.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,8 @@ export const BashTool = Tool.define(
297297
const fs = yield* AppFileSystem.Service
298298
const plugin = yield* Plugin.Service
299299

300+
const exempt = new Set((yield* plugin.trigger("bash.commands", {}, { noTimeout: [] as string[] })).noTimeout)
301+
300302
const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
301303
const lines = yield* spawner
302304
.lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))
@@ -378,6 +380,7 @@ export const BashTool = Tool.define(
378380
env: NodeJS.ProcessEnv
379381
timeout: number
380382
description: string
383+
raw?: boolean
381384
},
382385
ctx: Tool.Context,
383386
) {
@@ -415,13 +418,23 @@ export const BashTool = Tool.define(
415418
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
416419
})
417420

418-
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
419-
420-
const exit = yield* Effect.raceAll([
421-
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
421+
// timeout === 0 means no timeout (scripts with plugin callbacks can run indefinitely)
422+
const races: Effect.Effect<{ kind: "exit" | "abort" | "timeout"; code: number | null }>[] = [
423+
handle.exitCode.pipe(
424+
Effect.map((code) => ({ kind: "exit" as const, code })),
425+
Effect.orElseSucceed(() => ({ kind: "exit" as const, code: -1 })),
426+
),
422427
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
423-
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
424-
])
428+
]
429+
if (input.timeout > 0) {
430+
races.push(
431+
Effect.sleep(`${input.timeout + 100} millis`).pipe(
432+
Effect.map(() => ({ kind: "timeout" as const, code: null })),
433+
),
434+
)
435+
}
436+
437+
const exit = yield* Effect.raceAll(races)
425438

426439
if (exit.kind === "abort") {
427440
aborted = true
@@ -449,6 +462,8 @@ export const BashTool = Tool.define(
449462
output: preview(output),
450463
exit: code,
451464
description: input.description,
465+
// Signal Tool.wrap to skip truncation for unbounded plugin commands
466+
...(input.raw && { truncated: false }),
452467
},
453468
output,
454469
}
@@ -480,13 +495,23 @@ export const BashTool = Tool.define(
480495
if (params.timeout !== undefined && params.timeout < 0) {
481496
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
482497
}
483-
const timeout = params.timeout ?? DEFAULT_TIMEOUT
484498
const ps = PS.has(name)
485499
const root = yield* parse(params.command, ps)
486500
const scan = yield* collect(root, cwd, ps, shell)
487501
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
488502
yield* ask(ctx, scan)
489503

504+
// Plugin-registered commands that trigger long-running callbacks need no timeout.
505+
// The bash.commands hook lets plugins declare which command names should disable
506+
// the timeout (e.g., a plugin shipping a CLI binary that calls back into the AI).
507+
const unbounded =
508+
exempt.size > 0 &&
509+
commands(root).some((node) => {
510+
const bin = node.childForFieldName("name") ?? node.firstChild
511+
return bin !== null && exempt.has(bin.text)
512+
})
513+
const timeout = unbounded ? 0 : (params.timeout ?? DEFAULT_TIMEOUT)
514+
490515
return yield* run(
491516
{
492517
shell,
@@ -496,6 +521,7 @@ export const BashTool = Tool.define(
496521
env: yield* shellEnv(ctx, cwd),
497522
timeout,
498523
description: params.description,
524+
raw: unbounded,
499525
},
500526
ctx,
501527
)

packages/plugin/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,13 @@ export interface Hooks {
237237
input: { cwd: string; sessionID?: string; callID?: string },
238238
output: { env: Record<string, string> },
239239
) => Promise<void>
240+
/**
241+
* Register command names that should disable bash timeout.
242+
* Plugins shipping CLI binaries that call back into OpenCode should
243+
* register their command names here so scripts using them can run
244+
* indefinitely instead of being killed after the default 2-minute timeout.
245+
*/
246+
"bash.commands"?: (input: {}, output: { noTimeout: string[] }) => Promise<void>
240247
"tool.execute.after"?: (
241248
input: { tool: string; sessionID: string; callID: string; args: any },
242249
output: {

0 commit comments

Comments
 (0)