Skip to content

Commit 4b9bbdd

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 2719063 commit 4b9bbdd

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
}
@@ -479,13 +494,23 @@ export const BashTool = Tool.define(
479494
if (params.timeout !== undefined && params.timeout < 0) {
480495
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
481496
}
482-
const timeout = params.timeout ?? DEFAULT_TIMEOUT
483497
const ps = PS.has(name)
484498
const root = yield* parse(params.command, ps)
485499
const scan = yield* collect(root, cwd, ps, shell)
486500
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
487501
yield* ask(ctx, scan)
488502

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

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)