Skip to content

[FEATURE] Implement Hooks #270

Description

@edenreich

Summary

Hooks let users attach behavior to pre-defined points in the agent loop. #669 established the shared domain.HookPoint catalog (8 symmetric pre/post points) and the first action kind — system reminders (inject text). This ticket adds the command action: run an arbitrary shell command/script at a hook point, primarily post_session ("when the agent finished generating"), for deterministic post-processing (format, lint, test, notify). Feature-flagged, off by default.

Shared hook-point catalog (from #669)

domain.HookPoint — the fixed catalog both reminders and command hooks attach to:

pre post
pre_session post_session
pre_stream post_stream
pre_tool post_tool
pre_queue_drain post_queue_drain

post_session is the "agent finished generating" point this ticket targets first. Dispatch flows through the single dispatchHooks(point) seam #669 added in the event-driven agent (internal/agent/agent_utils.go) and the headless loop (cmd/agent.go); this ticket registers a command runner alongside the reminder injector at that seam.

Design

  • Config lives in a dedicated hooks.yaml (mirroring [FEATURE] Flexible system reminders: multiple reminders across the agent loop #669's reminders.yaml — "run code" and "inject text" stay separate concerns), feature-flagged, off by default:

    # .infer/hooks.yaml
    enabled: false
    hooks:
      - name: gofmt
        hook: post_session
        command: "gofmt -w ."
        timeout: 30   # seconds; 0 -> default 30
  • A domain.HookCommandProvider interface (sibling of the domain.SystemReminderProvider [FEATURE] Flexible system reminders: multiple reminders across the agent loop #669 introduced) resolves which commands are due at a point (CommandsDue(hook) []HookCommand); the agent — not the provider — runs them, so config stays free of os/exec.

  • Allow-list gated, secure-by-default. Each command is gated on the existing per-mode bash allow-list (config.IsBashCommandAllowed — the same matcher a model-proposed bash command faces), so command hooks open no new bypass. An off-list command is skipped and reported (a hook_command_skipped stream event carrying the rejection hint), never run; the user authorizes a command by allow-listing it (tools.bash.mode.*.allow or INFER_TOOLS_BASH_ALLOW_APPEND). This keeps headless safe: an unattended infer agent runs only allow-listed hook commands.

  • A single shared runner (agent.RunCommandHooks) is the one chokepoint both the chat agent and the headless loop call, so the gate and observability cannot drift between them.

  • Command stdin receives a JSON context (hook point, turn, session id), Claude-Code style. v1 is fire-and-observe: stdout/stderr/exit-code are captured for the stream event and logged, but not fed back into the conversation or used to alter the loop (block completion / inject a follow-up is a later iteration).

  • Each run emits a kind: hook_command stream event (exit code, duration, truncated output) for observability, mirroring [FEATURE] Flexible system reminders: multiple reminders across the agent loop #669's system_reminder events.

Acceptance Criteria

  • Command hooks attach to the pre-defined domain.HookPoint catalog (validated against the fixed set).
  • There is a way to execute arbitrary code when the agent finished generating (post_session wired).
  • It is extensible (new action kinds / hook points slot into the shared dispatchHooks seam).
  • It is a feature flag (hooks.enabled), turned off by default.
  • Commands honor the existing bash allow-list / approval policy.
  • Each execution emits an observability stream event.
  • It is documented.
  • It is tested.

Builds atop #669 (shared hook-point catalog + dispatch seam).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No fields configured for Feature.

    Projects

    Status
    In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions