From 38ecba53a2808e2794aef58f58623746f525a0af Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 00:53:08 -0400 Subject: [PATCH 1/6] feat(core): run session start hooks --- .../__tests__/output-manager.hooks.test.ts | 28 ++ apps/cli/src/agent/output-manager.ts | 3 + .../useMessageHandlers.hooks.test.ts | 8 + apps/cli/src/ui/hooks/useMessageHandlers.ts | 10 +- src/core/hooks/HookRunner.ts | 255 +++++++++++++++++ src/core/hooks/__tests__/HookRunner.spec.ts | 158 +++++++++++ src/core/task/Task.ts | 159 ++++++++++- .../task/__tests__/Task.persistence.spec.ts | 258 ++++++++++++++++++ src/extension.ts | 1 + webview-ui/src/components/chat/ChatRow.tsx | 3 + webview-ui/src/components/chat/HookRow.tsx | 60 ++++ .../chat/__tests__/HookRow.spec.tsx | 64 +++++ webview-ui/src/i18n/locales/ca/chat.json | 23 ++ webview-ui/src/i18n/locales/ca/settings.json | 2 +- webview-ui/src/i18n/locales/de/chat.json | 23 ++ webview-ui/src/i18n/locales/de/settings.json | 2 +- webview-ui/src/i18n/locales/en/chat.json | 23 ++ webview-ui/src/i18n/locales/en/settings.json | 2 +- webview-ui/src/i18n/locales/es/chat.json | 23 ++ webview-ui/src/i18n/locales/es/settings.json | 2 +- webview-ui/src/i18n/locales/fr/chat.json | 23 ++ webview-ui/src/i18n/locales/fr/settings.json | 2 +- webview-ui/src/i18n/locales/hi/chat.json | 23 ++ webview-ui/src/i18n/locales/hi/settings.json | 2 +- webview-ui/src/i18n/locales/id/chat.json | 23 ++ webview-ui/src/i18n/locales/id/settings.json | 2 +- webview-ui/src/i18n/locales/it/chat.json | 23 ++ webview-ui/src/i18n/locales/it/settings.json | 2 +- webview-ui/src/i18n/locales/ja/chat.json | 23 ++ webview-ui/src/i18n/locales/ja/settings.json | 2 +- webview-ui/src/i18n/locales/ko/chat.json | 23 ++ webview-ui/src/i18n/locales/ko/settings.json | 2 +- webview-ui/src/i18n/locales/nl/chat.json | 23 ++ webview-ui/src/i18n/locales/nl/settings.json | 2 +- webview-ui/src/i18n/locales/pl/chat.json | 23 ++ webview-ui/src/i18n/locales/pl/settings.json | 2 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 23 ++ .../src/i18n/locales/pt-BR/settings.json | 2 +- webview-ui/src/i18n/locales/ru/chat.json | 23 ++ webview-ui/src/i18n/locales/ru/settings.json | 2 +- webview-ui/src/i18n/locales/tr/chat.json | 23 ++ webview-ui/src/i18n/locales/tr/settings.json | 2 +- webview-ui/src/i18n/locales/vi/chat.json | 23 ++ webview-ui/src/i18n/locales/vi/settings.json | 2 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 23 ++ .../src/i18n/locales/zh-CN/settings.json | 2 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 23 ++ .../src/i18n/locales/zh-TW/settings.json | 2 +- 48 files changed, 1431 insertions(+), 26 deletions(-) create mode 100644 apps/cli/src/agent/__tests__/output-manager.hooks.test.ts create mode 100644 apps/cli/src/ui/hooks/__tests__/useMessageHandlers.hooks.test.ts create mode 100644 src/core/hooks/HookRunner.ts create mode 100644 src/core/hooks/__tests__/HookRunner.spec.ts create mode 100644 webview-ui/src/components/chat/HookRow.tsx create mode 100644 webview-ui/src/components/chat/__tests__/HookRow.spec.tsx diff --git a/apps/cli/src/agent/__tests__/output-manager.hooks.test.ts b/apps/cli/src/agent/__tests__/output-manager.hooks.test.ts new file mode 100644 index 0000000000..1a94abcd79 --- /dev/null +++ b/apps/cli/src/agent/__tests__/output-manager.hooks.test.ts @@ -0,0 +1,28 @@ +import { OutputManager } from "../output-manager.js" + +describe("OutputManager hook compatibility", () => { + it("suppresses structured hook rows", () => { + const stdout = { write: vi.fn() } as unknown as NodeJS.WriteStream + const stderr = { write: vi.fn() } as unknown as NodeJS.WriteStream + const manager = new OutputManager({ stdout, stderr }) + + manager.outputMessage({ + ts: 1, + type: "say", + say: "hook", + hook: { + hookRunId: "run-1", + hookId: "hook-1", + name: "Session hook", + phase: "sessionStart", + status: "failed", + startedAt: 1, + completedAt: 2, + errorSummary: "must not print", + }, + }) + + expect(stdout.write).not.toHaveBeenCalled() + expect(stderr.write).not.toHaveBeenCalled() + }) +}) diff --git a/apps/cli/src/agent/output-manager.ts b/apps/cli/src/agent/output-manager.ts index 805b090925..864b05ab22 100644 --- a/apps/cli/src/agent/output-manager.ts +++ b/apps/cli/src/agent/output-manager.ts @@ -242,6 +242,9 @@ export class OutputManager { skipFirstUserMessage: boolean, ): void { switch (say) { + case "hook": + // Structured hook lifecycle rows are rendered only by the webview. + break case "text": this.outputTextMessage(ts, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage) break diff --git a/apps/cli/src/ui/hooks/__tests__/useMessageHandlers.hooks.test.ts b/apps/cli/src/ui/hooks/__tests__/useMessageHandlers.hooks.test.ts new file mode 100644 index 0000000000..d04482028a --- /dev/null +++ b/apps/cli/src/ui/hooks/__tests__/useMessageHandlers.hooks.test.ts @@ -0,0 +1,8 @@ +import { isHookSayMessage } from "../useMessageHandlers.js" + +describe("useMessageHandlers hook compatibility", () => { + it("suppresses structured hook rows from the TUI message ledger", () => { + expect(isHookSayMessage("hook")).toBe(true) + expect(isHookSayMessage("text")).toBe(false) + }) +}) diff --git a/apps/cli/src/ui/hooks/useMessageHandlers.ts b/apps/cli/src/ui/hooks/useMessageHandlers.ts index 68695e39c7..8d518f6515 100644 --- a/apps/cli/src/ui/hooks/useMessageHandlers.ts +++ b/apps/cli/src/ui/hooks/useMessageHandlers.ts @@ -18,6 +18,10 @@ export interface UseMessageHandlersReturn { firstTextMessageSkipped: React.MutableRefObject } +export function isHookSayMessage(say: ClineSay): boolean { + return say === "hook" +} + /** * Hook to handle messages from the extension. * @@ -61,11 +65,7 @@ export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions const messageId = ts.toString() const isResuming = useCLIStore.getState().isResumingTask - if (say === "checkpoint_saved") { - return - } - - if (say === "api_req_started") { + if (say === "checkpoint_saved" || say === "api_req_started" || isHookSayMessage(say)) { return } diff --git a/src/core/hooks/HookRunner.ts b/src/core/hooks/HookRunner.ts new file mode 100644 index 0000000000..a371416918 --- /dev/null +++ b/src/core/hooks/HookRunner.ts @@ -0,0 +1,255 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { execa } from "execa" +import psTree from "ps-tree" + +import { + HOOK_CAPTURE_MAX_BYTES, + HOOK_MODEL_OUTPUT_MAX_BYTES, + HOOK_TIMEOUT_MS, + classifyHookExit, + sanitizeHookOutput, + type HookDefinition, + type HookInvocation, + type HookRunResult, +} from "@roo-code/types" + +export const HOOK_INVOCATION_FILE_ENV = "ZOO_CODE_HOOK_INVOCATION_FILE" + +type TerminationReason = "timedOut" | "cancelled" + +interface HookRunnerOptions { + timeoutMs?: number +} + +function collectProcessTree(pid: number): Promise { + return new Promise((resolve) => { + psTree(pid, (error, children) => { + if (error) { + resolve([]) + return + } + + resolve(children.map(({ PID }) => Number(PID)).filter(Number.isInteger)) + }) + }) +} + +async function terminateProcessTree(pid: number | undefined): Promise { + if (pid === undefined) { + return + } + + const descendants = await collectProcessTree(pid) + for (const childPid of descendants.reverse()) { + try { + process.kill(childPid, "SIGKILL") + } catch { + // The process may have exited between discovery and termination. + } + } + + try { + process.kill(pid, "SIGKILL") + } catch { + // The root process may already have exited. + } + + const pids = [...descendants, pid] + for (let attempt = 0; attempt < 200; attempt++) { + const alive = pids.filter((processId) => { + try { + process.kill(processId, 0) + return true + } catch { + return false + } + }) + if (alive.length === 0) { + return + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +function appendWithinBudget(chunks: Buffer[], chunk: Buffer, remainingBytes: { value: number }): boolean { + if (remainingBytes.value <= 0) { + return chunk.length > 0 + } + + const retained = chunk.subarray(0, remainingBytes.value) + if (retained.length > 0) { + chunks.push(retained) + remainingBytes.value -= retained.length + } + + return retained.length < chunk.length +} + +function truncateUtf8(output: string, maxBytes: number): { output: string; truncated: boolean } { + const bytes = Buffer.from(output) + if (bytes.length <= maxBytes) { + return { output, truncated: false } + } + + const marker = Buffer.from("\n[hook output omitted to fit limit]\n") + if (maxBytes < marker.length) { + return { output: "", truncated: true } + } + + let retained = bytes.subarray(0, maxBytes - marker.length).toString("utf8") + while (Buffer.byteLength(retained) + marker.length > maxBytes) { + retained = retained.slice(0, -1) + } + return { output: retained + marker.toString("utf8"), truncated: true } +} + +function boundedSummaries( + stdout: string, + stderr: string, +): { stdoutSummary?: string; stderrSummary?: string; truncated: boolean } { + let remainingBytes = HOOK_MODEL_OUTPUT_MAX_BYTES + const boundedStdout = truncateUtf8(stdout, remainingBytes) + remainingBytes -= Buffer.byteLength(boundedStdout.output) + const boundedStderr = truncateUtf8(stderr, remainingBytes) + + return { + stdoutSummary: boundedStdout.output || undefined, + stderrSummary: boundedStderr.output || undefined, + truncated: boundedStdout.truncated || boundedStderr.truncated, + } +} + +async function validateCwd(cwd: string): Promise { + if (!path.isAbsolute(cwd)) { + throw new Error("Hook workspace path must be an absolute file-system path.") + } + + const stat = await fs.stat(cwd) + if (!stat.isDirectory()) { + throw new Error("Hook workspace path is not a directory.") + } +} + +export class HookRunner { + private readonly timeoutMs: number + + constructor(options: HookRunnerOptions = {}) { + this.timeoutMs = options.timeoutMs ?? HOOK_TIMEOUT_MS + } + + async run(definition: HookDefinition, invocation: HookInvocation, signal: AbortSignal): Promise { + const startedAt = Date.now() + const baseResult = { + hookRunId: invocation.hookRunId, + hookId: definition.id, + phase: invocation.phase, + startedAt, + } + + if (signal.aborted) { + return { ...baseResult, status: "cancelled", truncated: false, completedAt: Date.now() } + } + + let tempDirectory: string | undefined + try { + await validateCwd(invocation.workspacePath) + tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-code-hook-")) + await fs.chmod(tempDirectory, 0o700) + const invocationPath = path.join(tempDirectory, "invocation.json") + await fs.writeFile(invocationPath, JSON.stringify(invocation), { encoding: "utf8", mode: 0o600 }) + + if (signal.aborted) { + return { ...baseResult, status: "cancelled", truncated: false, completedAt: Date.now() } + } + + const subprocess = execa(definition.executable, definition.argv, { + buffer: false, + cwd: invocation.workspacePath, + env: { ...process.env, [HOOK_INVOCATION_FILE_ENV]: invocationPath }, + reject: false, + shell: false, + stdin: "ignore", + stderr: "pipe", + stdout: "pipe", + }) + + const remainingCaptureBytes = { value: HOOK_CAPTURE_MAX_BYTES } + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + let captureTruncated = false + subprocess.stdout?.on("data", (chunk: Buffer | string) => { + captureTruncated = + appendWithinBudget(stdoutChunks, Buffer.from(chunk), remainingCaptureBytes) || captureTruncated + }) + subprocess.stderr?.on("data", (chunk: Buffer | string) => { + captureTruncated = + appendWithinBudget(stderrChunks, Buffer.from(chunk), remainingCaptureBytes) || captureTruncated + }) + + let resolveTermination!: (reason: TerminationReason) => void + const termination = new Promise((resolve) => { + resolveTermination = resolve + }) + const onAbort = () => resolveTermination("cancelled") + signal.addEventListener("abort", onAbort, { once: true }) + const timeout = setTimeout(() => resolveTermination("timedOut"), this.timeoutMs) + + const outcome = await Promise.race([ + subprocess.then((result) => ({ type: "exit" as const, result })), + termination.then((reason) => ({ type: "termination" as const, reason })), + ]) + + clearTimeout(timeout) + signal.removeEventListener("abort", onAbort) + + if (outcome.type === "termination") { + await terminateProcessTree(subprocess.pid) + await subprocess.catch(() => undefined) + const summaries = boundedSummaries( + sanitizeHookOutput(Buffer.concat(stdoutChunks).toString("utf8")), + sanitizeHookOutput(Buffer.concat(stderrChunks).toString("utf8")), + ) + return { + ...baseResult, + ...summaries, + status: outcome.reason, + truncated: captureTruncated || summaries.truncated, + completedAt: Date.now(), + } + } + + const summaries = boundedSummaries( + sanitizeHookOutput(Buffer.concat(stdoutChunks).toString("utf8")), + sanitizeHookOutput(Buffer.concat(stderrChunks).toString("utf8")), + ) + const classification = classifyHookExit(invocation.phase, outcome.result.exitCode ?? null) + if (outcome.result.exitCode === undefined && !summaries.stderrSummary) { + summaries.stderrSummary = "The hook process could not be started." + } + + return { + ...baseResult, + ...summaries, + status: classification.status, + exitCode: outcome.result.exitCode ?? undefined, + truncated: captureTruncated || summaries.truncated, + completedAt: Date.now(), + } + } catch { + return { + ...baseResult, + status: signal.aborted ? "cancelled" : "failed", + stderrSummary: signal.aborted ? undefined : "The hook process could not be started.", + truncated: false, + completedAt: Date.now(), + } + } finally { + if (tempDirectory) { + await fs.rm(tempDirectory, { force: true, recursive: true }).catch(() => undefined) + } + } + } +} diff --git a/src/core/hooks/__tests__/HookRunner.spec.ts b/src/core/hooks/__tests__/HookRunner.spec.ts new file mode 100644 index 0000000000..a3e7af7c0b --- /dev/null +++ b/src/core/hooks/__tests__/HookRunner.spec.ts @@ -0,0 +1,158 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { HOOK_MODEL_OUTPUT_MAX_BYTES, type HookDefinition, type HookInvocation } from "@roo-code/types" + +import { HookRunner } from "../HookRunner" + +const definition: HookDefinition = { + id: "session-hook", + name: "Session hook", + enabled: true, + phase: "sessionStart", + executable: process.execPath, + argv: [], +} + +describe("HookRunner", () => { + let cwd: string + + beforeEach(async () => { + cwd = await fs.mkdtemp(path.join(os.tmpdir(), "hook-runner-test-")) + }) + + afterEach(async () => { + await fs.rm(cwd, { force: true, recursive: true }) + }) + + function invocation(runId = "run-1"): HookInvocation { + return { + version: 1, + hookRunId: runId, + phase: "sessionStart", + taskId: "task-1", + instanceId: "instance-1", + workspacePath: cwd, + } + } + + it("executes an executable directly with argv and captures successful stdout", async () => { + const result = await new HookRunner().run( + { ...definition, argv: ["-e", "process.stdout.write(process.argv[1])", "literal $HOME && value"] }, + invocation(), + new AbortController().signal, + ) + + expect(result.status).toBe("succeeded") + expect(result.stdoutSummary).toBe("literal $HOME && value") + expect(result.exitCode).toBe(0) + }) + + it("classifies nonzero exits as nonfatal failures", async () => { + const result = await new HookRunner().run( + { ...definition, argv: ["-e", "process.stderr.write('diagnostic'); process.exit(7)"] }, + invocation(), + new AbortController().signal, + ) + + expect(result).toMatchObject({ status: "failed", exitCode: 7, stderrSummary: "diagnostic" }) + }) + + it("returns a safe failure for start errors and invalid cwd", async () => { + const missingCwd = await new HookRunner().run( + definition, + { ...invocation(), workspacePath: path.join(cwd, "missing") }, + new AbortController().signal, + ) + const startError = await new HookRunner().run( + { ...definition, executable: path.join(cwd, "missing-executable") }, + invocation(), + new AbortController().signal, + ) + + expect(missingCwd).toMatchObject({ status: "failed", stderrSummary: "The hook process could not be started." }) + expect(startError).toMatchObject({ status: "failed", stderrSummary: "The hook process could not be started." }) + }) + + it("does not spawn when already cancelled", async () => { + const controller = new AbortController() + controller.abort() + const result = await new HookRunner().run(definition, invocation(), controller.signal) + + expect(result.status).toBe("cancelled") + }) + + it("kills and awaits the process on timeout and cancellation", async () => { + const timeoutResult = await new HookRunner({ timeoutMs: 30 }).run( + { ...definition, argv: ["-e", "setInterval(() => {}, 1000)"] }, + invocation("timeout"), + new AbortController().signal, + ) + + const controller = new AbortController() + const cancellation = new HookRunner().run( + { ...definition, argv: ["-e", "setInterval(() => {}, 1000)"] }, + invocation("cancel"), + controller.signal, + ) + setTimeout(() => controller.abort(), 30) + + expect(timeoutResult.status).toBe("timedOut") + expect((await cancellation).status).toBe("cancelled") + }) + + it("terminates child processes before returning from timeout", async () => { + const childScript = "setInterval(() => {}, 1000)" + const parentScript = [ + "const { spawn } = require('child_process')", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}])`, + "process.stdout.write(String(child.pid))", + "setInterval(() => {}, 1000)", + ].join(";") + const result = await new HookRunner({ timeoutMs: 200 }).run( + { ...definition, argv: ["-e", parentScript] }, + invocation("tree-timeout"), + new AbortController().signal, + ) + const childPid = Number(result.stdoutSummary) + + expect(result.status).toBe("timedOut") + expect(Number.isInteger(childPid)).toBe(true) + expect(() => process.kill(childPid, 0)).toThrow() + }) + + it("bounds combined capture and persisted summaries", async () => { + const result = await new HookRunner().run( + { + ...definition, + argv: ["-e", "process.stdout.write('a'.repeat(70000)); process.stderr.write('b'.repeat(70000))"], + }, + invocation(), + new AbortController().signal, + ) + + expect(result.truncated).toBe(true) + expect(Buffer.byteLength((result.stdoutSummary ?? "") + (result.stderrSummary ?? ""))).toBeLessThanOrEqual( + HOOK_MODEL_OUTPUT_MAX_BYTES, + ) + }) + + it("provides mode-restricted invocation metadata and cleans it up", async () => { + const script = [ + "const fs = require('fs')", + "const p = process.env.ZOO_CODE_HOOK_INVOCATION_FILE", + "process.stdout.write(JSON.stringify({ data: JSON.parse(fs.readFileSync(p, 'utf8')), mode: fs.statSync(p).mode & 0o777, path: p }))", + ].join(";") + const result = await new HookRunner().run( + { ...definition, argv: ["-e", script] }, + invocation(), + new AbortController().signal, + ) + const metadata = JSON.parse(result.stdoutSummary ?? "{}") + + expect(metadata.data).toEqual(invocation()) + expect(metadata.mode).toBe(0o600) + await expect(fs.stat(metadata.path)).rejects.toThrow() + }) +}) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fe68f4ab0e..76adad6d18 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -36,6 +36,9 @@ import { type ModelInfo, type ClineApiReqCancelReason, type ClineApiReqInfo, + type HookDefinition, + type HookMessage, + type HookRunResult, RooCodeEventName, TelemetryEventName, TaskStatus, @@ -55,6 +58,7 @@ import { MAX_MCP_TOOLS_THRESHOLD, countEnabledMcpTools, providerIdentifiers, + getMatchingHooks, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService } from "@roo-code/cloud" @@ -136,6 +140,7 @@ import { validateAndFixToolResultIds } from "./validateToolResultIds" import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages" import { prepareApiConversationMessage } from "./apiConversationHistory" import { shouldAddUserMessageToHistory } from "./messageCounting" +import { HookRunner } from "../hooks/HookRunner" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds @@ -273,6 +278,12 @@ export class Task extends EventEmitter implements TaskLike { private readonly globalStoragePath: string abort: boolean = false currentRequestAbortController?: AbortController + private readonly taskLifetimeAbortController = new AbortController() + private readonly activeHookRuns = new Set>() + private readonly activeHookRows = new Map() + private hookDefinitionsSnapshot?: HookDefinition[] + private sessionStartHooksRun = false + private readonly hookRunner = new HookRunner() skipPrevResponseIdOnce: boolean = false // TaskStatus @@ -1076,7 +1087,7 @@ export class Task extends EventEmitter implements TaskLike { this.emit(RooCodeEventName.Message, { action: "created", message }) await this.saveClineMessages() - const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() + const shouldCaptureMessage = message.partial !== true && message.say !== "hook" && CloudService.isEnabled() if (shouldCaptureMessage) { CloudService.instance.captureEvent({ @@ -1109,7 +1120,7 @@ export class Task extends EventEmitter implements TaskLike { this.emit(RooCodeEventName.Message, { action: "updated", message }) // Check if we should sync to cloud and haven't already synced this message - const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() + const shouldCaptureMessage = message.partial !== true && message.say !== "hook" && CloudService.isEnabled() const hasNotBeenSynced = !this.cloudSyncedMessageTimestamps.has(message.ts) if (shouldCaptureMessage && hasNotBeenSynced) { @@ -1174,6 +1185,142 @@ export class Task extends EventEmitter implements TaskLike { return undefined } + private async addHookMessage(hook: HookMessage): Promise { + const ts = Math.max(Date.now(), (this.clineMessages.at(-1)?.ts ?? 0) + 1) + this.activeHookRows.set(`${this.taskId}:${this.instanceId}:${hook.hookRunId}`, ts) + await this.addToClineMessages({ ts, type: "say", say: "hook", hook }) + return ts + } + + private async updateHookMessage(result: HookRunResult): Promise { + const key = `${this.taskId}:${this.instanceId}:${result.hookRunId}` + const ts = this.activeHookRows.get(key) + if (ts === undefined) { + return + } + + const currentTask = this.providerRef.deref()?.getCurrentTask() + const isCurrentTask = currentTask?.taskId === this.taskId && currentTask.instanceId === this.instanceId + if (!isCurrentTask) { + this.activeHookRows.delete(key) + return + } + + const index = this.clineMessages.findIndex((message) => message.ts === ts) + const runningMessage = index === -1 ? undefined : this.clineMessages[index] + if (!runningMessage?.hook || runningMessage.hook.hookRunId !== result.hookRunId) { + this.activeHookRows.delete(key) + return + } + + const message: ClineMessage = { + ...runningMessage, + hook: { + ...runningMessage.hook, + status: result.status, + outputSummary: result.stdoutSummary, + errorSummary: result.stderrSummary, + truncated: result.truncated, + completedAt: result.completedAt, + }, + } + this.clineMessages[index] = message + this.activeHookRows.delete(key) + await this.saveClineMessages() + await this.updateClineMessage(message) + } + + private interruptStaleHookMessages(messages: ClineMessage[]): ClineMessage[] { + const completedAt = Date.now() + return messages.map((message) => + message.say === "hook" && message.hook?.status === "running" + ? { + ...message, + hook: { + ...message.hook, + status: "interrupted", + completedAt, + }, + } + : message, + ) + } + + private formatHookResultForModel(definition: HookDefinition, result: HookRunResult): string | undefined { + const escapeAttribute = (value: string) => + value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<") + const content = + result.status === "succeeded" + ? result.stdoutSummary?.trim() + : `The session start hook ${result.status}; task execution continued.` + if (!content) { + return undefined + } + + return `\n${content}\n` + } + + private async runSessionStartHooks(): Promise { + if (this.sessionStartHooksRun || this.taskLifetimeAbortController.signal.aborted) { + return [] + } + this.sessionStartHooksRun = true + + this.hookDefinitionsSnapshot ??= structuredClone( + (await this.providerRef.deref()?.getState())?.hookDefinitions?.filter(({ enabled }) => enabled) ?? [], + ) + const hooks = getMatchingHooks(this.hookDefinitionsSnapshot, "sessionStart") + const modelContent: Anthropic.TextBlockParam[] = [] + + for (const definition of hooks) { + if (this.taskLifetimeAbortController.signal.aborted) { + break + } + + const hookRunId = crypto.randomUUID() + const startedAt = Date.now() + await this.addHookMessage({ + hookRunId, + hookId: definition.id, + name: definition.name, + phase: "sessionStart", + status: "running", + startedAt, + }) + + const execution = this.hookRunner + .run( + definition, + { + version: 1, + hookRunId, + phase: "sessionStart", + taskId: this.taskId, + instanceId: this.instanceId, + workspacePath: this.cwd, + }, + this.taskLifetimeAbortController.signal, + ) + .then(async (result) => { + await this.updateHookMessage(result) + return result + }) + this.activeHookRuns.add(execution) + + try { + const result = await execution + const text = this.formatHookResultForModel(definition, result) + if (text && result.status !== "cancelled") { + modelContent.push({ type: "text", text }) + } + } finally { + this.activeHookRuns.delete(execution) + } + } + + return modelContent + } + // Note that `partial` has three valid states true (partial message), // false (completion of partial message), undefined (individual complete // message). @@ -1980,6 +2127,7 @@ export class Task extends EventEmitter implements TaskLike { this.isInitialized = true const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images) + const hookContent = await this.runSessionStartHooks() // Task starting await this.initiateTaskLoop([ @@ -1988,6 +2136,7 @@ export class Task extends EventEmitter implements TaskLike { text: `\n${task}\n`, }, ...imageBlocks, + ...hookContent, ]).catch((error) => { // Swallow loop rejection when the task was intentionally abandoned/aborted // during delegation or user cancellation to prevent unhandled rejections. @@ -2008,7 +2157,7 @@ export class Task extends EventEmitter implements TaskLike { private async resumeTaskFromHistory() { try { - const modifiedClineMessages = await this.getSavedClineMessages() + const modifiedClineMessages = this.interruptStaleHookMessages(await this.getSavedClineMessages()) // Remove any resume messages that may have been added before. const lastRelevantMessageIndex = findLastIndex( @@ -2226,6 +2375,7 @@ export class Task extends EventEmitter implements TaskLike { } await this.overwriteApiConversationHistory(modifiedApiConversationHistory) + newUserContent.push(...(await this.runSessionStartHooks())) // Task resuming from history item. await this.initiateTaskLoop(newUserContent) @@ -2264,6 +2414,7 @@ export class Task extends EventEmitter implements TaskLike { public async abortTask(isAbandoned = false) { // Aborting task + this.taskLifetimeAbortController.abort() // Will stop any autonomously running promises. if (isAbandoned) { @@ -2271,6 +2422,7 @@ export class Task extends EventEmitter implements TaskLike { } this.abort = true + await Promise.allSettled([...this.activeHookRuns]) // Reset consecutive error counters on abort (manual intervention) this.consecutiveNoToolUseCount = 0 @@ -2298,6 +2450,7 @@ export class Task extends EventEmitter implements TaskLike { public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) + this.taskLifetimeAbortController.abort() // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 1761db5bc3..e9c24f758a 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -533,4 +533,262 @@ describe("Task persistence", () => { expect(task.userMessageContent).toEqual([]) }) }) + + describe("hook lifecycle persistence", () => { + it("updates the running row at the same timestamp after saving", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue(task) + const internals = task as any + const ts = await internals.addHookMessage({ + hookRunId: "run-1", + hookId: "hook-1", + name: "Session hook", + phase: "sessionStart", + status: "running", + startedAt: 1, + }) + mockSaveTaskMessages.mockClear() + vi.mocked(mockProvider.postMessageToWebview).mockClear() + + await internals.updateHookMessage({ + hookRunId: "run-1", + hookId: "hook-1", + phase: "sessionStart", + status: "succeeded", + stdoutSummary: "ready", + truncated: false, + startedAt: 1, + completedAt: 2, + }) + + expect(task.clineMessages).toHaveLength(1) + expect(task.clineMessages[0]).toMatchObject({ ts, say: "hook", hook: { status: "succeeded" } }) + expect(mockSaveTaskMessages.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(mockProvider.postMessageToWebview).mock.invocationCallOrder[0], + ) + }) + + it("ignores a late hook completion after the live task instance is replaced", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const internals = task as any + await internals.addHookMessage({ + hookRunId: "late-run", + hookId: "hook-1", + name: "Session hook", + phase: "sessionStart", + status: "running", + startedAt: 1, + }) + vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue({ + taskId: task.taskId, + instanceId: "replacement-instance", + } as Task) + mockSaveTaskMessages.mockClear() + + await internals.updateHookMessage({ + hookRunId: "late-run", + hookId: "hook-1", + phase: "sessionStart", + status: "succeeded", + stdoutSummary: "late output", + truncated: false, + startedAt: 1, + completedAt: 2, + }) + + expect(task.clineMessages[0]?.hook?.status).toBe("running") + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + }) + + it("repairs stale running rows without changing their timestamp", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const repaired = (task as any).interruptStaleHookMessages([ + { + ts: 42, + type: "say", + say: "hook", + hook: { + hookRunId: "old-run", + hookId: "hook-1", + name: "Session hook", + phase: "sessionStart", + status: "running", + startedAt: 1, + }, + }, + ]) + + expect(repaired[0]).toMatchObject({ ts: 42, hook: { status: "interrupted" } }) + }) + + it("snapshots enabled session hooks once and returns ordinary bounded text", async () => { + const definitions = [ + { + id: "hook-1", + name: "Session hook", + enabled: true, + phase: "sessionStart" as const, + executable: process.execPath, + argv: [], + }, + ] + vi.spyOn(mockProvider, "getState").mockResolvedValue({ hookDefinitions: definitions } as any) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue(task) + const run = vi.fn().mockImplementation(async (_definition, invocation) => ({ + hookRunId: invocation.hookRunId, + hookId: "hook-1", + phase: "sessionStart", + status: "succeeded", + stdoutSummary: "session context", + truncated: false, + startedAt: 1, + completedAt: 2, + })) + ;(task as any).hookRunner = { run } + + const first = await (task as any).runSessionStartHooks() + definitions[0].enabled = false + const second = await (task as any).runSessionStartHooks() + + expect(run).toHaveBeenCalledTimes(1) + expect(first).toEqual([ + { + type: "text", + text: expect.stringContaining( + '\nsession context\n', + ), + }, + ]) + expect(first[0]).not.toHaveProperty("tool_use_id") + expect(second).toEqual([]) + }) + + it("runs session hooks after the visible new-task row and before the first model turn", async () => { + const definition = { + id: "hook-1", + name: "Session hook", + enabled: true, + phase: "sessionStart" as const, + executable: process.execPath, + argv: [], + } + vi.spyOn(mockProvider, "getState").mockResolvedValue({ hookDefinitions: [definition] } as any) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue(task) + const internals = task as any + internals.hookRunner = { + run: vi.fn().mockImplementation(async (_definition, invocation) => ({ + hookRunId: invocation.hookRunId, + hookId: "hook-1", + phase: "sessionStart", + status: "succeeded", + stdoutSummary: "new task context", + truncated: false, + startedAt: 1, + completedAt: 2, + })), + } + vi.spyOn(internals, "getEnabledMcpToolsCount").mockResolvedValue({ + enabledToolCount: 0, + enabledServerCount: 0, + }) + const initiate = vi.spyOn(internals, "initiateTaskLoop").mockResolvedValue(undefined) + + await internals.startTask("test task") + + expect(task.clineMessages.map((message) => message.say)).toEqual(["text", "hook"]) + expect(initiate).toHaveBeenCalledOnce() + expect(initiate.mock.calls[0][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "text", text: expect.stringContaining("new task context") }), + ]), + ) + }) + + it("runs session hooks once after an accepted resume and before its model turn", async () => { + const definition = { + id: "hook-1", + name: "Session hook", + enabled: true, + phase: "sessionStart" as const, + executable: process.execPath, + argv: [], + } + vi.spyOn(mockProvider, "getState").mockResolvedValue({ hookDefinitions: [definition] } as any) + mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "original task" }]) + mockReadApiMessages.mockResolvedValue([ + { role: "assistant", content: [{ type: "text", text: "prior response" }] }, + ]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "resumed-task", + number: 1, + ts: 1, + task: "original task", + totalCost: 0, + tokensIn: 0, + tokensOut: 0, + }, + startTask: false, + }) + vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue(task) + const internals = task as any + internals.hookRunner = { + run: vi.fn().mockImplementation(async (_definition, invocation) => ({ + hookRunId: invocation.hookRunId, + hookId: "hook-1", + phase: "sessionStart", + status: "succeeded", + stdoutSummary: "resume context", + truncated: false, + startedAt: 1, + completedAt: 2, + })), + } + vi.spyOn(task, "ask").mockImplementation(async () => { + task.clineMessages.push({ ts: 2, type: "ask", ask: "resume_task" }) + return { response: "yesButtonClicked" } + }) + const initiate = vi.spyOn(internals, "initiateTaskLoop").mockResolvedValue(undefined) + + await internals.resumeTaskFromHistory() + + expect(task.clineMessages.at(-2)?.ask).toBe("resume_task") + expect(task.clineMessages.at(-1)?.hook?.status).toBe("succeeded") + expect(initiate).toHaveBeenCalledOnce() + expect(initiate.mock.calls[0][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "text", text: expect.stringContaining("resume context") }), + ]), + ) + }) + }) }) diff --git a/src/extension.ts b/src/extension.ts index b880bee410..a20c4d73ae 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -387,6 +387,7 @@ export async function deactivate() { } } + await Promise.allSettled(ClineProvider.getAllInstances().map((provider) => provider.dispose())) await McpServerManager.cleanup(extensionContext) try { diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 3c48b2fdd1..3e0fa51866 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -78,6 +78,7 @@ import { cn } from "@/lib/utils" import { PathTooltip } from "../ui/PathTooltip" import { OpenMarkdownPreviewButton } from "./OpenMarkdownPreviewButton" import { SeeNewChangesButtons } from "./SeeNewChangesButtons" +import { HookRow } from "./HookRow" // Helper function to get previous todos before a specific message function getPreviousTodos(messages: ClineMessage[], currentMessageTs: number): any[] { @@ -1036,6 +1037,8 @@ export const ChatRowContent = ({ switch (message.type) { case "say": switch (message.say) { + case "hook": + return message.hook ? : null case "diff_error": return ( (["failed", "timedOut", "cancelled", "interrupted"]) + +export function HookRow({ hook }: { hook: HookMessage }) { + const { t } = useTranslation() + const failed = failureStatuses.has(hook.status) + const details = [hook.outputSummary, hook.errorSummary].filter(Boolean).join("\n\n") + const Icon = + hook.status === "running" + ? Loader2 + : hook.status === "succeeded" + ? CheckCircle2 + : hook.status === "blocked" + ? CircleSlash2 + : hook.status === "timedOut" + ? Clock3 + : XCircle + + return ( +
+
+ + {hook.name} + + {t(`chat:hooks.phase.${hook.phase}`)} · {t(`chat:hooks.status.${hook.status}`)} + +
+
+ {t(`chat:hooks.summary.${hook.status}`)} +
+ {details && hook.status !== "running" ? ( +
+ + {t("chat:hooks.details")} + +
+						{details}
+					
+
+ ) : null} + {failed ? ( + + ) : null} +
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/HookRow.spec.tsx b/webview-ui/src/components/chat/__tests__/HookRow.spec.tsx new file mode 100644 index 0000000000..10c7319cab --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/HookRow.spec.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from "@/utils/test-utils" + +import { HookRow } from "../HookRow" + +const translations: Record = { + "chat:hooks.phase.sessionStart": "Session start", + "chat:hooks.status.running": "Running", + "chat:hooks.status.failed": "Failed", + "chat:hooks.summary.running": "Executing trusted local hook", + "chat:hooks.summary.failed": "Hook failed; task execution continued", + "chat:hooks.details": "Output details", + "chat:hooks.openSettings": "Open Hooks settings", +} + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => translations[key] ?? key }), +})) + +describe("HookRow", () => { + it("renders a compact running row without exposing command data", () => { + render( + , + ) + + expect(screen.getByText("Prepare session")).toBeInTheDocument() + expect(screen.getByText(/Running/)).toBeInTheDocument() + expect(screen.queryByRole("button")).not.toBeInTheDocument() + }) + + it("shows bounded details and deep-links failures to Hooks settings", () => { + const postMessage = vi.spyOn(window, "postMessage").mockImplementation(() => {}) + render( + , + ) + + fireEvent.click(screen.getByText("Output details")) + expect(screen.getByText("bounded diagnostic")).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: /Open Hooks settings/ })) + expect(postMessage).toHaveBeenCalledWith( + { type: "action", action: "settingsButtonClicked", values: { section: "hooks" } }, + "*", + ) + }) +}) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index a4cddd5d12..3b76df79af 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -1,5 +1,28 @@ { "greeting": "Benvingut a Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Tasca", "expand": "Expandir tasca", diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 982c878207..4620c76fc3 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index c1c3341665..8a1ec5df3d 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -1,5 +1,28 @@ { "greeting": "Willkommen bei Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Aufgabe", "expand": "Aufgabe erweitern", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index bfbaafc356..9a30973f41 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index dbeb34ecab..c5ae8f2f55 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -1,5 +1,28 @@ { "greeting": "Welcome to Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Task", "expand": "Expand task", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 1ff09ef9ef..ce345743f9 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 99f6961b32..67ad418fc4 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -1,5 +1,28 @@ { "greeting": "¡Bienvenido a Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Tarea", "expand": "Expandir tarea", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 175c1488dd..89ae700874 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index dc9dbafa31..843a948ffa 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -1,5 +1,28 @@ { "greeting": "Bienvenue sur Zoo Code !", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Tâche", "expand": "Développer la tâche", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index a3c2a58cb0..6af14d4d82 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 8a1805f434..eba67eb030 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -1,5 +1,28 @@ { "greeting": "Zoo Code में आपका स्वागत है!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "कार्य", "expand": "कार्य विस्तृत करें", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 8b4e88ec04..b0ca8adca4 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 7dd672af09..c3d1a0c83d 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -1,5 +1,28 @@ { "greeting": "Selamat datang di Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Tugas", "expand": "Perluas tugas", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 5fbb8e8f75..5f3c4edfec 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index c94d303302..fd012db34c 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -1,5 +1,28 @@ { "greeting": "Benvenuto in Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Attività", "expand": "Espandi attività", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index a976fb6642..455f4c1c67 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 25fec3f952..bad399e494 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -1,5 +1,28 @@ { "greeting": "Zoo Code へようこそ!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "タスク", "expand": "タスクを展開", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 00e6ab6a51..4afc3a2735 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index b107a2fa89..371d7e978a 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -1,5 +1,28 @@ { "greeting": "Zoo Code에 오신 것을 환영합니다!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "작업", "expand": "작업 펼치기", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 9c5cd32625..f56133dd96 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index da5e82382d..6136650ae7 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -1,5 +1,28 @@ { "greeting": "Welkom bij Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Taak", "expand": "Taak uitvouwen", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 0f1b4f0e0d..ebb4774c89 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 698441503d..f8f711f30f 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -1,5 +1,28 @@ { "greeting": "Witamy w Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Zadanie", "expand": "Rozwiń zadanie", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index d0420a8511..e66478e53e 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 699f5b2e7b..66a48722ec 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -1,5 +1,28 @@ { "greeting": "Bem-vindo ao Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Tarefa", "expand": "Expandir tarefa", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 8410cca4bb..c7d632dff3 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index c7973b400c..285f874fdb 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -1,5 +1,28 @@ { "greeting": "Добро пожаловать в Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Задача", "expand": "Развернуть задачу", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index a113f245ea..2d956dc699 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index f9af409a26..db96376a24 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -1,5 +1,28 @@ { "greeting": "Zoo Code'a hoş geldin!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Görev", "expand": "Görevi genişlet", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 0927c09d49..9aae4875a8 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index f829a0ba3a..0d54faa537 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -1,5 +1,28 @@ { "greeting": "Chào mừng đến với Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "Nhiệm vụ", "expand": "Mở rộng nhiệm vụ", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 8e2c4e1fb7..ff6c6acd28 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index ce71bf4d8d..e0ff986141 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -1,5 +1,28 @@ { "greeting": "欢迎使用 Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "任务", "expand": "展开任务", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index e44d3b53a4..308d2c4772 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index a199ebaf22..b5b3fb8a31 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -1,5 +1,28 @@ { "greeting": "歡迎使用 Zoo Code!", + "hooks": { + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "status": { + "running": "Running", + "succeeded": "Succeeded", + "blocked": "Blocked", + "failed": "Failed", + "timedOut": "Timed out", + "cancelled": "Cancelled", + "interrupted": "Interrupted" + }, + "summary": { + "running": "Executing trusted local hook", + "succeeded": "Hook completed", + "blocked": "Hook blocked the operation", + "failed": "Hook failed; task execution continued", + "timedOut": "Hook timed out; task execution continued", + "cancelled": "Hook was cancelled", + "interrupted": "Hook was interrupted before completion" + }, + "details": "Output details", + "openSettings": "Open Hooks settings" + }, "task": { "title": "工作", "expand": "展開工作", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 708d5d4139..8868f86ecc 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -52,7 +52,7 @@ "edit": "Edit hook", "delete": "Delete hook", "global": "Global hooks", - "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names.", "enabled": "Enabled", "disabled": "Disabled", "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", From 762a3fc080fec1f6a8191bd9ce06fe6bd1a4da1d Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 03:06:16 -0400 Subject: [PATCH 2/6] test(core): type session hook fixtures --- .../task/__tests__/Task.persistence.spec.ts | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index e9c24f758a..b0a2bebe17 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -4,12 +4,13 @@ import * as os from "os" import * as path from "path" import * as vscode from "vscode" -import type { GlobalState, ProviderSettings } from "@roo-code/types" +import type { GlobalState, HookDefinition, ProviderSettings } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" import { ContextProxy } from "../../config/ContextProxy" +import type { HookRunner } from "../../hooks/HookRunner" // ─── Hoisted mocks ─────────────────────────────────────────────────────────── @@ -208,6 +209,11 @@ describe("Task persistence", () => { let mockOutputChannel: vscode.OutputChannel let mockExtensionContext: vscode.ExtensionContext + async function mockHookDefinitions(hookDefinitions: HookDefinition[]): Promise { + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...state, hookDefinitions }) + } + beforeEach(() => { vi.clearAllMocks() @@ -543,8 +549,7 @@ describe("Task persistence", () => { startTask: false, }) vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue(task) - const internals = task as any - const ts = await internals.addHookMessage({ + const ts = await task["addHookMessage"]({ hookRunId: "run-1", hookId: "hook-1", name: "Session hook", @@ -555,7 +560,7 @@ describe("Task persistence", () => { mockSaveTaskMessages.mockClear() vi.mocked(mockProvider.postMessageToWebview).mockClear() - await internals.updateHookMessage({ + await task["updateHookMessage"]({ hookRunId: "run-1", hookId: "hook-1", phase: "sessionStart", @@ -580,8 +585,7 @@ describe("Task persistence", () => { task: "test task", startTask: false, }) - const internals = task as any - await internals.addHookMessage({ + await task["addHookMessage"]({ hookRunId: "late-run", hookId: "hook-1", name: "Session hook", @@ -595,7 +599,7 @@ describe("Task persistence", () => { } as Task) mockSaveTaskMessages.mockClear() - await internals.updateHookMessage({ + await task["updateHookMessage"]({ hookRunId: "late-run", hookId: "hook-1", phase: "sessionStart", @@ -617,7 +621,7 @@ describe("Task persistence", () => { task: "test task", startTask: false, }) - const repaired = (task as any).interruptStaleHookMessages([ + const repaired = task["interruptStaleHookMessages"]([ { ts: 42, type: "say", @@ -647,7 +651,7 @@ describe("Task persistence", () => { argv: [], }, ] - vi.spyOn(mockProvider, "getState").mockResolvedValue({ hookDefinitions: definitions } as any) + await mockHookDefinitions(definitions) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -655,7 +659,7 @@ describe("Task persistence", () => { startTask: false, }) vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue(task) - const run = vi.fn().mockImplementation(async (_definition, invocation) => ({ + const run = vi.fn().mockImplementation(async (_definition, invocation) => ({ hookRunId: invocation.hookRunId, hookId: "hook-1", phase: "sessionStart", @@ -665,11 +669,11 @@ describe("Task persistence", () => { startedAt: 1, completedAt: 2, })) - ;(task as any).hookRunner = { run } + task["hookRunner"].run = run - const first = await (task as any).runSessionStartHooks() + const first = await task["runSessionStartHooks"]() definitions[0].enabled = false - const second = await (task as any).runSessionStartHooks() + const second = await task["runSessionStartHooks"]() expect(run).toHaveBeenCalledTimes(1) expect(first).toEqual([ @@ -693,7 +697,7 @@ describe("Task persistence", () => { executable: process.execPath, argv: [], } - vi.spyOn(mockProvider, "getState").mockResolvedValue({ hookDefinitions: [definition] } as any) + await mockHookDefinitions([definition]) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -701,26 +705,24 @@ describe("Task persistence", () => { startTask: false, }) vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue(task) - const internals = task as any - internals.hookRunner = { - run: vi.fn().mockImplementation(async (_definition, invocation) => ({ - hookRunId: invocation.hookRunId, - hookId: "hook-1", - phase: "sessionStart", - status: "succeeded", - stdoutSummary: "new task context", - truncated: false, - startedAt: 1, - completedAt: 2, - })), - } - vi.spyOn(internals, "getEnabledMcpToolsCount").mockResolvedValue({ + task["hookRunner"].run = vi.fn().mockImplementation(async (_definition, invocation) => ({ + hookRunId: invocation.hookRunId, + hookId: "hook-1", + phase: "sessionStart", + status: "succeeded", + stdoutSummary: "new task context", + truncated: false, + startedAt: 1, + completedAt: 2, + })) + task["getEnabledMcpToolsCount"] = vi.fn().mockResolvedValue({ enabledToolCount: 0, enabledServerCount: 0, }) - const initiate = vi.spyOn(internals, "initiateTaskLoop").mockResolvedValue(undefined) + const initiate = vi.fn().mockResolvedValue(undefined) + task["initiateTaskLoop"] = initiate - await internals.startTask("test task") + await task["startTask"]("test task") expect(task.clineMessages.map((message) => message.say)).toEqual(["text", "hook"]) expect(initiate).toHaveBeenCalledOnce() @@ -740,7 +742,7 @@ describe("Task persistence", () => { executable: process.execPath, argv: [], } - vi.spyOn(mockProvider, "getState").mockResolvedValue({ hookDefinitions: [definition] } as any) + await mockHookDefinitions([definition]) mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "original task" }]) mockReadApiMessages.mockResolvedValue([ { role: "assistant", content: [{ type: "text", text: "prior response" }] }, @@ -760,26 +762,24 @@ describe("Task persistence", () => { startTask: false, }) vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue(task) - const internals = task as any - internals.hookRunner = { - run: vi.fn().mockImplementation(async (_definition, invocation) => ({ - hookRunId: invocation.hookRunId, - hookId: "hook-1", - phase: "sessionStart", - status: "succeeded", - stdoutSummary: "resume context", - truncated: false, - startedAt: 1, - completedAt: 2, - })), - } + task["hookRunner"].run = vi.fn().mockImplementation(async (_definition, invocation) => ({ + hookRunId: invocation.hookRunId, + hookId: "hook-1", + phase: "sessionStart", + status: "succeeded", + stdoutSummary: "resume context", + truncated: false, + startedAt: 1, + completedAt: 2, + })) vi.spyOn(task, "ask").mockImplementation(async () => { task.clineMessages.push({ ts: 2, type: "ask", ask: "resume_task" }) return { response: "yesButtonClicked" } }) - const initiate = vi.spyOn(internals, "initiateTaskLoop").mockResolvedValue(undefined) + const initiate = vi.fn().mockResolvedValue(undefined) + task["initiateTaskLoop"] = initiate - await internals.resumeTaskFromHistory() + await task["resumeTaskFromHistory"]() expect(task.clineMessages.at(-2)?.ask).toBe("resume_task") expect(task.clineMessages.at(-1)?.hook?.status).toBe("succeeded") From ca4fae6b1b6bf4310dae80d77dc7f849e11f5f96 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 03:24:53 -0400 Subject: [PATCH 3/6] fix(core): make hook runner portable on Windows --- src/core/hooks/HookRunner.ts | 16 ++++++++++++++++ src/core/hooks/__tests__/HookRunner.spec.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/core/hooks/HookRunner.ts b/src/core/hooks/HookRunner.ts index a371416918..d4f647e5cb 100644 --- a/src/core/hooks/HookRunner.ts +++ b/src/core/hooks/HookRunner.ts @@ -41,6 +41,19 @@ async function terminateProcessTree(pid: number | undefined): Promise { if (pid === undefined) { return } + if (process.platform === "win32") { + const result = await execa("taskkill.exe", ["/PID", String(pid), "/T", "/F"], { reject: false }).catch( + () => undefined, + ) + if (!result || result.exitCode !== 0) { + try { + process.kill(pid, "SIGKILL") + } catch { + // The root process may already have exited. + } + } + return + } const descendants = await collectProcessTree(pid) for (const childPid of descendants.reverse()) { @@ -156,6 +169,9 @@ export class HookRunner { let tempDirectory: string | undefined try { await validateCwd(invocation.workspacePath) + if (path.isAbsolute(definition.executable)) { + await fs.access(definition.executable) + } tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-code-hook-")) await fs.chmod(tempDirectory, 0o700) const invocationPath = path.join(tempDirectory, "invocation.json") diff --git a/src/core/hooks/__tests__/HookRunner.spec.ts b/src/core/hooks/__tests__/HookRunner.spec.ts index a3e7af7c0b..a2a7d072d7 100644 --- a/src/core/hooks/__tests__/HookRunner.spec.ts +++ b/src/core/hooks/__tests__/HookRunner.spec.ts @@ -152,7 +152,7 @@ describe("HookRunner", () => { const metadata = JSON.parse(result.stdoutSummary ?? "{}") expect(metadata.data).toEqual(invocation()) - expect(metadata.mode).toBe(0o600) + if (process.platform !== "win32") expect(metadata.mode).toBe(0o600) await expect(fs.stat(metadata.path)).rejects.toThrow() }) }) From 9c2adfb4c5b1160076e1ea6db4535064a5919a53 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 03:29:25 -0400 Subject: [PATCH 4/6] fix(core): resolve Windows taskkill path --- src/core/hooks/HookRunner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/hooks/HookRunner.ts b/src/core/hooks/HookRunner.ts index d4f647e5cb..b824945171 100644 --- a/src/core/hooks/HookRunner.ts +++ b/src/core/hooks/HookRunner.ts @@ -42,7 +42,8 @@ async function terminateProcessTree(pid: number | undefined): Promise { return } if (process.platform === "win32") { - const result = await execa("taskkill.exe", ["/PID", String(pid), "/T", "/F"], { reject: false }).catch( + const taskkillPath = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe") + const result = await execa(taskkillPath, ["/PID", String(pid), "/T", "/F"], { reject: false }).catch( () => undefined, ) if (!result || result.exitCode !== 0) { From 784938134ea0437aac8e4003375d8e17fa2f3ab4 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 03:49:11 -0400 Subject: [PATCH 5/6] test(core): cover Windows hook termination --- src/core/hooks/HookRunner.ts | 9 ++++++--- src/core/hooks/__tests__/HookRunner.spec.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/core/hooks/HookRunner.ts b/src/core/hooks/HookRunner.ts index b824945171..d65630f290 100644 --- a/src/core/hooks/HookRunner.ts +++ b/src/core/hooks/HookRunner.ts @@ -22,6 +22,7 @@ type TerminationReason = "timedOut" | "cancelled" interface HookRunnerOptions { timeoutMs?: number + platform?: NodeJS.Platform } function collectProcessTree(pid: number): Promise { @@ -37,11 +38,11 @@ function collectProcessTree(pid: number): Promise { }) } -async function terminateProcessTree(pid: number | undefined): Promise { +async function terminateProcessTree(pid: number | undefined, platform: NodeJS.Platform): Promise { if (pid === undefined) { return } - if (process.platform === "win32") { + if (platform === "win32") { const taskkillPath = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe") const result = await execa(taskkillPath, ["/PID", String(pid), "/T", "/F"], { reject: false }).catch( () => undefined, @@ -149,9 +150,11 @@ async function validateCwd(cwd: string): Promise { export class HookRunner { private readonly timeoutMs: number + private readonly platform: NodeJS.Platform constructor(options: HookRunnerOptions = {}) { this.timeoutMs = options.timeoutMs ?? HOOK_TIMEOUT_MS + this.platform = options.platform ?? process.platform } async run(definition: HookDefinition, invocation: HookInvocation, signal: AbortSignal): Promise { @@ -223,7 +226,7 @@ export class HookRunner { signal.removeEventListener("abort", onAbort) if (outcome.type === "termination") { - await terminateProcessTree(subprocess.pid) + await terminateProcessTree(subprocess.pid, this.platform) await subprocess.catch(() => undefined) const summaries = boundedSummaries( sanitizeHookOutput(Buffer.concat(stdoutChunks).toString("utf8")), diff --git a/src/core/hooks/__tests__/HookRunner.spec.ts b/src/core/hooks/__tests__/HookRunner.spec.ts index a2a7d072d7..ea539da9fb 100644 --- a/src/core/hooks/__tests__/HookRunner.spec.ts +++ b/src/core/hooks/__tests__/HookRunner.spec.ts @@ -102,6 +102,16 @@ describe("HookRunner", () => { expect((await cancellation).status).toBe("cancelled") }) + it("uses the Windows termination path without process discovery", async () => { + const result = await new HookRunner({ timeoutMs: 30, platform: "win32" }).run( + { ...definition, argv: ["-e", "setInterval(() => {}, 1000)"] }, + invocation("windows-timeout"), + new AbortController().signal, + ) + + expect(result.status).toBe("timedOut") + }) + it("terminates child processes before returning from timeout", async () => { const childScript = "setInterval(() => {}, 1000)" const parentScript = [ From 75322a7d2871dde61a581ee7f0910783b7a5c1f5 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 04:00:39 -0400 Subject: [PATCH 6/6] refactor(core): simplify taskkill fallback --- src/core/hooks/HookRunner.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/core/hooks/HookRunner.ts b/src/core/hooks/HookRunner.ts index d65630f290..a76d1b1ea2 100644 --- a/src/core/hooks/HookRunner.ts +++ b/src/core/hooks/HookRunner.ts @@ -44,10 +44,8 @@ async function terminateProcessTree(pid: number | undefined, platform: NodeJS.Pl } if (platform === "win32") { const taskkillPath = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe") - const result = await execa(taskkillPath, ["/PID", String(pid), "/T", "/F"], { reject: false }).catch( - () => undefined, - ) - if (!result || result.exitCode !== 0) { + const result = await execa(taskkillPath, ["/PID", String(pid), "/T", "/F"], { reject: false }) + if (result.exitCode !== 0) { try { process.kill(pid, "SIGKILL") } catch {