Skip to content

Commit f3ad843

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 5d3dba6 commit f3ad843

4 files changed

Lines changed: 162 additions & 7 deletions

File tree

packages/opencode/src/tool/bash.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ async function run(
339339
env: NodeJS.ProcessEnv
340340
timeout: number
341341
description: string
342+
raw?: boolean
342343
},
343344
ctx: Tool.Context,
344345
) {
@@ -378,13 +379,23 @@ async function run(
378379
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
379380
})
380381

381-
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
382-
383-
const exit = yield* Effect.raceAll([
384-
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
382+
// timeout === 0 means no timeout (scripts with plugin callbacks can run indefinitely)
383+
const races: Effect.Effect<{ kind: "exit" | "abort" | "timeout"; code: number | null }>[] = [
384+
handle.exitCode.pipe(
385+
Effect.map((code) => ({ kind: "exit" as const, code })),
386+
Effect.orElseSucceed(() => ({ kind: "exit" as const, code: -1 })),
387+
),
385388
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
386-
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
387-
])
389+
]
390+
if (input.timeout > 0) {
391+
races.push(
392+
Effect.sleep(`${input.timeout + 100} millis`).pipe(
393+
Effect.map(() => ({ kind: "timeout" as const, code: null })),
394+
),
395+
)
396+
}
397+
398+
const exit = yield* Effect.raceAll(races)
388399

