hooks: support array of commands per hook event

Hook values can now be a string or array of strings in agent JSON config.
Commands run in order; exit 2 from any command blocks immediately.
Context from all successful commands is concatenated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-19 11:53:57 +02:00
parent aeeb0a3e22
commit be42fbd739
4 changed files with 55 additions and 22 deletions

View File

@ -138,8 +138,8 @@ func BuildAgentEnv(cfg *config.Config, d tools.Dispatcher, cwd string) AgentEnv
agentPrompt := ""
var genParams backend.GenerationParams
if cfg != nil {
if cfg.Hooks != nil {
hooks = cfg.Hooks
for k, v := range cfg.Hooks {
hooks[k] = []string(v)
}
agentPrompt = cfg.Prompt
genParams = backend.GenerationParams{

View File

@ -22,8 +22,8 @@ const (
const defaultHookTimeout = 60
// Hooks maps hook names to shell command strings.
type Hooks map[string]string
// Hooks maps hook names to one or more shell commands.
type Hooks map[string][]string
// HookResult holds the outcome of running a hook.
type HookResult struct {
@ -36,29 +36,47 @@ type HookResult struct {
Context string
}
// Run executes the hook for the named event, sending payload as JSON on stdin.
// Returns a HookResult describing whether the hook ran, blocked, and any context.
// Run executes all commands for the named hook in order, sending payload as
// JSON on stdin for each. Returns a combined HookResult.
//
// Exit codes:
// - 0: success. Stdout is added as conversation context.
// - 2: block. For UserPromptSubmit: don't send the prompt.
// For Stop: don't stop, continue with stderr as the next prompt.
// Exit codes per command:
// - 0: success. Stdout is appended to combined context.
// - 2: block. Stops execution immediately and returns blocked.
// - other: non-blocking warning (stderr logged, execution continues).
func (h Hooks) Run(ctx context.Context, name string, payload any) HookResult {
cmdStr := h[name]
clog.Debug("hook %s: cmd=%q", name, cmdStr)
if cmdStr == "" {
cmds := h[name]
if len(cmds) == 0 {
return HookResult{}
}
payloadJSON, _ := json.Marshal(payload)
var cwd string
if m, ok := payload.(map[string]string); ok {
cwd = m["cwd"]
}
var contextParts []string
for _, cmdStr := range cmds {
clog.Debug("hook %s: cmd=%q", name, cmdStr)
result := runHookCmd(ctx, name, cmdStr, payloadJSON, cwd)
if !result.Ran {
continue
}
if result.Blocked {
return HookResult{Ran: true, Blocked: true, Context: result.Context}
}
if result.Context != "" {
contextParts = append(contextParts, result.Context)
}
}
return HookResult{Ran: true, Context: strings.Join(contextParts, "\n")}
}
func runHookCmd(ctx context.Context, name, cmdStr string, payloadJSON []byte, cwd string) HookResult {
cmd := exec.CommandContext(ctx, "sh", "-c", cmdStr)
cmd.Stdin = bytes.NewReader(payloadJSON)
if m, ok := payload.(map[string]string); ok {
if dir := m["cwd"]; dir != "" {
cmd.Dir = dir
}
if cwd != "" {
cmd.Dir = cwd
}
var stdout, stderr bytes.Buffer
@ -85,7 +103,6 @@ func (h Hooks) Run(ctx context.Context, name string, payload any) HookResult {
return HookResult{}
}
}
switch exitCode {
case 0:
out := strings.TrimSpace(stdout.String())
@ -99,13 +116,11 @@ func (h Hooks) Run(ctx context.Context, name string, payload any) HookResult {
clog.Debug("hook %s: exit=%d (non-blocking error) stderr=%q", name, exitCode, stderr.String())
return HookResult{Ran: true}
}
case <-ctx.Done():
cmd.Process.Kill() //nolint:errcheck
<-done
clog.Debug("hook %s: cancelled (context done)", name)
return HookResult{}
case <-timeout:
cmd.Process.Kill() //nolint:errcheck
<-done

View File

@ -5,9 +5,27 @@ import (
"os"
)
// HookCmds holds one or more shell commands for a hook. It unmarshals from
// either a JSON string ("cmd") or array (["cmd1","cmd2"]).
type HookCmds []string
func (h *HookCmds) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
*h = HookCmds{s}
return nil
}
var ss []string
if err := json.Unmarshal(data, &ss); err != nil {
return err
}
*h = HookCmds(ss)
return nil
}
type Config struct {
MCPServers map[string]ServerConfig `json:"mcpServers,omitempty"`
Hooks map[string]string `json:"hooks,omitempty"`
Hooks map[string]HookCmds `json:"hooks,omitempty"`
Prompt string `json:"prompt,omitempty"`
TrustedTools []string `json:"trustedTools,omitempty"`
MaxTokens int `json:"maxTokens,omitempty"`

View File

@ -24,7 +24,7 @@ func TestLoad(t *testing.T) {
t.Fatalf("Load failed: %v", err)
}
if cfg.Hooks["stop"] != "notify-send done" {
if len(cfg.Hooks["stop"]) != 1 || cfg.Hooks["stop"][0] != "notify-send done" {
t.Errorf("Expected hook 'notify-send done', got %q", cfg.Hooks["stop"])
}
}