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 ` 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 := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { return fmt.Errorf("write %s: %w", hooksPath, err) } return nil } // 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 }