389400
if (exit.kind === "abort") {
390401
aborted = true
@@ -419,6 +430,8 @@ async function run(
419430
output: preview(output),
420431
exit: code,
421432
description: input.description,
433+
// Signal Tool.wrap to skip truncation for unbounded plugin commands
434+
...(input.raw && { truncated: false }),
422435
},
423436
output,
424437
}
@@ -461,6 +474,10 @@ export const BashTool = Tool.define("bash", async () => {
461474
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
462475
log.info("bash tool using shell", { shell })
463476

477+
// Collect command names from plugins that need no-timeout (e.g., CLI binaries that
478+
// call back into the AI — their scripts can run indefinitely). Cached at init.
479+
const exempt = new Set((await Plugin.trigger("bash.commands", {}, { noTimeout: [] as string[] })).noTimeout)
480+
464481
return {
465482
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
466483
.replaceAll("${os}", process.platform)
@@ -474,13 +491,23 @@ export const BashTool = Tool.define("bash", async () => {
474491
if (params.timeout !== undefined && params.timeout < 0) {
475492
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
476493
}
477-
const timeout = params.timeout ?? DEFAULT_TIMEOUT
478494
const ps = PS.has(name)
479495
const root = await parse(params.command, ps)
480496
const scan = await collect(root, cwd, ps, shell)
481497
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
482498
await ask(ctx, scan)
483499

500+
// Plugin-registered commands that trigger long-running callbacks need no timeout.
501+
// The bash.commands hook lets plugins declare which command names should disable
502+
// the timeout (e.g., a plugin shipping a CLI binary that calls back into the AI).
503+
const unbounded =
504+
exempt.size > 0 &&
505+
commands(root).some((node) => {
506+
const bin = node.childForFieldName("name") ?? node.firstChild
507+
return bin !== null && exempt.has(bin.text)
508+
})
509+
const timeout = unbounded ? 0 : (params.timeout ?? DEFAULT_TIMEOUT)
510+
484511
return run(
485512
{
486513
shell,
@@ -490,6 +517,7 @@ export const BashTool = Tool.define("bash", async () => {
490517
env: await shellEnv(ctx, cwd),
491518
timeout,
492519
description: params.description,
520+
raw: unbounded,
493521
},
494522
ctx,
495523
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { readFileSync } from "fs"
3+
import { resolve } from "path"
4+
5+
describe("bash shellEnv _server caching", () => {
6+
const src = readFileSync(resolve(import.meta.dir, "../../src/tool/bash.ts"), "utf-8")
7+
8+
// Regression: _server was typed as `typeof import(...) | undefined` and
9+
// assigned with `_server ??= await import(...)`. The ??= across an await
10+
// boundary meant two concurrent shellEnv calls could both enter the import
11+
// path before either resolved — each awaited its own import.
12+
// The fix caches the Promise itself so concurrent callers share one import.
13+
test("_server is typed as Promise (not resolved module)", () => {
14+
// Must cache the promise, not the resolved module
15+
expect(src).toMatch(/let _server:\s*Promise<typeof import/)
16+
})
17+
18+
test("_server assignment does not use await before ??=", () => {
19+
// The old pattern `_server ??= await import(...)` is racy.
20+
// The new pattern caches the promise: `_server ??= import(...)`
21+
// then awaits separately: `const server = await _server`
22+
expect(src).not.toMatch(/_server \?\?= await import/)
23+
expect(src).toContain("_server ??= import(")
24+
})
25+
})

packages/opencode/test/tool/tool-define.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import z from "zod"
33
import { Tool } from "../../src/tool/tool"
44

55
const params = z.object({ input: z.string() })
6+
const defaultArgs = { input: "test" }
67

78
function makeTool(id: string, executeFn?: () => void) {
89
return {
@@ -46,4 +47,98 @@ describe("Tool.define", () => {
4647

4748
expect(first).not.toBe(second)
4849
})
50+
51+
test("validation still works after many init() calls", async () => {
52+
const tool = Tool.define("test-validation", {
53+
description: "validation test",
54+
parameters: z.object({ count: z.number().int().positive() }),
55+
async execute(args) {
56+
return { title: "test", output: String(args.count), metadata: {} }
57+
},
58+
})
59+
60+
for (let i = 0; i < 100; i++) {
61+
await tool.init()
62+
}
63+
64+
const resolved = await tool.init()
65+
66+
const result = await resolved.execute({ count: 42 }, {} as any)
67+
expect(result.output).toBe("42")
68+
69+
await expect(resolved.execute({ count: -1 }, {} as any)).rejects.toThrow("invalid arguments")
70+
})
71+
72+
test("skips truncation when metadata.truncated is already set to false", async () => {
73+
const big = "x".repeat(200_000)
74+
const tool = Tool.define("test-raw", {
75+
description: "raw output tool",
76+
parameters: params,
77+
async execute() {
78+
return { title: "test", output: big, metadata: { truncated: false } }
79+
},
80+
})
81+
82+
const resolved = await tool.init()
83+
const result = await resolved.execute(defaultArgs, {} as any)
84+
85+
// The wrap() layer should skip Truncate.output() because truncated is already defined
86+
expect(result.output).toBe(big)
87+
expect(result.metadata.truncated).toBe(false)
88+
})
89+
90+
test("applies truncation when metadata.truncated is undefined", async () => {
91+
const big = "x".repeat(200_000)
92+
const tool = Tool.define("test-truncate", {
93+
description: "truncated output tool",
94+
parameters: params,
95+
async execute() {
96+
return { title: "test", output: big, metadata: {} as Record<string, any> }
97+
},
98+
})
99+
100+
const resolved = await tool.init()
101+
const result = await resolved.execute(defaultArgs, {} as any)
102+
103+
// The wrap() layer should apply Truncate.output() because truncated is undefined
104+
expect(result.metadata.truncated).toBe(true)
105+
expect(result.output.length).toBeLessThan(big.length)
106+
})
107+
108+
test("skips truncation when metadata.truncated is false and output is small", async () => {
109+
const small = "hello world"
110+
const tool = Tool.define("test-raw-small", {
111+
description: "small raw output tool",
112+
parameters: params,
113+
async execute() {
114+
return { title: "test", output: small, metadata: { truncated: false } }
115+
},
116+
})
117+
118+
const resolved = await tool.init()
119+
const result = await resolved.execute(defaultArgs, {} as any)
120+
121+
// truncated: false should be preserved even for small output
122+
expect(result.output).toBe(small)
123+
expect(result.metadata.truncated).toBe(false)
124+
})
125+
126+
test("skips truncation when metadata.truncated is already set to true", async () => {
127+
const big = "x".repeat(200_000)
128+
const tool = Tool.define("test-raw-true", {
129+
description: "pre-truncated output tool",
130+
parameters: params,
131+
async execute() {
132+
return { title: "test", output: big, metadata: { truncated: true, outputPath: "/tmp/out.txt" } }
133+
},
134+
})
135+
136+
const resolved = await tool.init()
137+
const result = await resolved.execute(defaultArgs, {} as any)
138+
139+
// When truncated is already true, wrap() should NOT re-truncate
140+
expect(result.output).toBe(big)
141+
expect(result.metadata.truncated).toBe(true)
142+
expect(result.metadata.outputPath).toBe("/tmp/out.txt")
143+
})
49144
})

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)