feat: add stop hook continuation on blocking and example hooks (#657)

This commit is contained in:
jnjpng
2026-01-23 15:28:18 -08:00
committed by GitHub
parent 76f0e114ee
commit cc2b33bb6b
9 changed files with 572 additions and 200 deletions

21
hooks/fix-on-changes.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
# Hook script: Run bun run fix if there are uncommitted changes
# Triggered on: Stop event
# Check if there are any uncommitted changes (staged or unstaged)
if git diff --quiet HEAD 2>/dev/null; then
echo "No changes, skipping."
exit 0
fi
# Run fix - capture output and send to stderr on failure
output=$(bun run fix 2>&1)
exit_code=$?
if [ $exit_code -eq 0 ]; then
echo "$output"
exit 0
else
echo "$output" >&2
exit 2
fi

21
hooks/typecheck-on-changes.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
# Hook script: Run typecheck if there are uncommitted changes
# Triggered on: Stop event
# Check if there are any uncommitted changes (staged or unstaged)
if git diff --quiet HEAD 2>/dev/null; then
echo "No changes, skipping."
exit 0
fi
# Run typecheck - capture output and send to stderr on failure
output=$(tsc --noEmit --pretty 2>&1)
exit_code=$?
if [ $exit_code -eq 0 ]; then
echo "$output"
exit 0
else
echo "$output" >&2
exit 2
fi

View File

@@ -2359,16 +2359,51 @@ export default function App({
conversationBusyRetriesRef.current = 0; conversationBusyRetriesRef.current = 0;
lastDequeuedMessageRef.current = null; // Clear - message was processed successfully lastDequeuedMessageRef.current = null; // Clear - message was processed successfully
// Run Stop hooks (fire-and-forget) // Run Stop hooks - if blocked/errored, continue the conversation with feedback
runStopHooks( const stopHookResult = await runStopHooks(
stopReasonToHandle, stopReasonToHandle,
buffersRef.current.order.length, buffersRef.current.order.length,
Array.from(buffersRef.current.byId.values()).filter( Array.from(buffersRef.current.byId.values()).filter(
(item) => item.kind === "tool_call", (item) => item.kind === "tool_call",
).length, ).length,
).catch(() => { );
// Silently ignore hook errors
}); // If hook blocked (exit 2), inject stderr feedback and continue conversation
if (stopHookResult.blocked) {
const stderrOutput = stopHookResult.results
.map((r) => r.stderr)
.filter(Boolean)
.join("\n");
const feedback = stderrOutput || "Stop hook blocked";
const hookMessage = `<stop-hook>\n${feedback}\n</stop-hook>`;
// Add status to transcript so user sees what's happening
const statusId = uid("status");
buffersRef.current.byId.set(statusId, {
kind: "status",
id: statusId,
lines: [
"Stop hook encountered blocking error, continuing loop with stderr feedback.",
],
});
buffersRef.current.order.push(statusId);
refreshDerived();
// Continue conversation with the hook feedback
setTimeout(() => {
processConversation(
[
{
type: "message",
role: "user",
content: hookMessage,
},
],
{ allowReentry: true },
);
}, 0);
return;
}
// Disable eager approval check after first successful message (LET-7101) // Disable eager approval check after first successful message (LET-7101)
// Any new approvals from here on are from our own turn, not orphaned // Any new approvals from here on are from our own turn, not orphaned

View File

@@ -4,14 +4,24 @@
import { Box, Text, useInput } from "ink"; import { Box, Text, useInput } from "ink";
import TextInput from "ink-text-input"; import TextInput from "ink-text-input";
import { memo, useCallback, useEffect, useState } from "react"; import { memo, useCallback, useEffect, useState } from "react";
import type { HookEvent, HookMatcher } from "../../hooks/types"; import {
type HookEvent,
type HookMatcher,
isToolEvent,
type SimpleHookEvent,
type SimpleHookMatcher,
type ToolHookEvent,
} from "../../hooks/types";
import { import {
addHookMatcher, addHookMatcher,
addSimpleHookMatcher,
countHooksForEvent, countHooksForEvent,
countTotalHooks, countTotalHooks,
type HookMatcherWithSource, type HookMatcherWithSource,
loadHooksWithSource, type HookWithSource,
removeHookMatcher, loadMatchersWithSource,
loadSimpleMatchersWithSource,
removeHook,
type SaveLocation, type SaveLocation,
} from "../../hooks/writer"; } from "../../hooks/writer";
import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTerminalWidth } from "../hooks/useTerminalWidth";
@@ -31,8 +41,8 @@ interface HooksManagerProps {
type Screen = type Screen =
| "events" | "events"
| "matchers" | "hooks-list" // Was "matchers" - now handles both matchers and commands
| "add-matcher" | "add-matcher" // For tool events only
| "add-command" | "add-command"
| "save-location" | "save-location"
| "delete-confirm"; | "delete-confirm";
@@ -127,7 +137,8 @@ export const HooksManager = memo(function HooksManager({
const [screen, setScreen] = useState<Screen>("events"); const [screen, setScreen] = useState<Screen>("events");
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedEvent, setSelectedEvent] = useState<HookEvent | null>(null); const [selectedEvent, setSelectedEvent] = useState<HookEvent | null>(null);
const [matchers, setMatchers] = useState<HookMatcherWithSource[]>([]); // For tool events: HookMatcherWithSource[], for simple events: HookCommandWithSource[]
const [hooks, setHooks] = useState<HookWithSource[]>([]);
const [totalHooks, setTotalHooks] = useState(0); const [totalHooks, setTotalHooks] = useState(0);
// New hook state // New hook state
@@ -136,9 +147,12 @@ export const HooksManager = memo(function HooksManager({
const [selectedLocation, setSelectedLocation] = useState(0); const [selectedLocation, setSelectedLocation] = useState(0);
// Delete confirmation // Delete confirmation
const [deleteMatcherIndex, setDeleteMatcherIndex] = useState(-1); const [deleteHookIndex, setDeleteHookIndex] = useState(-1);
const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(1); // Default to No const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(1); // Default to No
// Helper to check if current event is a tool event
const isCurrentToolEvent = selectedEvent ? isToolEvent(selectedEvent) : false;
// Refresh counts - called when hooks change // Refresh counts - called when hooks change
const refreshCounts = useCallback(() => { const refreshCounts = useCallback(() => {
setTotalHooks(countTotalHooks()); setTotalHooks(countTotalHooks());
@@ -151,10 +165,13 @@ export const HooksManager = memo(function HooksManager({
} }
}, [screen, refreshCounts]); }, [screen, refreshCounts]);
// Load matchers when event is selected // Load hooks when event is selected (matchers for both tool and simple events)
const loadMatchers = useCallback((event: HookEvent) => { const loadHooks = useCallback((event: HookEvent) => {
const loaded = loadHooksWithSource(event); if (isToolEvent(event)) {
setMatchers(loaded); setHooks(loadMatchersWithSource(event as ToolHookEvent));
} else {
setHooks(loadSimpleMatchersWithSource(event as SimpleHookEvent));
}
}, []); }, []);
// Handle adding a hook // Handle adding a hook
@@ -164,52 +181,59 @@ export const HooksManager = memo(function HooksManager({
const location = SAVE_LOCATIONS[selectedLocation]?.location; const location = SAVE_LOCATIONS[selectedLocation]?.location;
if (!location) return; if (!location) return;
const matcher: HookMatcher = { if (isToolEvent(selectedEvent)) {
matcher: newMatcher.trim() || "*", // Tool events use HookMatcher with matcher pattern
hooks: [{ type: "command", command: newCommand.trim() }], const matcher: HookMatcher = {
}; matcher: newMatcher.trim() || "*",
hooks: [{ type: "command", command: newCommand.trim() }],
};
await addHookMatcher(selectedEvent as ToolHookEvent, matcher, location);
} else {
// Simple events use SimpleHookMatcher (same structure, just no matcher field)
const matcher: SimpleHookMatcher = {
hooks: [{ type: "command", command: newCommand.trim() }],
};
await addSimpleHookMatcher(
selectedEvent as SimpleHookEvent,
matcher,
location,
);
}
await addHookMatcher(selectedEvent, matcher, location); loadHooks(selectedEvent);
loadMatchers(selectedEvent);
refreshCounts(); refreshCounts();
// Reset and go back to matchers // Reset and go back to hooks list
setNewMatcher(""); setNewMatcher("");
setNewCommand(""); setNewCommand("");
setSelectedLocation(0); setSelectedLocation(0);
setScreen("matchers"); setScreen("hooks-list");
setSelectedIndex(0); setSelectedIndex(0);
}, [ }, [
selectedEvent, selectedEvent,
newMatcher, newMatcher,
newCommand, newCommand,
selectedLocation, selectedLocation,
loadMatchers, loadHooks,
refreshCounts, refreshCounts,
]); ]);
// Handle deleting a hook // Handle deleting a hook
const handleDeleteHook = useCallback(async () => { const handleDeleteHook = useCallback(async () => {
if (deleteMatcherIndex < 0 || !selectedEvent) return; if (deleteHookIndex < 0 || !selectedEvent) return;
const matcher = matchers[deleteMatcherIndex]; const hook = hooks[deleteHookIndex];
if (!matcher) return; if (!hook) return;
await removeHookMatcher(selectedEvent, matcher.sourceIndex, matcher.source); await removeHook(selectedEvent, hook.sourceIndex, hook.source);
loadMatchers(selectedEvent); loadHooks(selectedEvent);
refreshCounts(); refreshCounts();
// Reset and go back to matchers // Reset and go back to hooks list
setDeleteMatcherIndex(-1); setDeleteHookIndex(-1);
setScreen("matchers"); setScreen("hooks-list");
setSelectedIndex(0); setSelectedIndex(0);
}, [ }, [deleteHookIndex, selectedEvent, hooks, loadHooks, refreshCounts]);
deleteMatcherIndex,
selectedEvent,
matchers,
loadMatchers,
refreshCounts,
]);
useInput((input, key) => { useInput((input, key) => {
// CTRL-C: immediately cancel // CTRL-C: immediately cancel
@@ -228,16 +252,16 @@ export const HooksManager = memo(function HooksManager({
const selected = HOOK_EVENTS[selectedIndex]; const selected = HOOK_EVENTS[selectedIndex];
if (selected) { if (selected) {
setSelectedEvent(selected.event); setSelectedEvent(selected.event);
loadMatchers(selected.event); loadHooks(selected.event);
setScreen("matchers"); setScreen("hooks-list");
setSelectedIndex(0); setSelectedIndex(0);
} }
} else if (key.escape) { } else if (key.escape) {
onClose(); onClose();
} }
} else if (screen === "matchers") { } else if (screen === "hooks-list") {
// Items: [+ Add new matcher] + existing matchers // Items: [+ Add new hook] + existing hooks
const itemCount = matchers.length + 1; const itemCount = hooks.length + 1;
if (key.upArrow) { if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1)); setSelectedIndex((prev) => Math.max(0, prev - 1));
@@ -245,15 +269,20 @@ export const HooksManager = memo(function HooksManager({
setSelectedIndex((prev) => Math.min(itemCount - 1, prev + 1)); setSelectedIndex((prev) => Math.min(itemCount - 1, prev + 1));
} else if (key.return) { } else if (key.return) {
if (selectedIndex === 0) { if (selectedIndex === 0) {
// Add new matcher // Add new hook - for tool events, go to matcher screen; for simple, go to command
setScreen("add-matcher"); if (isCurrentToolEvent) {
setNewMatcher(""); setScreen("add-matcher");
setNewMatcher("");
} else {
setScreen("add-command");
setNewCommand("");
}
} else { } else {
// Could add edit functionality here // Could add edit functionality here
} }
} else if ((input === "d" || input === "D") && selectedIndex > 0) { } else if ((input === "d" || input === "D") && selectedIndex > 0) {
// Delete selected matcher // Delete selected hook
setDeleteMatcherIndex(selectedIndex - 1); setDeleteHookIndex(selectedIndex - 1);
setDeleteConfirmIndex(1); // Default to No setDeleteConfirmIndex(1); // Default to No
setScreen("delete-confirm"); setScreen("delete-confirm");
} else if (key.escape) { } else if (key.escape) {
@@ -262,12 +291,12 @@ export const HooksManager = memo(function HooksManager({
setSelectedEvent(null); setSelectedEvent(null);
} }
} else if (screen === "add-matcher") { } else if (screen === "add-matcher") {
// Text input handles most keys // Text input handles most keys (tool events only)
if (key.return && !key.shift) { if (key.return && !key.shift) {
setScreen("add-command"); setScreen("add-command");
setNewCommand(""); setNewCommand("");
} else if (key.escape) { } else if (key.escape) {
setScreen("matchers"); setScreen("hooks-list");
setSelectedIndex(0); setSelectedIndex(0);
setNewMatcher(""); setNewMatcher("");
} }
@@ -276,7 +305,13 @@ export const HooksManager = memo(function HooksManager({
setScreen("save-location"); setScreen("save-location");
setSelectedLocation(0); setSelectedLocation(0);
} else if (key.escape) { } else if (key.escape) {
setScreen("add-matcher"); // Go back to matcher screen for tool events, or hooks list for simple
if (isCurrentToolEvent) {
setScreen("add-matcher");
} else {
setScreen("hooks-list");
setSelectedIndex(0);
}
} }
} else if (screen === "save-location") { } else if (screen === "save-location") {
if (key.upArrow) { if (key.upArrow) {
@@ -297,10 +332,10 @@ export const HooksManager = memo(function HooksManager({
if (deleteConfirmIndex === 0) { if (deleteConfirmIndex === 0) {
handleDeleteHook(); handleDeleteHook();
} else { } else {
setScreen("matchers"); setScreen("hooks-list");
} }
} else if (key.escape) { } else if (key.escape) {
setScreen("matchers"); setScreen("hooks-list");
} }
} }
}); });
@@ -342,49 +377,76 @@ export const HooksManager = memo(function HooksManager({
); );
} }
// Render Matchers List // Render Hooks List (matchers for tool events, commands for simple events)
if (screen === "matchers" && selectedEvent) { if (screen === "hooks-list" && selectedEvent) {
const title = isCurrentToolEvent
? ` ${selectedEvent} - Tool Matchers `
: ` ${selectedEvent} - Hooks `;
const addLabel = isCurrentToolEvent
? "+ Add new matcher..."
: "+ Add new hook...";
return ( return (
<Box flexDirection="column" paddingX={1}> <Box flexDirection="column" paddingX={1}>
<Text>{boxTop(boxWidth)}</Text> <Text>{boxTop(boxWidth)}</Text>
<Text>{boxLine(` ${selectedEvent} - Tool Matchers `, boxWidth)}</Text> <Text>{boxLine(title, boxWidth)}</Text>
<Text>{boxBottom(boxWidth)}</Text> <Text>{boxBottom(boxWidth)}</Text>
<Text dimColor>Input to command is JSON of tool call arguments.</Text> {isCurrentToolEvent ? (
<Text dimColor>Exit code 0 - stdout/stderr not shown</Text> <>
<Text dimColor> <Text dimColor>
Exit code 2 - show stderr to model and block tool call Input to command is JSON of tool call arguments.
</Text> </Text>
<Text dimColor> <Text dimColor>Exit code 0 - stdout/stderr not shown</Text>
Other exit codes - show stderr to user only but continue <Text dimColor>
</Text> Exit code 2 - show stderr to model and block tool call
</Text>
<Text dimColor>
Other exit codes - show stderr to user only but continue
</Text>
</>
) : (
<>
<Text dimColor>Exit code 0 - success, continue</Text>
<Text dimColor>Exit code 2 - show stderr to model and block</Text>
<Text dimColor>Other exit codes - show stderr to user only</Text>
</>
)}
<Text> </Text> <Text> </Text>
{/* Add new matcher option */} {/* Add new hook option */}
<Text> <Text>
<Text color={selectedIndex === 0 ? colors.input.prompt : undefined}> <Text color={selectedIndex === 0 ? colors.input.prompt : undefined}>
{selectedIndex === 0 ? "" : " "} 1.{" "} {selectedIndex === 0 ? "" : " "} 1.{" "}
</Text> </Text>
<Text color="green">+ Add new matcher...</Text> <Text color="green">{addLabel}</Text>
</Text> </Text>
{/* Existing matchers */} {/* Existing hooks */}
{matchers.map((matcher, index) => { {hooks.map((hook, index) => {
const isSelected = index + 1 === selectedIndex; const isSelected = index + 1 === selectedIndex;
const prefix = isSelected ? "" : " "; const prefix = isSelected ? "" : " ";
const sourceLabel = `[${getSourceLabel(matcher.source)}]`; const sourceLabel = `[${getSourceLabel(hook.source)}]`;
const matcherPattern = matcher.matcher || "*";
const command = matcher.hooks[0]?.command || ""; // Handle both tool matchers (with matcher field) and simple matchers (without)
const isToolMatcher = "matcher" in hook;
const matcherPattern = isToolMatcher
? (hook as HookMatcherWithSource).matcher || "*"
: null;
// Both types have hooks array
const command = "hooks" in hook ? hook.hooks[0]?.command || "" : "";
const truncatedCommand = const truncatedCommand =
command.length > 30 ? `${command.slice(0, 27)}...` : command; command.length > 30 ? `${command.slice(0, 27)}...` : command;
return ( return (
<Text key={`${matcher.source}-${index}`}> <Text key={`${hook.source}-${index}`}>
<Text color={isSelected ? colors.input.prompt : undefined}> <Text color={isSelected ? colors.input.prompt : undefined}>
{prefix} {index + 2}.{" "} {prefix} {index + 2}.{" "}
</Text> </Text>
<Text color="cyan">{sourceLabel}</Text> <Text color="cyan">{sourceLabel}</Text>
<Text> {matcherPattern.padEnd(12)} </Text> {matcherPattern !== null && (
<Text> {matcherPattern.padEnd(12)} </Text>
)}
<Text dimColor>{truncatedCommand}</Text> <Text dimColor>{truncatedCommand}</Text>
</Text> </Text>
); );
@@ -440,18 +502,20 @@ export const HooksManager = memo(function HooksManager({
); );
} }
// Render Add Matcher - Command Input // Render Add Command Input
if (screen === "add-command" && selectedEvent) { if (screen === "add-command" && selectedEvent) {
const title = isCurrentToolEvent
? ` Add new matcher for ${selectedEvent} `
: ` Add new hook for ${selectedEvent} `;
return ( return (
<Box flexDirection="column" paddingX={1}> <Box flexDirection="column" paddingX={1}>
<Text>{boxTop(boxWidth)}</Text> <Text>{boxTop(boxWidth)}</Text>
<Text> <Text>{boxLine(title, boxWidth)}</Text>
{boxLine(` Add new matcher for ${selectedEvent} `, boxWidth)}
</Text>
<Text>{boxBottom(boxWidth)}</Text> <Text>{boxBottom(boxWidth)}</Text>
<Text>Matcher: {newMatcher || "*"}</Text> {isCurrentToolEvent && <Text>Matcher: {newMatcher || "*"}</Text>}
<Text> </Text> {isCurrentToolEvent && <Text> </Text>}
<Text>Command:</Text> <Text>Command:</Text>
<Text>{boxTop(boxWidth - 2)}</Text> <Text>{boxTop(boxWidth - 2)}</Text>
@@ -481,7 +545,7 @@ export const HooksManager = memo(function HooksManager({
<Text> </Text> <Text> </Text>
<Text>Event: {selectedEvent}</Text> <Text>Event: {selectedEvent}</Text>
<Text>Matcher: {newMatcher || "*"}</Text> {isCurrentToolEvent && <Text>Matcher: {newMatcher || "*"}</Text>}
<Text>Command: {newCommand}</Text> <Text>Command: {newCommand}</Text>
<Text> </Text> <Text> </Text>
@@ -509,8 +573,14 @@ export const HooksManager = memo(function HooksManager({
} }
// Render Delete Confirmation // Render Delete Confirmation
if (screen === "delete-confirm" && deleteMatcherIndex >= 0) { if (screen === "delete-confirm" && deleteHookIndex >= 0) {
const matcher = matchers[deleteMatcherIndex]; const hook = hooks[deleteHookIndex];
const isToolMatcher = hook && "matcher" in hook;
const matcherPattern = isToolMatcher
? (hook as HookMatcherWithSource).matcher || "*"
: null;
// Both types have hooks array
const command = hook && "hooks" in hook ? hook.hooks[0]?.command : "";
return ( return (
<Box flexDirection="column" paddingX={1}> <Box flexDirection="column" paddingX={1}>
@@ -519,9 +589,9 @@ export const HooksManager = memo(function HooksManager({
<Text>{boxBottom(boxWidth)}</Text> <Text>{boxBottom(boxWidth)}</Text>
<Text> </Text> <Text> </Text>
<Text>Matcher: {matcher?.matcher || "*"}</Text> {matcherPattern !== null && <Text>Matcher: {matcherPattern}</Text>}
<Text>Command: {matcher?.hooks[0]?.command}</Text> <Text>Command: {command}</Text>
<Text>Source: {matcher ? getSourceLabel(matcher.source) : ""}</Text> <Text>Source: {hook ? getSourceLabel(hook.source) : ""}</Text>
<Text> </Text> <Text> </Text>
<Text> <Text>

View File

@@ -2,7 +2,16 @@
// Loads and matches hooks from settings-manager // Loads and matches hooks from settings-manager
import { settingsManager } from "../settings-manager"; import { settingsManager } from "../settings-manager";
import type { HookCommand, HookEvent, HooksConfig } from "./types"; import {
type HookCommand,
type HookEvent,
type HookMatcher,
type HooksConfig,
isToolEvent,
type SimpleHookEvent,
type SimpleHookMatcher,
type ToolHookEvent,
} from "./types";
/** /**
* Clear hooks cache - kept for API compatibility with existing callers. * Clear hooks cache - kept for API compatibility with existing callers.
@@ -71,7 +80,7 @@ export async function loadProjectLocalHooks(
/** /**
* Merge hooks configurations * Merge hooks configurations
* Priority order: project-local > project > global * Priority order: project-local > project > global
* For each event, matchers are ordered by priority (local first, global last) * For each event, hooks are ordered by priority (local first, global last)
*/ */
export function mergeHooksConfigs( export function mergeHooksConfigs(
global: HooksConfig, global: HooksConfig,
@@ -86,15 +95,34 @@ export function mergeHooksConfigs(
]) as Set<HookEvent>; ]) as Set<HookEvent>;
for (const event of allEvents) { for (const event of allEvents) {
const globalMatchers = global[event] || []; if (isToolEvent(event)) {
const projectMatchers = project[event] || []; // Tool events use HookMatcher[]
const projectLocalMatchers = projectLocal[event] || []; const toolEvent = event as ToolHookEvent;
// Project-local matchers run first, then project, then global const globalMatchers = (global[toolEvent] || []) as HookMatcher[];
merged[event] = [ const projectMatchers = (project[toolEvent] || []) as HookMatcher[];
...projectLocalMatchers, const projectLocalMatchers = (projectLocal[toolEvent] ||
...projectMatchers, []) as HookMatcher[];
...globalMatchers, // Project-local runs first, then project, then global
]; (merged as Record<ToolHookEvent, HookMatcher[]>)[toolEvent] = [
...projectLocalMatchers,
...projectMatchers,
...globalMatchers,
];
} else {
// Simple events use SimpleHookMatcher[] (same as HookMatcher but without matcher field)
const simpleEvent = event as SimpleHookEvent;
const globalMatchers = (global[simpleEvent] || []) as SimpleHookMatcher[];
const projectMatchers = (project[simpleEvent] ||
[]) as SimpleHookMatcher[];
const projectLocalMatchers = (projectLocal[simpleEvent] ||
[]) as SimpleHookMatcher[];
// Project-local runs first, then project, then global
(merged as Record<SimpleHookEvent, SimpleHookMatcher[]>)[simpleEvent] = [
...projectLocalMatchers,
...projectMatchers,
...globalMatchers,
];
}
} }
return merged; return merged;
@@ -146,22 +174,37 @@ export function getMatchingHooks(
event: HookEvent, event: HookEvent,
toolName?: string, toolName?: string,
): HookCommand[] { ): HookCommand[] {
const matchers = config[event]; if (isToolEvent(event)) {
if (!matchers || matchers.length === 0) { // Tool events use HookMatcher[] - need to match against tool name
return []; const matchers = config[event as ToolHookEvent] as
} | HookMatcher[]
| undefined;
if (!matchers || matchers.length === 0) {
return [];
}
const hooks: HookCommand[] = []; const hooks: HookCommand[] = [];
for (const matcher of matchers) {
if (!toolName || matchesTool(matcher.matcher, toolName)) {
hooks.push(...matcher.hooks);
}
}
return hooks;
} else {
// Simple events use SimpleHookMatcher[] - extract hooks from each matcher
const matchers = config[event as SimpleHookEvent] as
| SimpleHookMatcher[]
| undefined;
if (!matchers || matchers.length === 0) {
return [];
}
for (const matcher of matchers) { const hooks: HookCommand[] = [];
// For non-tool events, matcher is usually empty/"*" for (const matcher of matchers) {
// For tool events, check if the tool matches
if (!toolName || matchesTool(matcher.matcher, toolName)) {
hooks.push(...matcher.hooks); hooks.push(...matcher.hooks);
} }
return hooks;
} }
return hooks;
} }
/** /**
@@ -171,13 +214,27 @@ export function hasHooksForEvent(
config: HooksConfig, config: HooksConfig,
event: HookEvent, event: HookEvent,
): boolean { ): boolean {
const matchers = config[event]; if (isToolEvent(event)) {
if (!matchers || matchers.length === 0) { // Tool events use HookMatcher[]
return false; const matchers = config[event as ToolHookEvent] as
| HookMatcher[]
| undefined;
if (!matchers || matchers.length === 0) {
return false;
}
// Check if any matcher has hooks
return matchers.some((m) => m.hooks && m.hooks.length > 0);
} else {
// Simple events use SimpleHookMatcher[]
const matchers = config[event as SimpleHookEvent] as
| SimpleHookMatcher[]
| undefined;
if (!matchers || matchers.length === 0) {
return false;
}
// Check if any matcher has hooks
return matchers.some((m) => m.hooks && m.hooks.length > 0);
} }
// Check if any matcher has hooks
return matchers.some((m) => m.hooks && m.hooks.length > 0);
} }
/** /**

View File

@@ -2,12 +2,17 @@
// Types for Letta Code hooks system (Claude Code-compatible) // Types for Letta Code hooks system (Claude Code-compatible)
/** /**
* Hook event types that can trigger hooks * Tool-related hook events that require matchers to specify which tools to match
*/ */
export type HookEvent = export type ToolHookEvent =
| "PreToolUse" // Runs before tool calls (can block them) | "PreToolUse" // Runs before tool calls (can block them)
| "PostToolUse" // Runs after tool calls complete (cannot block) | "PostToolUse" // Runs after tool calls complete (cannot block)
| "PermissionRequest" // Runs when a permission dialog is shown (can allow or deny) | "PermissionRequest"; // Runs when a permission dialog is shown (can allow or deny)
/**
* Simple hook events that don't require matchers
*/
export type SimpleHookEvent =
| "UserPromptSubmit" // Runs when the user submits a prompt (can block) | "UserPromptSubmit" // Runs when the user submits a prompt (can block)
| "Notification" // Runs when a notification is sent (cannot block) | "Notification" // Runs when a notification is sent (cannot block)
| "Stop" // Runs when the agent finishes responding (can block) | "Stop" // Runs when the agent finishes responding (can block)
@@ -17,6 +22,11 @@ export type HookEvent =
| "SessionStart" // Runs when a new session starts or is resumed | "SessionStart" // Runs when a new session starts or is resumed
| "SessionEnd"; // Runs when session ends (cannot block) | "SessionEnd"; // Runs when session ends (cannot block)
/**
* All hook event types
*/
export type HookEvent = ToolHookEvent | SimpleHookEvent;
/** /**
* Individual hook command configuration * Individual hook command configuration
*/ */
@@ -30,7 +40,7 @@ export interface HookCommand {
} }
/** /**
* Hook matcher configuration - matches hooks to specific tools/events * Hook matcher configuration for tool events - matches hooks to specific tools
*/ */
export interface HookMatcher { export interface HookMatcher {
/** /**
@@ -44,13 +54,41 @@ export interface HookMatcher {
hooks: HookCommand[]; hooks: HookCommand[];
} }
/**
* Simple hook matcher for non-tool events - no matcher needed, just hooks
*/
export interface SimpleHookMatcher {
/** List of hooks to run */
hooks: HookCommand[];
}
/** /**
* Full hooks configuration stored in settings * Full hooks configuration stored in settings
* - Tool events (PreToolUse, PostToolUse, PermissionRequest) use HookMatcher[] with matcher patterns
* - Simple events use SimpleHookMatcher[] (same structure, just no matcher field)
*/ */
export type HooksConfig = { export type HooksConfig = {
[K in HookEvent]?: HookMatcher[]; [K in ToolHookEvent]?: HookMatcher[];
} & {
[K in SimpleHookEvent]?: SimpleHookMatcher[];
}; };
/**
* Set of tool events that require matchers
*/
export const TOOL_EVENTS: Set<HookEvent> = new Set([
"PreToolUse",
"PostToolUse",
"PermissionRequest",
]);
/**
* Type guard to check if an event is a tool event
*/
export function isToolEvent(event: HookEvent): event is ToolHookEvent {
return TOOL_EVENTS.has(event);
}
/** /**
* Exit codes from hook execution * Exit codes from hook execution
*/ */

View File

@@ -2,7 +2,15 @@
// Functions to write hooks to settings files via settings-manager // Functions to write hooks to settings files via settings-manager
import { settingsManager } from "../settings-manager"; import { settingsManager } from "../settings-manager";
import type { HookEvent, HookMatcher, HooksConfig } from "./types"; import {
type HookEvent,
type HookMatcher,
type HooksConfig,
isToolEvent,
type SimpleHookEvent,
type SimpleHookMatcher,
type ToolHookEvent,
} from "./types";
/** /**
* Save location for hooks * Save location for hooks
@@ -71,10 +79,10 @@ export async function saveHooksToLocation(
} }
/** /**
* Add a new hook matcher to an event * Add a new hook matcher to a tool event (PreToolUse, PostToolUse, PermissionRequest)
*/ */
export async function addHookMatcher( export async function addHookMatcher(
event: HookEvent, event: ToolHookEvent,
matcher: HookMatcher, matcher: HookMatcher,
location: SaveLocation, location: SaveLocation,
workingDirectory: string = process.cwd(), workingDirectory: string = process.cwd(),
@@ -83,61 +91,91 @@ export async function addHookMatcher(
// Initialize event array if needed // Initialize event array if needed
if (!hooks[event]) { if (!hooks[event]) {
hooks[event] = []; (hooks as Record<ToolHookEvent, HookMatcher[]>)[event] = [];
} }
// Add the new matcher // Add the new matcher
const eventMatchers = hooks[event]; const eventMatchers = hooks[event] as HookMatcher[];
if (eventMatchers) { eventMatchers.push(matcher);
eventMatchers.push(matcher);
await saveHooksToLocation(hooks, location, workingDirectory);
}
/**
* Add a new hook matcher to a simple event (non-tool events)
* Simple events use the same structure as tool events but without the matcher field
*/
export async function addSimpleHookMatcher(
event: SimpleHookEvent,
matcher: SimpleHookMatcher,
location: SaveLocation,
workingDirectory: string = process.cwd(),
): Promise<void> {
const hooks = loadHooksFromLocation(location, workingDirectory);
// Initialize event array if needed
if (!hooks[event]) {
(hooks as Record<SimpleHookEvent, SimpleHookMatcher[]>)[event] = [];
} }
// Add the new matcher
const eventMatchers = hooks[event] as SimpleHookMatcher[];
eventMatchers.push(matcher);
await saveHooksToLocation(hooks, location, workingDirectory); await saveHooksToLocation(hooks, location, workingDirectory);
} }
/** /**
* Remove a hook matcher from an event by index * Remove a hook matcher from an event by index
* Works for both tool events (HookMatcher) and simple events (SimpleHookMatcher)
*/ */
export async function removeHookMatcher( export async function removeHook(
event: HookEvent, event: HookEvent,
matcherIndex: number, index: number,
location: SaveLocation, location: SaveLocation,
workingDirectory: string = process.cwd(), workingDirectory: string = process.cwd(),
): Promise<void> { ): Promise<void> {
const hooks = loadHooksFromLocation(location, workingDirectory); const hooks = loadHooksFromLocation(location, workingDirectory);
const eventMatchers = hooks[event];
if ( if (isToolEvent(event)) {
!eventMatchers || const eventMatchers = hooks[event as ToolHookEvent] as
matcherIndex < 0 || | HookMatcher[]
matcherIndex >= eventMatchers.length | undefined;
) { if (!eventMatchers || index < 0 || index >= eventMatchers.length) {
throw new Error(`Invalid matcher index ${matcherIndex} for event ${event}`); throw new Error(`Invalid matcher index ${index} for event ${event}`);
} }
eventMatchers.splice(index, 1);
// Remove the matcher at the given index if (eventMatchers.length === 0) {
eventMatchers.splice(matcherIndex, 1); delete hooks[event as ToolHookEvent];
}
// Clean up empty arrays } else {
if (eventMatchers.length === 0) { const eventMatchers = hooks[event as SimpleHookEvent] as
delete hooks[event]; | SimpleHookMatcher[]
| undefined;
if (!eventMatchers || index < 0 || index >= eventMatchers.length) {
throw new Error(`Invalid matcher index ${index} for event ${event}`);
}
eventMatchers.splice(index, 1);
if (eventMatchers.length === 0) {
delete hooks[event as SimpleHookEvent];
}
} }
await saveHooksToLocation(hooks, location, workingDirectory); await saveHooksToLocation(hooks, location, workingDirectory);
} }
/** /**
* Update a hook matcher at a specific index * Update a hook matcher at a specific index (tool events only)
*/ */
export async function updateHookMatcher( export async function updateHookMatcher(
event: HookEvent, event: ToolHookEvent,
matcherIndex: number, matcherIndex: number,
matcher: HookMatcher, matcher: HookMatcher,
location: SaveLocation, location: SaveLocation,
workingDirectory: string = process.cwd(), workingDirectory: string = process.cwd(),
): Promise<void> { ): Promise<void> {
const hooks = loadHooksFromLocation(location, workingDirectory); const hooks = loadHooksFromLocation(location, workingDirectory);
const eventMatchers = hooks[event]; const eventMatchers = hooks[event] as HookMatcher[] | undefined;
if ( if (
!eventMatchers || !eventMatchers ||
@@ -147,14 +185,39 @@ export async function updateHookMatcher(
throw new Error(`Invalid matcher index ${matcherIndex} for event ${event}`); throw new Error(`Invalid matcher index ${matcherIndex} for event ${event}`);
} }
// Update the matcher at the given index
eventMatchers[matcherIndex] = matcher; eventMatchers[matcherIndex] = matcher;
await saveHooksToLocation(hooks, location, workingDirectory); await saveHooksToLocation(hooks, location, workingDirectory);
} }
/** /**
* Hook matcher with source tracking for display * Update a hook matcher at a specific index (simple events only)
*/
export async function updateSimpleHookMatcher(
event: SimpleHookEvent,
matcherIndex: number,
matcher: SimpleHookMatcher,
location: SaveLocation,
workingDirectory: string = process.cwd(),
): Promise<void> {
const hooks = loadHooksFromLocation(location, workingDirectory);
const eventMatchers = hooks[event] as SimpleHookMatcher[] | undefined;
if (
!eventMatchers ||
matcherIndex < 0 ||
matcherIndex >= eventMatchers.length
) {
throw new Error(`Invalid matcher index ${matcherIndex} for event ${event}`);
}
eventMatchers[matcherIndex] = matcher;
await saveHooksToLocation(hooks, location, workingDirectory);
}
/**
* Hook matcher with source tracking for display (tool events)
*/ */
export interface HookMatcherWithSource extends HookMatcher { export interface HookMatcherWithSource extends HookMatcher {
source: SaveLocation; source: SaveLocation;
@@ -162,21 +225,62 @@ export interface HookMatcherWithSource extends HookMatcher {
} }
/** /**
* Load all hooks for an event with source tracking * Simple hook matcher with source tracking for display (simple events)
* Returns matchers tagged with their source location
*/ */
export function loadHooksWithSource( export interface SimpleHookMatcherWithSource extends SimpleHookMatcher {
event: HookEvent, source: SaveLocation;
sourceIndex: number; // Index within that source file
}
/**
* Union type for hooks with source tracking
*/
export type HookWithSource =
| HookMatcherWithSource
| SimpleHookMatcherWithSource;
/**
* Load all hook matchers for a tool event with source tracking
*/
export function loadMatchersWithSource(
event: ToolHookEvent,
workingDirectory: string = process.cwd(), workingDirectory: string = process.cwd(),
): HookMatcherWithSource[] { ): HookMatcherWithSource[] {
const result: HookMatcherWithSource[] = []; const result: HookMatcherWithSource[] = [];
// Load from each location and tag with source
const locations: SaveLocation[] = ["project-local", "project", "user"]; const locations: SaveLocation[] = ["project-local", "project", "user"];
for (const location of locations) { for (const location of locations) {
const hooks = loadHooksFromLocation(location, workingDirectory); const hooks = loadHooksFromLocation(location, workingDirectory);
const matchers = hooks[event] || []; const matchers = (hooks[event] || []) as HookMatcher[];
for (let i = 0; i < matchers.length; i++) {
const matcher = matchers[i];
if (matcher) {
result.push({
...matcher,
source: location,
sourceIndex: i,
});
}
}
}
return result;
}
/**
* Load all hook matchers for a simple event with source tracking
*/
export function loadSimpleMatchersWithSource(
event: SimpleHookEvent,
workingDirectory: string = process.cwd(),
): SimpleHookMatcherWithSource[] {
const result: SimpleHookMatcherWithSource[] = [];
const locations: SaveLocation[] = ["project-local", "project", "user"];
for (const location of locations) {
const hooks = loadHooksFromLocation(location, workingDirectory);
const matchers = (hooks[event] || []) as SimpleHookMatcher[];
for (let i = 0; i < matchers.length; i++) { for (let i = 0; i < matchers.length; i++) {
const matcher = matchers[i]; const matcher = matchers[i];
@@ -206,9 +310,19 @@ export function countTotalHooks(
for (const location of locations) { for (const location of locations) {
const hooks = loadHooksFromLocation(location, workingDirectory); const hooks = loadHooksFromLocation(location, workingDirectory);
for (const event of Object.keys(hooks) as HookEvent[]) { for (const event of Object.keys(hooks) as HookEvent[]) {
const matchers = hooks[event] || []; if (isToolEvent(event)) {
for (const matcher of matchers) { // Tool events have HookMatcher[] with nested hooks
count += matcher.hooks.length; const matchers = (hooks[event as ToolHookEvent] || []) as HookMatcher[];
for (const matcher of matchers) {
count += matcher.hooks.length;
}
} else {
// Simple events have SimpleHookMatcher[] with nested hooks
const matchers = (hooks[event as SimpleHookEvent] ||
[]) as SimpleHookMatcher[];
for (const matcher of matchers) {
count += matcher.hooks.length;
}
} }
} }
} }
@@ -229,9 +343,19 @@ export function countHooksForEvent(
for (const location of locations) { for (const location of locations) {
const hooks = loadHooksFromLocation(location, workingDirectory); const hooks = loadHooksFromLocation(location, workingDirectory);
const matchers = hooks[event] || []; if (isToolEvent(event)) {
for (const matcher of matchers) { // Tool events have HookMatcher[] with nested hooks
count += matcher.hooks.length; const matchers = (hooks[event as ToolHookEvent] || []) as HookMatcher[];
for (const matcher of matchers) {
count += matcher.hooks.length;
}
} else {
// Simple events have SimpleHookMatcher[] with nested hooks
const matchers = (hooks[event as SimpleHookEvent] ||
[]) as SimpleHookMatcher[];
for (const matcher of matchers) {
count += matcher.hooks.length;
}
} }
} }

View File

@@ -11,7 +11,13 @@ import {
matchesTool, matchesTool,
mergeHooksConfigs, mergeHooksConfigs,
} from "../../hooks/loader"; } from "../../hooks/loader";
import type { HookEvent, HooksConfig } from "../../hooks/types"; import {
type HookEvent,
type HooksConfig,
isToolEvent,
type SimpleHookEvent,
type ToolHookEvent,
} from "../../hooks/types";
import { settingsManager } from "../../settings-manager"; import { settingsManager } from "../../settings-manager";
describe("Hooks Loader", () => { describe("Hooks Loader", () => {
@@ -251,11 +257,9 @@ describe("Hooks Loader", () => {
test("handles undefined tool name (for non-tool events)", () => { test("handles undefined tool name (for non-tool events)", () => {
const config: HooksConfig = { const config: HooksConfig = {
// Simple events use SimpleHookMatcher[] (hooks wrapper, no matcher)
SessionStart: [ SessionStart: [
{ { hooks: [{ type: "command", command: "session hook" }] },
matcher: "*",
hooks: [{ type: "command", command: "session hook" }],
},
], ],
}; };
@@ -367,12 +371,20 @@ describe("Hooks Loader", () => {
test("config can have all 11 event types", () => { test("config can have all 11 event types", () => {
const config: HooksConfig = {}; const config: HooksConfig = {};
for (const event of allEvents) { for (const event of allEvents) {
config[event] = [ if (isToolEvent(event)) {
{ // Tool events use HookMatcher[]
matcher: "*", (config as Record<ToolHookEvent, unknown>)[event as ToolHookEvent] = [
hooks: [{ type: "command", command: `echo ${event}` }], {
}, matcher: "*",
]; hooks: [{ type: "command", command: `echo ${event}` }],
},
];
} else {
// Simple events use SimpleHookMatcher[] (hooks wrapper)
(config as Record<SimpleHookEvent, unknown>)[
event as SimpleHookEvent
] = [{ hooks: [{ type: "command", command: `echo ${event}` }] }];
}
} }
for (const event of allEvents) { for (const event of allEvents) {
@@ -384,21 +396,21 @@ describe("Hooks Loader", () => {
test("merging preserves all event types", () => { test("merging preserves all event types", () => {
const global: HooksConfig = { const global: HooksConfig = {
// Tool events use HookMatcher[]
PreToolUse: [ PreToolUse: [
{ matcher: "*", hooks: [{ type: "command", command: "g1" }] }, { matcher: "*", hooks: [{ type: "command", command: "g1" }] },
], ],
SessionStart: [ // Simple events use SimpleHookMatcher[] (hooks wrapper)
{ matcher: "*", hooks: [{ type: "command", command: "g2" }] }, SessionStart: [{ hooks: [{ type: "command", command: "g2" }] }],
],
}; };
const project: HooksConfig = { const project: HooksConfig = {
// Tool events use HookMatcher[]
PostToolUse: [ PostToolUse: [
{ matcher: "*", hooks: [{ type: "command", command: "p1" }] }, { matcher: "*", hooks: [{ type: "command", command: "p1" }] },
], ],
SessionEnd: [ // Simple events use SimpleHookMatcher[] (hooks wrapper)
{ matcher: "*", hooks: [{ type: "command", command: "p2" }] }, SessionEnd: [{ hooks: [{ type: "command", command: "p2" }] }],
],
}; };
const merged = mergeHooksConfigs(global, project); const merged = mergeHooksConfigs(global, project);
@@ -503,14 +515,15 @@ describe("Hooks Loader", () => {
test("all three levels merge correctly", () => { test("all three levels merge correctly", () => {
const global: HooksConfig = { const global: HooksConfig = {
// Tool event with HookMatcher[]
PreToolUse: [ PreToolUse: [
{ matcher: "*", hooks: [{ type: "command", command: "global" }] }, { matcher: "*", hooks: [{ type: "command", command: "global" }] },
], ],
SessionEnd: [ // Simple event with SimpleHookMatcher[]
{ matcher: "*", hooks: [{ type: "command", command: "global-end" }] }, SessionEnd: [{ hooks: [{ type: "command", command: "global-end" }] }],
],
}; };
const project: HooksConfig = { const project: HooksConfig = {
// Tool event with HookMatcher[]
PreToolUse: [ PreToolUse: [
{ matcher: "*", hooks: [{ type: "command", command: "project" }] }, { matcher: "*", hooks: [{ type: "command", command: "project" }] },
], ],
@@ -522,14 +535,13 @@ describe("Hooks Loader", () => {
], ],
}; };
const projectLocal: HooksConfig = { const projectLocal: HooksConfig = {
// Tool event with HookMatcher[]
PreToolUse: [ PreToolUse: [
{ matcher: "*", hooks: [{ type: "command", command: "local" }] }, { matcher: "*", hooks: [{ type: "command", command: "local" }] },
], ],
// Simple event with SimpleHookMatcher[]
SessionStart: [ SessionStart: [
{ { hooks: [{ type: "command", command: "local-start" }] },
matcher: "*",
hooks: [{ type: "command", command: "local-start" }],
},
], ],
}; };

View File

@@ -535,17 +535,16 @@ describe("Settings Manager - Hooks", () => {
test("Hooks configuration persists to disk", async () => { test("Hooks configuration persists to disk", async () => {
settingsManager.updateSettings({ settingsManager.updateSettings({
hooks: { hooks: {
// Tool event with HookMatcher[]
PreToolUse: [ PreToolUse: [
{ {
matcher: "*", matcher: "*",
hooks: [{ type: "command", command: "echo persisted" }], hooks: [{ type: "command", command: "echo persisted" }],
}, },
], ],
// Simple event with SimpleHookMatcher[]
SessionStart: [ SessionStart: [
{ { hooks: [{ type: "command", command: "echo session" }] },
matcher: "*",
hooks: [{ type: "command", command: "echo session" }],
},
], ],
}, },
}); });
@@ -594,11 +593,9 @@ describe("Settings Manager - Hooks", () => {
settingsManager.updateLocalProjectSettings( settingsManager.updateLocalProjectSettings(
{ {
hooks: { hooks: {
// Simple event uses SimpleHookMatcher[] (hooks wrapper)
UserPromptSubmit: [ UserPromptSubmit: [
{ { hooks: [{ type: "command", command: "echo local-hook" }] },
matcher: "*",
hooks: [{ type: "command", command: "echo local-hook" }],
},
], ],
}, },
}, },
@@ -616,12 +613,8 @@ describe("Settings Manager - Hooks", () => {
settingsManager.updateLocalProjectSettings( settingsManager.updateLocalProjectSettings(
{ {
hooks: { hooks: {
Stop: [ // Simple event uses SimpleHookMatcher[] (hooks wrapper)
{ Stop: [{ hooks: [{ type: "command", command: "echo stop-hook" }] }],
matcher: "*",
hooks: [{ type: "command", command: "echo stop-hook" }],
},
],
}, },
}, },
testProjectDir, testProjectDir,
@@ -637,6 +630,7 @@ describe("Settings Manager - Hooks", () => {
await settingsManager.loadLocalProjectSettings(testProjectDir); await settingsManager.loadLocalProjectSettings(testProjectDir);
expect(reloaded.hooks?.Stop).toHaveLength(1); expect(reloaded.hooks?.Stop).toHaveLength(1);
// Simple event hooks are in SimpleHookMatcher format with hooks array
expect(reloaded.hooks?.Stop?.[0]?.hooks[0]?.command).toBe("echo stop-hook"); expect(reloaded.hooks?.Stop?.[0]?.hooks[0]?.command).toBe("echo stop-hook");
}); });