diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ead3a0149b4..385283db252 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1023,6 +1023,15 @@ export namespace Config { .object({ command: z.string().array(), environment: z.record(z.string(), z.string()).optional(), + timeout: z.number().optional().describe("Timeout in milliseconds (default: 30000)"), + }) + .array() + .optional(), + input_required: z + .object({ + command: z.string().array(), + environment: z.record(z.string(), z.string()).optional(), + timeout: z.number().optional().describe("Timeout in milliseconds (default: 30000)"), }) .array() .optional(), diff --git a/packages/opencode/src/hook/index.ts b/packages/opencode/src/hook/index.ts new file mode 100644 index 00000000000..ccfa127ba6d --- /dev/null +++ b/packages/opencode/src/hook/index.ts @@ -0,0 +1,239 @@ +import { Bus } from "@/bus" +import { Config } from "@/config/config" +import { Instance } from "@/project/instance" +import { PermissionNext } from "@/permission/next" +import { Question } from "@/question" +import { Session } from "@/session" +import { SessionStatus } from "@/session/status" +import { Shell } from "@/shell/shell" +import { Log } from "@/util/log" +import { spawn } from "child_process" + +const DEFAULT_TIMEOUT_MS = 30_000 +const INPUT_REQUIRED_THROTTLE_MS = 1_000 +const MAX_STDERR = 8 * 1024 + +export namespace Hook { + const log = Log.create({ service: "hook" }) + + // Track previous status per session to detect transitions + const previousStatus = new Map() + + // Global in-flight flag: mute new triggers while a hook run is active (shared between session_completed and input_required) + let running = false + + // Leading throttle window for input_required hooks + let inputRequiredMuteUntil = 0 + + export function init() { + log.info("init") + initSessionCompleted() + initInputRequired() + } + + function initSessionCompleted() { + Bus.subscribe(SessionStatus.Event.Status, async (evt) => { + const { sessionID, status } = evt.properties + const prevType = previousStatus.get(sessionID) + + log.info("status event", { sessionID, status: status.type, prevType }) + + // Update tracked status (delete on idle to avoid memory leak) + if (status.type === "idle") { + previousStatus.delete(sessionID) + } else { + previousStatus.set(sessionID, status.type) + } + + // Only trigger on non-idle → idle transitions + if (status.type !== "idle") return + if (prevType === undefined || prevType === "idle") return // Was already idle or unknown + if (running) return // Mute while another hook run is in progress + + log.info("triggering session_completed hooks", { sessionID, prevType }) + + const config = await Config.get().catch(() => undefined) + if (!config) return + const hooks = config.experimental?.hook?.session_completed + if (!hooks?.length) { + log.info("no session_completed hooks configured") + return + } + + log.info("running session_completed hooks", { count: hooks.length }) + + running = true + try { + const session = await Session.get(sessionID).catch(() => undefined) + for (const hook of hooks) { + log.info("executing hook", { command: hook.command }) + await runHook(hook, { + SESSION_ID: sessionID, + SESSION_TITLE: session?.title ?? "", + PROJECT_DIR: Instance.directory, + OPENCODE_CWD: Instance.directory, + }) + } + } finally { + running = false + } + }) + } + + function initInputRequired() { + // Subscribe to permission.asked + Bus.subscribe(PermissionNext.Event.Asked, async (evt) => { + log.info("permission.asked event", { sessionID: evt.properties.sessionID, permission: evt.properties.permission }) + scheduleInputRequired({ + sessionID: evt.properties.sessionID, + inputType: "permission_required", + permissionName: evt.properties.permission, + }) + }) + + // Subscribe to question.asked + Bus.subscribe(Question.Event.Asked, async (evt) => { + log.info("question.asked event", { sessionID: evt.properties.sessionID }) + scheduleInputRequired({ + sessionID: evt.properties.sessionID, + inputType: "question_asked", + questionHeader: evt.properties.questions[0]?.header, + }) + }) + } + + function scheduleInputRequired(input: { + sessionID: string + inputType: "permission_required" | "question_asked" + permissionName?: string + questionHeader?: string + }) { + // Skip if hooks are currently running (shared lock) + if (running) { + log.info("input_required muted, hooks running") + return + } + + const now = Date.now() + if (now < inputRequiredMuteUntil) { + log.info("input_required muted", { + reason: "throttle", + now, + until: inputRequiredMuteUntil, + inputType: input.inputType, + }) + return + } + + inputRequiredMuteUntil = now + INPUT_REQUIRED_THROTTLE_MS + void triggerInputRequired(input) + } + + async function triggerInputRequired(input: { + sessionID: string + inputType: "permission_required" | "question_asked" + permissionName?: string + questionHeader?: string + }) { + const config = await Config.get().catch(() => undefined) + if (!config) return + + const hooks = config.experimental?.hook?.input_required + if (!hooks?.length) { + log.info("no input_required hooks configured") + return + } + + log.info("running input_required hooks", { count: hooks.length, inputType: input.inputType }) + + running = true + try { + const session = await Session.get(input.sessionID).catch(() => undefined) + for (const hook of hooks) { + log.info("executing input_required hook", { command: hook.command }) + + const extraEnv: Record = { + SESSION_ID: input.sessionID, + SESSION_TITLE: session?.title ?? "", + PROJECT_DIR: Instance.directory, + OPENCODE_CWD: Instance.directory, + INPUT_TYPE: input.inputType, + } + + if (input.inputType === "permission_required" && input.permissionName) { + extraEnv.PERMISSION_NAME = input.permissionName + } + if (input.inputType === "question_asked" && input.questionHeader) { + extraEnv.QUESTION_HEADER = input.questionHeader + } + + await runHook(hook, extraEnv) + } + } finally { + running = false + } + } + + async function runHook( + hook: { command: string[]; environment?: Record; timeout?: number }, + extraEnv: Record, + ) { + const [cmd, ...args] = hook.command + const proc = spawn(cmd, args, { + cwd: Instance.directory, + env: { ...process.env, ...hook.environment, ...extraEnv }, + stdio: ["ignore", "ignore", "pipe"], + detached: process.platform !== "win32", + }) + + let stderr = "" + let exited = false + let timedOut = false + + proc.stderr?.on("data", (chunk: Buffer) => { + if (stderr.length < MAX_STDERR) { + stderr += chunk.toString() + } + }) + + const kill = () => Shell.killTree(proc, { exited: () => exited }) + + const timeoutMs = hook.timeout ?? DEFAULT_TIMEOUT_MS + const timer = setTimeout(() => { + timedOut = true + void kill() + }, timeoutMs) + + const exit = await new Promise((resolve) => { + proc.once("exit", (code) => { + exited = true + clearTimeout(timer) + resolve(code) + }) + proc.once("error", () => { + exited = true + clearTimeout(timer) + resolve(null) + }) + }) + + if (exit !== 0 || timedOut) { + const truncated = stderr.length > MAX_STDERR ? stderr.slice(0, MAX_STDERR) + "..." : stderr + log.warn("hook failed", { + command: hook.command, + exit, + timedOut, + stderr: truncated, + }) + } + } + + // Test helpers + export const _test = { + reset: () => { + previousStatus.clear() + running = false + inputRequiredMuteUntil = 0 + }, + } +} diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 56fe4d13e66..ce8c4edb647 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -11,6 +11,7 @@ import { Instance } from "./instance" import { Vcs } from "./vcs" import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" +import { Hook } from "../hook" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) @@ -22,6 +23,7 @@ export async function InstanceBootstrap() { FileWatcher.init() File.init() Vcs.init() + Hook.init() Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { diff --git a/packages/opencode/test/hook/hook.test.ts b/packages/opencode/test/hook/hook.test.ts new file mode 100644 index 00000000000..b74e2bb658d --- /dev/null +++ b/packages/opencode/test/hook/hook.test.ts @@ -0,0 +1,311 @@ +import { test, expect, beforeEach, afterEach } from "bun:test" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config/config" +import { Hook } from "../../src/hook" +import { Instance } from "../../src/project/instance" +import { PermissionNext } from "../../src/permission/next" +import { Question } from "../../src/question" +import { SessionStatus } from "../../src/session/status" +import { tmpdir } from "../fixture/fixture" +import path from "path" +import fs from "fs/promises" + +// Helper to wait for async operations +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +// Test hook that writes to a file instead of showing notifications +async function createTestHook(dir: string, filename: string) { + const scriptPath = path.join(dir, `test-hook-${filename}.sh`) + const outputPath = path.join(dir, filename) + await fs.writeFile( + scriptPath, + `#!/bin/bash +echo "SESSION_ID=$SESSION_ID" >> "${outputPath}" +echo "INPUT_TYPE=$INPUT_TYPE" >> "${outputPath}" +echo "PERMISSION_NAME=$PERMISSION_NAME" >> "${outputPath}" +echo "QUESTION_HEADER=$QUESTION_HEADER" >> "${outputPath}" +echo "---" >> "${outputPath}" +`, + ) + await fs.chmod(scriptPath, 0o755) + return { scriptPath, outputPath } +} + +async function readHookOutput(outputPath: string): Promise { + const content = await fs.readFile(outputPath, "utf-8").catch(() => "") + return content.split("---\n").filter(Boolean) +} + +beforeEach(() => { + Hook._test.reset() +}) + +afterEach(() => { + Hook._test.reset() +}) + +test("session_completed hook triggers on busy -> idle transition", async () => { + await using tmp = await tmpdir({ git: true }) + const { scriptPath, outputPath } = await createTestHook(tmp.path, "session-completed.txt") + + // Write config before Instance.provide + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + experimental: { + hook: { + session_completed: [{ command: [scriptPath] }], + }, + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Pre-load config to avoid slow first load during hook execution + await Config.get() + + Hook.init() + + // Simulate busy -> idle transition + SessionStatus.set("session_test1", { type: "busy" }) + SessionStatus.set("session_test1", { type: "idle" }) + + // Wait for hook to execute (config loading + execution) + await sleep(1500) + + const outputs = await readHookOutput(outputPath) + expect(outputs.length).toBe(1) + expect(outputs[0]).toContain("SESSION_ID=session_test1") + expect(outputs[0]).toContain("INPUT_TYPE=") + }, + }) +}) + +test("session_completed hook does not trigger on idle -> idle", async () => { + await using tmp = await tmpdir({ git: true }) + const { scriptPath, outputPath } = await createTestHook(tmp.path, "no-trigger.txt") + + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + experimental: { + hook: { + session_completed: [{ command: [scriptPath] }], + }, + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + Hook.init() + + // Only idle events (no busy first) + SessionStatus.set("session_test2", { type: "idle" }) + SessionStatus.set("session_test2", { type: "idle" }) + + await sleep(500) + + const outputs = await readHookOutput(outputPath) + expect(outputs.length).toBe(0) + }, + }) +}) + +test("input_required hook triggers on permission.asked", async () => { + await using tmp = await tmpdir({ git: true }) + const { scriptPath, outputPath } = await createTestHook(tmp.path, "permission.txt") + + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + experimental: { + hook: { + input_required: [{ command: [scriptPath] }], + }, + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + Hook.init() + + // Simulate permission.asked event + Bus.publish(PermissionNext.Event.Asked, { + id: "permission_test1", + sessionID: "session_test3", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + }) + + // Wait for hook to execute (leading throttle fires immediately) + await sleep(1500) + + const outputs = await readHookOutput(outputPath) + expect(outputs.length).toBe(1) + expect(outputs[0]).toContain("SESSION_ID=session_test3") + expect(outputs[0]).toContain("INPUT_TYPE=permission_required") + expect(outputs[0]).toContain("PERMISSION_NAME=bash") + }, + }) +}) + +test("input_required hook triggers on question.asked", async () => { + await using tmp = await tmpdir({ git: true }) + const { scriptPath, outputPath } = await createTestHook(tmp.path, "question.txt") + + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + experimental: { + hook: { + input_required: [{ command: [scriptPath] }], + }, + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + Hook.init() + + // Simulate question.asked event + Bus.publish(Question.Event.Asked, { + id: "question_test1", + sessionID: "session_test4", + questions: [{ question: "Which option?", header: "Choice", options: [] }], + }) + + // Wait for hook to execute + await sleep(1500) + + const outputs = await readHookOutput(outputPath) + expect(outputs.length).toBe(1) + expect(outputs[0]).toContain("SESSION_ID=session_test4") + expect(outputs[0]).toContain("INPUT_TYPE=question_asked") + expect(outputs[0]).toContain("QUESTION_HEADER=Choice") + }, + }) +}) + +test("input_required hook throttles multiple rapid events (first wins)", async () => { + await using tmp = await tmpdir({ git: true }) + const { scriptPath, outputPath } = await createTestHook(tmp.path, "throttle.txt") + + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + experimental: { + hook: { + input_required: [{ command: [scriptPath] }], + }, + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + Hook.init() + + // Rapid fire multiple permission events + Bus.publish(PermissionNext.Event.Asked, { + id: "permission_throttle1", + sessionID: "session_test5", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + }) + + await sleep(100) + + Bus.publish(PermissionNext.Event.Asked, { + id: "permission_throttle2", + sessionID: "session_test5", + permission: "edit", + patterns: ["foo.ts"], + metadata: {}, + always: [], + }) + + await sleep(100) + + Bus.publish(PermissionNext.Event.Asked, { + id: "permission_throttle3", + sessionID: "session_test5", + permission: "read", + patterns: ["bar.ts"], + metadata: {}, + always: [], + }) + + // Wait for hook to execute + await sleep(1500) + + const outputs = await readHookOutput(outputPath) + // Should only have ONE output due to throttle (first one wins) + expect(outputs.length).toBe(1) + expect(outputs[0]).toContain("PERMISSION_NAME=bash") + }, + }) +}) + +test("_test.reset clears all state", async () => { + await using tmp = await tmpdir({ git: true }) + const { scriptPath, outputPath } = await createTestHook(tmp.path, "reset.txt") + + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + experimental: { + hook: { + session_completed: [{ command: [scriptPath] }], + }, + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + Hook.init() + + // Set some state + SessionStatus.set("session_reset1", { type: "busy" }) + + // Reset + Hook._test.reset() + + // Re-init after reset + Hook.init() + + // This should NOT trigger because we don't have previous busy state recorded after reset + SessionStatus.set("session_reset1", { type: "idle" }) + + await sleep(500) + + const outputs = await readHookOutput(outputPath) + expect(outputs.length).toBe(0) + }, + }) +})