agent-orchestrator/backend/internal/adapters/agent/copilot/hooks.go

302 lines
10 KiB
Go

package copilot
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const (
// copilotHooksDir is the repository-scope hooks directory Copilot CLI reads
// (.github/hooks/*.json). AO writes a single dedicated file there so it never
// disturbs other hook files the user or repo may ship.
copilotHooksDir = ".github/hooks"
copilotHooksFileName = "ao.json"
// copilotHooksVersion is the schema version of the hooks file (Copilot uses 1).
copilotHooksVersion = 1
// copilotHookCommandPrefix identifies the hook commands AO owns, so install
// skips duplicates and uninstall recognizes AO entries by prefix without an
// embedded template to diff against. The CLI dispatcher routes
// `ao hooks copilot <event>` to DeriveActivityState.
copilotHookCommandPrefix = "ao hooks copilot "
copilotHookTimeoutSec = 30
)
// copilotHookFile is the on-disk shape of .github/hooks/ao.json. AO owns this
// dedicated file outright, so it only models the keys it manages (version,
// disableAllHooks, hooks); user-defined hooks live in their own .github/hooks/*
// files and are never touched.
type copilotHookFile struct {
Version int `json:"version"`
DisableAllHooks *bool `json:"disableAllHooks,omitempty"`
Hooks map[string][]copilotHookEntry `json:"hooks"`
}
// copilotHookEntry is one hook command. Copilot entries carry separate bash and
// powershell command strings (both required for cross-platform), a type, an
// optional working dir, and a timeout in seconds.
type copilotHookEntry struct {
Type string `json:"type"`
Bash string `json:"bash,omitempty"`
Powershell string `json:"powershell,omitempty"`
Cwd string `json:"cwd,omitempty"`
TimeoutSec int `json:"timeoutSec,omitempty"`
}
// copilotHookSpec describes one hook AO installs, defined in code rather than
// read from an embedded settings file.
type copilotHookSpec struct {
// Event is the native Copilot camelCase event name (sessionStart, ...).
Event string
// Command is the AO sub-command suffix (session-start, ...). It is appended
// to copilotHookCommandPrefix to form both the bash and powershell command,
// and is the value DeriveActivityState switches on.
Command string
}
// copilotManagedHooks is the source of truth for the hooks AO installs. The AO
// sub-command names (session-start, user-prompt-submit, permission-request,
// stop) are exactly what DeriveActivityState in activity.go switches on.
//
// Native event names use Copilot's camelCase form, taken verbatim from
// https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks
// (sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse,
// errorOccurred, agentStop). Copilot does not document a "permissionRequest"
// event — the closest signal that AO's permission-request sub-command can
// piggyback on is preToolUse, which fires before any tool invocation, including
// the ones that would otherwise prompt the user for approval. This is a
// many-to-one collapse: every preToolUse currently produces ActivityWaitingInput
// via the permission-request sub-command. agentStop is the per-turn completion
// signal and maps to the "stop" sub-command (turn end → idle).
var copilotManagedHooks = []copilotHookSpec{
{Event: "sessionStart", Command: "session-start"},
{Event: "userPromptSubmitted", Command: "user-prompt-submit"},
{Event: "preToolUse", Command: "permission-request"},
{Event: "agentStop", Command: "stop"},
}
// GetAgentHooks installs AO's Copilot hooks into the worktree-local
// .github/hooks/ao.json file (the repository-scope hooks config Copilot CLI
// reads). The hooks report normalized activity-state signals back into AO's
// store. Existing AO entries are not duplicated and any unrelated keys are
// preserved, so the install is idempotent.
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
if err := ctx.Err(); err != nil {
return err
}
if strings.TrimSpace(cfg.WorkspacePath) == "" {
return errors.New("copilot.GetAgentHooks: WorkspacePath is required")
}
hooksPath := copilotHooksPath(cfg.WorkspacePath)
file, err := readCopilotHooks(hooksPath)
if err != nil {
return fmt.Errorf("copilot.GetAgentHooks: %w", err)
}
if file.Hooks == nil {
file.Hooks = map[string][]copilotHookEntry{}
}
for _, spec := range copilotManagedHooks {
command := copilotHookCommandPrefix + spec.Command
if copilotHookCommandExists(file.Hooks[spec.Event], command) {
continue
}
file.Hooks[spec.Event] = append(file.Hooks[spec.Event], copilotHookEntry{
Type: "command",
Bash: command,
Powershell: command,
TimeoutSec: copilotHookTimeoutSec,
})
}
if err := writeCopilotHooks(hooksPath, file); err != nil {
return fmt.Errorf("copilot.GetAgentHooks: %w", err)
}
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), copilotHooksFileName); err != nil {
return fmt.Errorf("copilot.GetAgentHooks: gitignore: %w", err)
}
return nil
}
// UninstallHooks removes AO's Copilot hooks from the workspace-local
// .github/hooks/ao.json file, leaving user-defined hooks and unrelated keys
// untouched. A missing file is a no-op.
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
if err := ctx.Err(); err != nil {
return err
}
if strings.TrimSpace(workspacePath) == "" {
return errors.New("copilot.UninstallHooks: workspacePath is required")
}
hooksPath := copilotHooksPath(workspacePath)
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
return nil
}
file, err := readCopilotHooks(hooksPath)
if err != nil {
return fmt.Errorf("copilot.UninstallHooks: %w", err)
}
for event, entries := range file.Hooks {
kept := removeCopilotManagedHooks(entries)
if len(kept) == 0 {
delete(file.Hooks, event)
continue
}
file.Hooks[event] = kept
}
if err := writeCopilotHooks(hooksPath, file); err != nil {
return fmt.Errorf("copilot.UninstallHooks: %w", err)
}
return nil
}
// AreHooksInstalled reports whether any AO Copilot hook is present in the
// workspace-local hooks file. A missing file means none are installed.
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
if strings.TrimSpace(workspacePath) == "" {
return false, errors.New("copilot.AreHooksInstalled: workspacePath is required")
}
hooksPath := copilotHooksPath(workspacePath)
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
return false, nil
}
file, err := readCopilotHooks(hooksPath)
if err != nil {
return false, fmt.Errorf("copilot.AreHooksInstalled: %w", err)
}
for _, entries := range file.Hooks {
for _, entry := range entries {
if isCopilotManagedHook(entry) {
return true, nil
}
}
}
return false, nil
}
func copilotHooksPath(workspacePath string) string {
return filepath.Join(workspacePath, filepath.FromSlash(copilotHooksDir), copilotHooksFileName)
}
// readCopilotHooks loads the hooks file. A missing or empty file yields an empty
// file struct with a nil hooks map (and the AO schema version, used on write).
func readCopilotHooks(hooksPath string) (copilotHookFile, error) {
file := copilotHookFile{Version: copilotHooksVersion}
data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir
if errors.Is(err, os.ErrNotExist) {
return file, nil
}
if err != nil {
return copilotHookFile{}, fmt.Errorf("read %s: %w", hooksPath, err)
}
if strings.TrimSpace(string(data)) == "" {
return file, nil
}
if err := json.Unmarshal(data, &file); err != nil {
return copilotHookFile{}, fmt.Errorf("parse %s: %w", hooksPath, err)
}
if file.Version == 0 {
file.Version = copilotHooksVersion
}
return file, nil
}
// writeCopilotHooks writes the file. An empty hooks map still writes a valid
// (versioned) file so AreHooksInstalled and re-install see a consistent shape.
func writeCopilotHooks(hooksPath string, file copilotHookFile) error {
if file.Version == 0 {
file.Version = copilotHooksVersion
}
if file.Hooks == nil {
file.Hooks = map[string][]copilotHookEntry{}
}
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil {
return fmt.Errorf("create hooks dir: %w", err)
}
data, err := json.MarshalIndent(file, "", " ")
if err != nil {
return fmt.Errorf("encode %s: %w", hooksPath, err)
}
data = append(data, '\n')
if err := atomicWriteFile(hooksPath, data, 0o600); err != nil {
return fmt.Errorf("write %s: %w", hooksPath, err)
}
return nil
}
// atomicWriteFile writes data to path via a temp file in the same directory
// followed by a rename, so a crash or signal mid-write can't leave a truncated
// or empty file that Copilot then fails to parse (silently disabling hooks).
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
// isCopilotManagedHook reports whether an entry is one AO owns, recognized by the
// command prefix on either the bash or powershell command.
func isCopilotManagedHook(entry copilotHookEntry) bool {
return strings.HasPrefix(entry.Bash, copilotHookCommandPrefix) ||
strings.HasPrefix(entry.Powershell, copilotHookCommandPrefix)
}
func copilotHookCommandExists(entries []copilotHookEntry, command string) bool {
for _, entry := range entries {
if entry.Bash == command || entry.Powershell == command {
return true
}
}
return false
}
// removeCopilotManagedHooks strips AO hook entries from a slice, preserving
// user-defined entries in order.
func removeCopilotManagedHooks(entries []copilotHookEntry) []copilotHookEntry {
kept := make([]copilotHookEntry, 0, len(entries))
for _, entry := range entries {
if !isCopilotManagedHook(entry) {
kept = append(kept, entry)
}
}
return kept
}