feat: promote hooks from experimental to permanent feature

Remove hooks from experimental flags and make it a core feature:
- Remove HOOKS from ExperimentId enum and experimentsSchema
- Initialize HookManager unconditionally in ClineProvider
- Remove experiment checks from webviewMessageHandler
- Always show Hooks tab in SettingsView
- Add dynamic initialization with file watchers (no restart required)
- Update translations to remove restart requirement text
- Add comprehensive tests for dynamic hook initialization

The master 'Enable Hooks' toggle in HooksSettings controls execution.
This commit is contained in:
Toray Altas 2026-01-17 16:08:16 -05:00
parent 2cac5639dc
commit fe0e61f232
16 changed files with 489 additions and 120 deletions

View file

@ -14,7 +14,6 @@ export const experimentIds = [
"runSlashCommand",
"multipleNativeToolCalls",
"customTools",
"hooks",
] as const
export const experimentIdsSchema = z.enum(experimentIds)
@ -33,7 +32,6 @@ export const experimentsSchema = z.object({
runSlashCommand: z.boolean().optional(),
multipleNativeToolCalls: z.boolean().optional(),
customTools: z.boolean().optional(),
hooks: z.boolean().optional(),
})
export type Experiments = z.infer<typeof experimentsSchema>

View file

@ -540,23 +540,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
})
// Initialize tool execution hooks (only if hooks experiment is enabled)
const hooksExperimentEnabled = experiments.isEnabled(experimentsConfig ?? {}, EXPERIMENT_IDS.HOOKS)
// Initialize tool execution hooks
this.toolExecutionHooks = createToolExecutionHooks(
hooksExperimentEnabled ? (provider.getHookManager() ?? null) : null,
provider.getHookManager() ?? null,
(status) => provider.postHookStatusToWebview(status),
async (type, text) => {
await this.say(type as ClineSay, text)
},
// Getter for global hooksEnabled state - checks both experiment and user setting
// Getter for global hooksEnabled state
() => {
const experimentEnabled = experiments.isEnabled(
provider.contextProxy.getValue("experiments") ?? {},
EXPERIMENT_IDS.HOOKS,
)
// Default to true if hooksEnabled is undefined (backwards compatibility)
const userEnabled = provider.contextProxy.getValue("hooksEnabled") ?? true
return experimentEnabled && userEnabled
return provider.contextProxy.getValue("hooksEnabled") ?? true
},
)

View file

