diff --git a/pkg/agent/core.go b/pkg/agent/core.go index 72632f5..c7ba8e2 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -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{ diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go index da02fbe..2eebd0a 100644 --- a/pkg/agent/hooks.go +++ b/pkg/agent/hooks.go @@ -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 diff --git a/pkg/config/config.go b/pkg/config/config.go index 42d0763..f162099 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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"` diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 010dffc..24884c7 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -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"]) } }