From 4b9bbdd0f16761b21a966983aa409a38689b8550 Mon Sep 17 00:00:00 2001 From: micu Date: Tue, 7 Apr 2026 11:13:50 +0200 Subject: [PATCH] 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. --- packages/opencode/.opencode/package-lock.json | 43 +++++++++++++++++++ packages/opencode/src/tool/bash.ts | 40 ++++++++++++++--- packages/plugin/src/index.ts | 7 +++ 3 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/.opencode/package-lock.json diff --git a/packages/opencode/.opencode/package-lock.json b/packages/opencode/.opencode/package-lock.json new file mode 100644 index 000000000000..6d31f522038e --- /dev/null +++ b/packages/opencode/.opencode/package-lock.json @@ -0,0 +1,43 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "*" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.3.7", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.3.7", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.92", + "@opentui/solid": ">=0.1.92" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.3.7", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index eb49159f16dc..7a79ab815611 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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])) @@ -378,6 +380,7 @@ export const BashTool = Tool.define( env: NodeJS.ProcessEnv timeout: number description: string + raw?: boolean }, ctx: Tool.Context, ) { @@ -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 @@ -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, } @@ -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, @@ -495,6 +520,7 @@ export const BashTool = Tool.define( env: yield* shellEnv(ctx, cwd), timeout, description: params.description, + raw: unbounded, }, ctx, ) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 1afb55daa76e..c3d2fed91353 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -237,6 +237,13 @@ export interface Hooks { input: { cwd: string; sessionID?: string; callID?: string }, output: { env: Record }, ) => Promise + /** + * 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 "tool.execute.after"?: ( input: { tool: string; sessionID: string; callID: string; args: any }, output: {