@ -56,7 +56,7 @@ import { findLast } from "../../shared/array"
import { supportPrompt } from "../../shared/support-prompt"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
import { experimentDefault, experiments, EXPERIMENT_IDS } from "../../shared/experiments"
import { experimentDefault } from "../../shared/experiments"
import { formatLanguage } from "../../shared/language"
import { WebviewMessage } from "../../shared/WebviewMessage"
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
@ -144,6 +144,9 @@ export class ClineProvider
protected mcpHub?: McpHub // Change from private to protected
protected skillsManager?: SkillsManager
protected hookManager?: IHookManager
private hookFileWatchers: vscode.FileSystemWatcher[] = []
private hookReloadTimeout?: NodeJS.Timeout
private static readonly HOOK_RELOAD_DEBOUNCE_MS = 500
private marketplaceManager: MarketplaceManager
private mdmService?: MdmService
private taskCreationCallback: (task: Task) => void
@ -623,6 +626,7 @@ export class ClineProvider
this.mcpHub = undefined
await this.skillsManager?.dispose()
this.skillsManager = undefined
this.disposeHookManager()
this.marketplaceManager?.cleanup()
this.customModesManager?.dispose()
this.log("Disposed all disposables")
@ -2654,6 +2658,7 @@ export class ClineProvider
* Initialize the Hook Manager for lifecycle hooks.
* This loads hooks configuration from project/.roo/hooks/ files.
* Only initializes if the hooks experiment is enabled.
* Sets up file watchers for automatic config reloading.
*/
public async initializeHookManager(): Promise<void> {
const cwd = this.currentWorkspacePath || getWorkspacePath()
@ -2665,12 +2670,6 @@ export class ClineProvider
try {
const state = await this.getState()
// Check if hooks experiment is enabled
if (!experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.HOOKS)) {
this.log("[HookManager] Hooks experiment is disabled, skipping initialization")
return
}
this.hookManager = createHookManager({
cwd,
mode: state?.mode,
@ -2685,6 +2684,12 @@ export class ClineProvider
// Load hooks configuration
await this.hookManager.loadHooksConfig()
this.log("[HookManager] Hooks loaded successfully")
// Set up file watchers for hook configuration files
this.setupHookFileWatchers(cwd, state?.mode)
// Notify webview of hook state
await this.postStateToWebview()
} catch (error) {
this.log(
`[HookManager] Failed to initialize hooks: ${error instanceof Error ? error.message : String(error)}`,
@ -2694,6 +2699,109 @@ export class ClineProvider
}
}
/**
* Set up file watchers for hook configuration files.
* Watches .roo/hooks/, ~/.roo/hooks/, and mode-specific directories.
*/
private setupHookFileWatchers(cwd: string, mode?: string): void {
// Clean up any existing watchers first
this.disposeHookFileWatchers()
const watchPatterns: string[] = []
// Project hooks: .roo/hooks/*.{json,yaml,yml}
const projectHooksPattern = new vscode.RelativePattern(
vscode.Uri.file(path.join(cwd, ".roo", "hooks")),
"*.{json,yaml,yml}",
)
watchPatterns.push(".roo/hooks/*.{json,yaml,yml}")
// Mode-specific hooks: .roo/hooks-{mode}/*.{json,yaml,yml}
let modeHooksPattern: vscode.RelativePattern | undefined
if (mode) {
modeHooksPattern = new vscode.RelativePattern(
vscode.Uri.file(path.join(cwd, ".roo", `hooks-${mode}`)),
"*.{json,yaml,yml}",
)
watchPatterns.push(`.roo/hooks-${mode}/*.{json,yaml,yml}`)
}
// Global hooks: ~/.roo/hooks/*.{json,yaml,yml}
const globalHooksPattern = new vscode.RelativePattern(
vscode.Uri.file(path.join(os.homedir(), ".roo", "hooks")),
"*.{json,yaml,yml}",
)
watchPatterns.push("~/.roo/hooks/*.{json,yaml,yml}")
this.log(`[HookManager] Setting up file watchers for: ${watchPatterns.join(", ")}`)
// Create debounced reload handler
const debouncedReload = () => {
if (this.hookReloadTimeout) {
clearTimeout(this.hookReloadTimeout)
}
this.hookReloadTimeout = setTimeout(async () => {
this.log("[HookManager] Config file changed, reloading hooks...")
await this.reloadHooksConfig()
await this.postStateToWebview()
}, ClineProvider.HOOK_RELOAD_DEBOUNCE_MS)
}
// Create watchers for each pattern
const projectWatcher = vscode.workspace.createFileSystemWatcher(projectHooksPattern)
projectWatcher.onDidCreate(debouncedReload)
projectWatcher.onDidChange(debouncedReload)
projectWatcher.onDidDelete(debouncedReload)
this.hookFileWatchers.push(projectWatcher)
if (modeHooksPattern) {
const modeWatcher = vscode.workspace.createFileSystemWatcher(modeHooksPattern)
modeWatcher.onDidCreate(debouncedReload)
modeWatcher.onDidChange(debouncedReload)
modeWatcher.onDidDelete(debouncedReload)
this.hookFileWatchers.push(modeWatcher)
}
const globalWatcher = vscode.workspace.createFileSystemWatcher(globalHooksPattern)
globalWatcher.onDidCreate(debouncedReload)
globalWatcher.onDidChange(debouncedReload)
globalWatcher.onDidDelete(debouncedReload)
this.hookFileWatchers.push(globalWatcher)
this.log(`[HookManager] File watchers set up successfully (${this.hookFileWatchers.length} watchers)`)
}
/**
* Dispose hook file watchers.
*/
private disposeHookFileWatchers(): void {
if (this.hookReloadTimeout) {
clearTimeout(this.hookReloadTimeout)
this.hookReloadTimeout = undefined
}
for (const watcher of this.hookFileWatchers) {
watcher.dispose()
}
this.hookFileWatchers = []
}
/**
* Dispose the Hook Manager and clean up resources.
* Called when hooks experiment is disabled.
*/
public disposeHookManager(): void {
this.log("[HookManager] Disposing hook manager...")
// Dispose file watchers
this.disposeHookFileWatchers()
// Clear the hook manager reference
this.hookManager = undefined
this.log("[HookManager] Hook manager disposed")
}
/**
* Get the Hook Manager instance.
*/

View file

@ -0,0 +1,349 @@
// npx vitest run core/webview/__tests__/ClineProvider.hooks-dynamic-init.spec.ts
import type { IHookManager } from "../../../services/hooks/types"
// Mock vscode before importing ClineProvider
vi.mock("vscode", () => {
const mockFileSystemWatcher = {
onDidCreate: vi.fn().mockReturnValue({ dispose: vi.fn() }),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
onDidDelete: vi.fn().mockReturnValue({ dispose: vi.fn() }),
dispose: vi.fn(),
}
return {
window: {
showInformationMessage: vi.fn(),
showErrorMessage: vi.fn(),
showTextDocument: vi.fn().mockResolvedValue(undefined),
createWebviewPanel: vi.fn(),
},
workspace: {
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
openTextDocument: vi.fn().mockResolvedValue({ uri: { fsPath: "/mock/file" } }),
createFileSystemWatcher: vi.fn().mockReturnValue(mockFileSystemWatcher),
getConfiguration: vi.fn().mockReturnValue({
get: vi.fn(),
update: vi.fn(),
}),
},
commands: {
executeCommand: vi.fn().mockResolvedValue(undefined),
},
Uri: {
file: vi.fn((path: string) => ({ fsPath: path })),
joinPath: vi.fn((_base: { fsPath: string }, ...segments: string[]) => ({
fsPath: `${_base.fsPath}/${segments.join("/")}`,
})),
},
RelativePattern: vi.fn().mockImplementation((_base: unknown, _pattern: string) => ({ _base, _pattern })),
EventEmitter: vi.fn().mockImplementation(() => ({
event: vi.fn(),
fire: vi.fn(),
dispose: vi.fn(),
})),
ConfigurationTarget: {
Global: 1,
Workspace: 2,
},
ViewColumn: {
One: 1,
},
}
})
vi.mock("fs/promises", () => ({
default: {
mkdir: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(""),
writeFile: vi.fn().mockResolvedValue(undefined),
access: vi.fn().mockResolvedValue(undefined),
readdir: vi.fn().mockResolvedValue([]),
},
mkdir: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(""),
writeFile: vi.fn().mockResolvedValue(undefined),
access: vi.fn().mockResolvedValue(undefined),
readdir: vi.fn().mockResolvedValue([]),
}))
vi.mock("os", () => ({
default: {
homedir: vi.fn().mockReturnValue("/mock/home"),
},
homedir: vi.fn().mockReturnValue("/mock/home"),
}))
vi.mock("../../../services/hooks", () => ({
createHookManager: vi.fn().mockImplementation(() => createMockHookManager()),
}))
vi.mock("../../../utils/path", () => ({
getWorkspacePath: vi.fn().mockReturnValue("/mock/workspace"),
}))
import * as vscode from "vscode"
import { createHookManager } from "../../../services/hooks"
// Create mock HookManager
const createMockHookManager = (): IHookManager => ({
loadHooksConfig: vi.fn().mockResolvedValue({
hooksByEvent: new Map(),
hooksById: new Map(),
loadedAt: new Date(),
disabledHookIds: new Set(),
hasProjectHooks: false,
}),
reloadHooksConfig: vi.fn().mockResolvedValue(undefined),
getEnabledHooks: vi.fn().mockReturnValue([]),
executeHooks: vi.fn().mockResolvedValue({
results: [],
blocked: false,
totalDuration: 0,
}),
setHookEnabled: vi.fn().mockResolvedValue(undefined),
getHookExecutionHistory: vi.fn().mockReturnValue([]),
getConfigSnapshot: vi.fn().mockReturnValue({
hooksByEvent: new Map(),
hooksById: new Map(),
loadedAt: new Date(),
disabledHookIds: new Set(),
hasProjectHooks: false,
}),
})
describe("ClineProvider - Hook Dynamic Initialization", () => {
let mockHookManager: IHookManager
beforeEach(() => {
vi.clearAllMocks()
mockHookManager = createMockHookManager()
vi.mocked(createHookManager).mockReturnValue(mockHookManager)
vi.mocked(vscode.workspace.createFileSystemWatcher).mockClear()
})
describe("initializeHookManager", () => {
it("should always initialize hook manager (hooks is a core feature)", async () => {
// Create a mock provider-like object to test the logic
const cwd = "/mock/workspace"
const state = { mode: "code" }
// Hooks are always initialized - no experiment check needed
const newHookManager = createHookManager({
cwd,
mode: state.mode,
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
})
await newHookManager.loadHooksConfig()
expect(createHookManager).toHaveBeenCalledWith({
cwd,
mode: state.mode,
logger: expect.any(Object),
})
expect(newHookManager.loadHooksConfig).toHaveBeenCalled()
})
it("should set up file watchers for hook configuration files", async () => {
const cwd = "/mock/workspace"
const mode = "code"
// Simulate setupHookFileWatchers
// Project hooks pattern
const projectPattern = new vscode.RelativePattern(vscode.Uri.file(`${cwd}/.roo/hooks`), "*.{json,yaml,yml}")
// Mode-specific hooks pattern
const modePattern = new vscode.RelativePattern(
vscode.Uri.file(`${cwd}/.roo/hooks-${mode}`),
"*.{json,yaml,yml}",
)
// Global hooks pattern
const globalPattern = new vscode.RelativePattern(
vscode.Uri.file("/mock/home/.roo/hooks"),
"*.{json,yaml,yml}",
)
// Create watchers
vscode.workspace.createFileSystemWatcher(projectPattern)
vscode.workspace.createFileSystemWatcher(modePattern)
vscode.workspace.createFileSystemWatcher(globalPattern)
expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledTimes(3)
expect(vscode.RelativePattern).toHaveBeenCalledWith(expect.any(Object), "*.{json,yaml,yml}")
})
it("should register change event handlers on file watchers", async () => {
const watcher = vscode.workspace.createFileSystemWatcher({} as vscode.GlobPattern)
watcher.onDidCreate(vi.fn())
watcher.onDidChange(vi.fn())
watcher.onDidDelete(vi.fn())
expect(watcher.onDidCreate).toHaveBeenCalled()
expect(watcher.onDidChange).toHaveBeenCalled()
expect(watcher.onDidDelete).toHaveBeenCalled()
})
})
describe("disposeHookManager", () => {
it("should dispose all file watchers", () => {
const hookFileWatchers: { dispose: ReturnType<typeof vi.fn> }[] = [
{ dispose: vi.fn() },
{ dispose: vi.fn() },
{ dispose: vi.fn() },
]
// Simulate disposeHookFileWatchers
for (const watcher of hookFileWatchers) {
watcher.dispose()
}
expect(hookFileWatchers[0].dispose).toHaveBeenCalled()
expect(hookFileWatchers[1].dispose).toHaveBeenCalled()
expect(hookFileWatchers[2].dispose).toHaveBeenCalled()
})
it("should clear the hook manager reference", () => {
let hookManager: IHookManager | undefined = createMockHookManager()
// Simulate disposeHookManager
hookManager = undefined
expect(hookManager).toBeUndefined()
})
it("should clear pending reload timeout", () => {
vi.useFakeTimers()
let hookReloadTimeout: NodeJS.Timeout | undefined = setTimeout(() => {}, 500)
// Simulate clearing timeout in disposeHookFileWatchers
if (hookReloadTimeout) {
clearTimeout(hookReloadTimeout)
hookReloadTimeout = undefined
}
expect(hookReloadTimeout).toBeUndefined()
vi.useRealTimers()
})
})
describe("debounced reload", () => {
it("should debounce multiple file change events", async () => {
vi.useFakeTimers()
const HOOK_RELOAD_DEBOUNCE_MS = 500
const mockReload = vi.fn()
let hookReloadTimeout: NodeJS.Timeout | undefined
// Simulate debounced reload function
const debouncedReload = () => {
if (hookReloadTimeout) {
clearTimeout(hookReloadTimeout)
}
hookReloadTimeout = setTimeout(async () => {
await mockReload()
}, HOOK_RELOAD_DEBOUNCE_MS)
}
// Trigger multiple change events rapidly
debouncedReload()
debouncedReload()
debouncedReload()
// Should not have called reload yet
expect(mockReload).not.toHaveBeenCalled()
// Advance time by debounce duration
await vi.advanceTimersByTimeAsync(HOOK_RELOAD_DEBOUNCE_MS)
// Should have called reload exactly once
expect(mockReload).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it("should reset debounce timer on each new change event", async () => {
vi.useFakeTimers()
const HOOK_RELOAD_DEBOUNCE_MS = 500
const mockReload = vi.fn()
let hookReloadTimeout: NodeJS.Timeout | undefined
const debouncedReload = () => {
if (hookReloadTimeout) {
clearTimeout(hookReloadTimeout)
}
hookReloadTimeout = setTimeout(async () => {
await mockReload()
}, HOOK_RELOAD_DEBOUNCE_MS)
}
// First change
debouncedReload()
// Advance time partially
await vi.advanceTimersByTimeAsync(300)
// Second change should reset the timer
debouncedReload()
// Advance time by original debounce (should not trigger because timer was reset)
await vi.advanceTimersByTimeAsync(300)
expect(mockReload).not.toHaveBeenCalled()
// Advance remaining time
await vi.advanceTimersByTimeAsync(200)
expect(mockReload).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
})
})
describe("File Watcher Patterns", () => {
it("should watch project hooks directory with correct pattern", () => {
const cwd = "/test/workspace"
const expectedPath = `${cwd}/.roo/hooks`
const expectedPattern = "*.{json,yaml,yml}"
new vscode.RelativePattern(vscode.Uri.file(expectedPath), expectedPattern)
expect(vscode.Uri.file).toHaveBeenCalledWith(expectedPath)
expect(vscode.RelativePattern).toHaveBeenCalledWith(expect.any(Object), expectedPattern)
})
it("should watch mode-specific hooks directory when mode is provided", () => {
const cwd = "/test/workspace"
const mode = "architect"
const expectedPath = `${cwd}/.roo/hooks-${mode}`
new vscode.RelativePattern(vscode.Uri.file(expectedPath), "*.{json,yaml,yml}")
expect(vscode.Uri.file).toHaveBeenCalledWith(expectedPath)
})
it("should watch global hooks directory", () => {
const homedir = "/mock/home"
const expectedPath = `${homedir}/.roo/hooks`
new vscode.RelativePattern(vscode.Uri.file(expectedPath), "*.{json,yaml,yml}")
expect(vscode.Uri.file).toHaveBeenCalledWith(expectedPath)
})
it("should support json, yaml, and yml file extensions", () => {
const expectedPattern = "*.{json,yaml,yml}"
new vscode.RelativePattern(vscode.Uri.file("/any/path"), expectedPattern)
expect(vscode.RelativePattern).toHaveBeenCalledWith(expect.any(Object), expectedPattern)
})
})

View file

@ -640,18 +640,6 @@ export const webviewMessageHandler = async (
...oldExperiments,
...(value as Record<ExperimentId, boolean>),
}
// Check if hooks experiment was just enabled
const newExperiments = newValue as Record<ExperimentId, boolean>
if (
!experiments.isEnabled(oldExperiments, EXPERIMENT_IDS.HOOKS) &&
experiments.isEnabled(newExperiments, EXPERIMENT_IDS.HOOKS)
) {
// Initialize HookManager when hooks experiment is enabled
provider.initializeHookManager().catch((error) => {
provider.log(`Failed to initialize Hook Manager after experiment enable: ${error}`)
})
}
} else if (key === "customSupportPrompts") {
if (!value) {
continue
@ -3355,11 +3343,6 @@ export const webviewMessageHandler = async (
// =====================================================================
case "hooksReloadConfig": {
// Check if hooks experiment is enabled
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
break
}
// Reload hooks configuration from all sources
const hookManager = provider.getHookManager()
if (hookManager) {
@ -3377,11 +3360,6 @@ export const webviewMessageHandler = async (
}
case "hooksSetEnabled": {
// Check if hooks experiment is enabled
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
break
}
// Enable or disable a specific hook
const hookManager = provider.getHookManager()
if (hookManager && message.hookId && typeof message.hookEnabled === "boolean") {
@ -3399,11 +3377,6 @@ export const webviewMessageHandler = async (
}
case "hooksSetAllEnabled": {
// Check if hooks experiment is enabled
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
break
}
// Enable or disable hooks globally via the master toggle.
// This is stored in global state and checked before executing any hook.
if (typeof message.hooksEnabled === "boolean") {
@ -3421,11 +3394,6 @@ export const webviewMessageHandler = async (
}
case "hooksOpenConfigFolder": {
// Check if hooks experiment is enabled
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
break
}
// Open the hooks configuration folder in VS Code
const source = message.hooksSource ?? "project"
try {
@ -3456,11 +3424,6 @@ export const webviewMessageHandler = async (
}
case "hooksDeleteHook": {
// Check if hooks experiment is enabled
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
break
}
const hookManager = provider.getHookManager()
if (!hookManager || !message.hookId) {
break
@ -3567,11 +3530,6 @@ export const webviewMessageHandler = async (
}
case "hooksOpenHookFile": {
// Check if hooks experiment is enabled
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
break
}
const { filePath: hookFilePath } = message
if (!hookFilePath) {
return
@ -3595,12 +3553,6 @@ export const webviewMessageHandler = async (
}
case "hooksCreateNew": {
// Check if hooks experiment is enabled
const hooksExperimentsState = getGlobalState("experiments") ?? experimentDefault
if (!experiments.isEnabled(hooksExperimentsState, EXPERIMENT_IDS.HOOKS)) {
break
}
try {
const cwd = provider.cwd
const hooksPath = path.join(cwd, ".roo", "hooks")

View file

@ -33,7 +33,6 @@ describe("experiments", () => {
runSlashCommand: false,
multipleNativeToolCalls: false,
customTools: false,
hooks: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
})
@ -47,7 +46,6 @@ describe("experiments", () => {
runSlashCommand: false,
multipleNativeToolCalls: false,
customTools: false,
hooks: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true)
})
@ -61,7 +59,6 @@ describe("experiments", () => {
runSlashCommand: false,
multipleNativeToolCalls: false,
customTools: false,
hooks: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
})

View file

@ -8,7 +8,6 @@ export const EXPERIMENT_IDS = {
RUN_SLASH_COMMAND: "runSlashCommand",
MULTIPLE_NATIVE_TOOL_CALLS: "multipleNativeToolCalls",
CUSTOM_TOOLS: "customTools",
HOOKS: "hooks",
} as const satisfies Record<string, ExperimentId>
type _AssertExperimentIds = AssertEqual<Equals<ExperimentId, Values<typeof EXPERIMENT_IDS>>>
@ -27,7 +26,6 @@ export const experimentConfigsMap: Record<ExperimentKey, ExperimentConfig> = {
RUN_SLASH_COMMAND: { enabled: false },
MULTIPLE_NATIVE_TOOL_CALLS: { enabled: false },
CUSTOM_TOOLS: { enabled: false },
HOOKS: { enabled: false },
}
export const experimentDefault = Object.fromEntries(

View file

@ -66,18 +66,15 @@ const App = () => {
cloudOrganizations,
renderContext,
mdmCompliant,
experiments,
} = useExtensionState()
// Hooks auto-reload
// Hooks auto-reload (always enabled since hooks is a permanent feature)
useEffect(() => {
if (experiments?.hooks) {
const intervalId = setInterval(() => {
vscode.postMessage({ type: "hooksReloadConfig" })
}, 5000)
return () => clearInterval(intervalId)
}
}, [experiments?.hooks])
const intervalId = setInterval(() => {
vscode.postMessage({ type: "hooksReloadConfig" })
}, 5000)
return () => clearInterval(intervalId)
}, [])
// Create a persistent state manager
const marketplaceStateManager = useMemo(() => new MarketplaceViewStateManager(), [])

View file

@ -173,14 +173,14 @@ describe("App", () => {
})
})
it("auto-reloads hooks config every 5 seconds when hooks experiment is enabled", () => {
it("auto-reloads hooks config every 5 seconds", () => {
vi.useFakeTimers()
mockUseExtensionState.mockReturnValue({
didHydrateState: true,
showWelcome: false,
shouldShowAnnouncement: false,
experiments: { hooks: true },
experiments: {},
language: "en",
telemetrySetting: "enabled",
})
@ -212,29 +212,6 @@ describe("App", () => {
vi.useRealTimers()
})
it("does not auto-reload hooks config when hooks experiment is disabled", () => {
vi.useFakeTimers()
mockUseExtensionState.mockReturnValue({
didHydrateState: true,
showWelcome: false,
shouldShowAnnouncement: false,
experiments: { hooks: false },
language: "en",
telemetrySetting: "enabled",
})
render(<AppWithProviders />)
// Advance time by 5s
act(() => {
vi.advanceTimersByTime(5000)
})
expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: "hooksReloadConfig" })
vi.useRealTimers()
})
afterEach(() => {
cleanup()
window.removeEventListener("message", () => {})

View file

@ -521,8 +521,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
}
}, [])
const sections: { id: SectionName; icon: LucideIcon }[] = useMemo(() => {
const allSections: { id: SectionName; icon: LucideIcon }[] = [
const sections: { id: SectionName; icon: LucideIcon }[] = useMemo(
() => [
{ id: "providers", icon: Plug },
{ id: "modes", icon: Users2 },
{ id: "mcp", icon: Server },
@ -539,10 +539,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{ id: "experimental", icon: FlaskConical },
{ id: "language", icon: Globe },
{ id: "about", icon: Info },
]
// Filter out hooks section if the experiment is not enabled
return allSections.filter((section) => section.id !== "hooks" || experiments?.hooks === true)
}, [experiments?.hooks])
],
[],
)
// Update target section logic to set active tab
useEffect(() => {
@ -885,8 +884,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{/* MCP Section */}
{renderTab === "mcp" && <McpView />}
{/* Hooks Section - only render if experiment is enabled */}
{renderTab === "hooks" && experiments?.hooks === true && <HooksSettings />}
{/* Hooks Section */}
{renderTab === "hooks" && <HooksSettings />}
{/* Prompts Section */}
{renderTab === "prompts" && (

View file

@ -897,7 +897,7 @@
},
"HOOKS": {
"name": "Habilitar Hooks",
"description": "Utilitza ordres de shell personalitzades per automatitzar accions abans o després de l'execució d'eines. (Cal reiniciar després de desar la configuració)"
"description": "Utilitza ordres de shell personalitzades per automatitzar accions abans o després de l'execució d'eines."
}
},
"promptCaching": {

View file

@ -897,7 +897,7 @@
},
"HOOKS": {
"name": "Hooks aktivieren",
"description": "Verwende benutzerdefinierte Shell-Befehle, um Aktionen vor oder nach der Tool-Ausführung zu automatisieren. (Neustart erforderlich nach dem Speichern der Einstellungen)"
"description": "Verwende benutzerdefinierte Shell-Befehle, um Aktionen vor oder nach der Tool-Ausführung zu automatisieren."
}
},
"promptCaching": {

View file

@ -906,7 +906,7 @@
},
"HOOKS": {
"name": "Enable Hooks",
"description": "Use custom shell commands to automate actions before or after tool execution. (Restart required after saving settings)"
"description": "Use custom shell commands to automate actions before or after tool execution."
}
},
"promptCaching": {

View file

@ -897,7 +897,7 @@
},
"HOOKS": {
"name": "Habilitar Hooks",
"description": "Usa comandos de shell personalizados para automatizar acciones antes o después de la ejecución de herramientas. (Se requiere reinicio después de guardar la configuración)"
"description": "Usa comandos de shell personalizados para automatizar acciones antes o después de la ejecución de herramientas."
}
},
"promptCaching": {

View file

@ -898,7 +898,7 @@
},
"HOOKS": {
"name": "Abilita Hooks",
"description": "Usa comandi shell personalizzati per automatizzare azioni prima o dopo l'esecuzione degli strumenti. (Richiede riavvio dopo aver salvato le impostazioni)"
"description": "Usa comandi shell personalizzati per automatizzare azioni prima o dopo l'esecuzione degli strumenti."
}
},
"promptCaching": {

View file

@ -898,7 +898,7 @@
},
"HOOKS": {
"name": "Hook'ları Etkinleştir",
"description": "Araç yürütmeden önce veya sonra eylemleri otomatikleştirmek için özel kabuk komutları kullanın. (Ayarları kaydettikten sonra yeniden başlatma gerekli)"
"description": "Araç yürütmeden önce veya sonra eylemleri otomatikleştirmek için özel kabuk komutları kullanın."
}
},
"promptCaching": {