package hooks
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"time"
)
// DefaultTimeout bounds one hook command. A hook runs on the agent's own
// goroutine, between a turn and the next thing it was going to do, so a
// command that hangs is a session that hangs. Ten seconds is long enough for
// anything a notification needs to do and short enough that a wedged one is an
// annoyance rather than a lost afternoon.
const DefaultTimeout = 10 * time.Second
// Runner runs shell commands the user configured for an event. It is the
// escape hatch: the built-in handlers cover what Codey knows about, and this
// covers everything else, without anybody needing to write Go.
//
// Each command gets the event as environment variables and as JSON on stdin —
// variables because a one-line hook should be a one-liner, JSON because
// anything more than that wants a parser rather than string surgery.
type Runner struct {
// Commands maps an event to the command lines fired for it, in order.
// They run through the system shell, so a hook can be a pipeline.
Commands map[Event][]string
// Dir is the working directory commands run in. Empty means the process's.
Dir string
// Timeout bounds one command. Zero means DefaultTimeout.
Timeout time.Duration
// Env is added to the environment commands inherit, as "K=V" strings.
Env []string
// run executes one command line. It is a field so tests do not need a
// shell, and nil means the real one.
run func(ctx context.Context, line string, env []string, stdin []byte, dir string) error
}
// NewRunner returns a runner for a map of event name to command lines, as it
// is spelled in the config file. Unknown event names are returned as an error
// rather than dropped: a hook on a misspelt event is a hook that silently
// never fires, which is the worst way for this to fail.
func NewRunner(spec map[string][]string, dir string) (*Runner, error) {
if len(spec) == 0 {
return nil, nil
}
commands := make(map[Event][]string, len(spec))
for name, lines := range spec {
ev := Event(name)
if !Known(ev) {
return nil, fmt.Errorf("unknown hook event %q; known events are %s", name, KnownList())
}
var kept []string
for _, line := range lines {
if line != "" {
kept = append(kept, line)
}
}
if len(kept) > 0 {
commands[ev] = kept
}
}
if len(commands) == 0 {
return nil, nil
}
return &Runner{Commands: commands, Dir: dir}, nil
}
// Known reports whether an event name is one Codey fires.
func Known(e Event) bool {
for _, known := range All {
if e == known {
return true
}
}
return false
}
// All is every event, in roughly the order a session produces them. It exists
// for config validation and for documentation that cannot drift from the code.
var All = []Event{
SessionStart, SessionEnd,
RunStart, RunEnd,
TurnStart,
ToolStart, ToolResult,
Blocked, Unblocked,
Paused, Resumed,
}
// KnownList is All as a comma-separated string, for error messages.
func KnownList() string {
var b bytes.Buffer
for i, e := range All {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(string(e))
}
return b.String()
}
// Handle implements Handler.
func (r *Runner) Handle(ctx context.Context, p Payload) error {
if r == nil {
return nil
}
lines := r.Commands[p.Event]
if len(lines) == 0 {
return nil
}
stdin, err := json.Marshal(payloadJSON(p))
if err != nil {
return fmt.Errorf("encoding hook payload: %w", err)
}
env := append(os.Environ(), r.Env...)
env = append(env, payloadEnv(p)...)
timeout := r.Timeout
if timeout <= 0 {
timeout = DefaultTimeout
}
run := r.run
if run == nil {
run = runShell
}
// The event that says a run ended arrives with the run's context already
// cancelled when the user interrupted it, and a hook that never fires on
// an interrupt is a hook that cannot clean up after one. The deadline
// below is the real bound here; cancellation of the turn is not.
base := context.WithoutCancel(ctx)
var first error
for _, line := range lines {
cmdCtx, cancel := context.WithTimeout(base, timeout)
err := run(cmdCtx, line, env, stdin, r.Dir)
cancel()
if err != nil && first == nil {
first = fmt.Errorf("hook %s: %q: %w", p.Event, line, err)
}
}
return first
}
// runShell runs one command line through the system shell, with the event on
// stdin. Output is captured and reported only on failure: a hook is not
// allowed to write over the TUI.
func runShell(ctx context.Context, line string, env []string, stdin []byte, dir string) error {
shell, flag := "/bin/sh", "-c"
if runtime.GOOS == "windows" {
shell, flag = "cmd", "/c"
}
cmd := exec.CommandContext(ctx, shell, flag, line)
cmd.Env = env
cmd.Dir = dir
cmd.Stdin = bytes.NewReader(stdin)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
if trimmed := bytes.TrimSpace(out.Bytes()); len(trimmed) > 0 {
return fmt.Errorf("%w: %s", err, truncate(string(trimmed), 500))
}
return err
}
return nil
}
// payloadEnv is the event as environment variables. Empty fields are still
// exported, so a hook script can read one without testing whether it is set.
func payloadEnv(p Payload) []string {
return []string{
"CODEY_HOOK_EVENT=" + string(p.Event),
"CODEY_HOOK_TIME=" + p.Time.UTC().Format(time.RFC3339Nano),
"CODEY_SESSION=" + p.Session,
"CODEY_AGENT=" + p.Agent,
"CODEY_TURN=" + strconv.Itoa(p.Turn),
"CODEY_TOOL=" + p.Tool,
"CODEY_REASON=" + p.Reason,
"CODEY_MESSAGE=" + p.Message,
"CODEY_ERROR=" + p.Err,
}
}
// payloadJSON is the wire shape of an event, with snake_case names matching
// the environment variables and the event names themselves.
type jsonPayload struct {
Event string `json:"event"`
Time string `json:"time"`
Session string `json:"session,omitempty"`
Agent string `json:"agent,omitempty"`
Turn int `json:"turn,omitempty"`
Tool string `json:"tool,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
Err string `json:"error,omitempty"`
}
func payloadJSON(p Payload) jsonPayload {
return jsonPayload{
Event: string(p.Event),
Time: p.Time.UTC().Format(time.RFC3339Nano),
Session: p.Session,
Agent: p.Agent,
Turn: p.Turn,
Tool: p.Tool,
Reason: p.Reason,
Message: p.Message,
Err: p.Err,
}
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "…"
}