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:
parent
aeeb0a3e22
commit
be42fbd739
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue