package autohand 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 ( autohandConfigDirName = ".autohand" autohandConfigFileName = "config.json" // autohandHookCommandPrefix identifies the hook commands AO owns, so // install skips duplicates and uninstall recognizes AO entries by prefix // without an embedded template to diff against. autohandHookCommandPrefix = "ao hooks autohand " autohandHookTimeout = 30 ) // autohandManagedHookKeys are the entry keys AO owns. On marshal they are // written from the typed fields below; any other key the user set is preserved // from Extra. Keep in sync with the json tags on autohandHookEntry. var autohandManagedHookKeys = []string{"event", "command", "description", "enabled", "timeout"} // autohandHookEntry is the on-disk shape of one entry in the config's // hooks.hooks array. AO owns the five typed fields; any other key the user set // on an entry (matcher, filter, async, ...) is captured in Extra so a rewrite // preserves fields AO does not own instead of silently dropping them. type autohandHookEntry struct { Event string `json:"event"` Command string `json:"command"` Description string `json:"description,omitempty"` Enabled bool `json:"enabled"` Timeout int `json:"timeout,omitempty"` // Extra holds keys AO does not manage, captured on unmarshal and written // back on marshal so they round-trip. encoding/json does not support // `json:",inline"`, so the round-trip is implemented via the custom // UnmarshalJSON/MarshalJSON below. Extra map[string]json.RawMessage `json:"-"` } // UnmarshalJSON decodes the entry's typed fields and captures every key AO does // not manage into Extra, so a later MarshalJSON can write them back verbatim. func (e *autohandHookEntry) UnmarshalJSON(data []byte) error { raw := map[string]json.RawMessage{} if err := json.Unmarshal(data, &raw); err != nil { return err } // Decode the managed fields via a type alias to avoid recursing into this // method, then drop the managed keys so Extra holds only unknown ones. type managedAlias autohandHookEntry var managed managedAlias if err := json.Unmarshal(data, &managed); err != nil { return err } *e = autohandHookEntry(managed) for _, key := range autohandManagedHookKeys { delete(raw, key) } if len(raw) > 0 { e.Extra = raw } else { e.Extra = nil } return nil } // MarshalJSON writes AO's managed fields merged with any preserved unknown keys // from Extra. Managed fields win on key collision so AO's values stay // authoritative. func (e autohandHookEntry) MarshalJSON() ([]byte, error) { out := make(map[string]json.RawMessage, len(e.Extra)+len(autohandManagedHookKeys)) for key, val := range e.Extra { out[key] = val } type managedAlias autohandHookEntry managedJSON, err := json.Marshal(managedAlias(e)) if err != nil { return nil, err } var managed map[string]json.RawMessage if err := json.Unmarshal(managedJSON, &managed); err != nil { return nil, err } for key, val := range managed { out[key] = val } return json.Marshal(out) } // autohandHookSpec describes one hook AO installs. Event is Autohand's native // lifecycle event name; Subcommand is the AO hook sub-command appended after the // command prefix (and the value DeriveActivityState switches on). type autohandHookSpec struct { Event string Subcommand string } // autohandManagedHooks is the source of truth for the hooks AO installs. Each // native Autohand event is routed to the AO sub-command DeriveActivityState // understands. Autohand's pre-prompt event is the user-prompt-submit signal. var autohandManagedHooks = []autohandHookSpec{ {Event: "session-start", Subcommand: "session-start"}, {Event: "pre-prompt", Subcommand: "user-prompt-submit"}, {Event: "permission-request", Subcommand: "permission-request"}, {Event: "stop", Subcommand: "stop"}, } // GetAgentHooks installs AO's Autohand hooks into the Autohand config's // hooks.hooks array. Existing user hooks are preserved and duplicate AO commands // are not appended. The rest of the config (auth, provider, ...) is preserved // byte-for-byte because only the hooks section is decoded and rewritten. // // Autohand loads hooks from a single config file (default ~/.autohand/config.json, // overridable via AUTOHAND_CONFIG); it does not merge a workspace-local file at // runtime, so AO installs into that config rather than a per-workspace file. The // AUTOHAND_CONFIG env var, when set, takes precedence so AO and the agent agree // on the target. func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { if err := ctx.Err(); err != nil { return err } configPath := autohandConfigPath() topLevel, hooksSection, entries, err := readAutohandHooks(configPath) if err != nil { return fmt.Errorf("autohand.GetAgentHooks: %w", err) } for _, spec := range autohandManagedHooks { command := autohandHookCommandPrefix + spec.Subcommand if autohandHookCommandExists(entries, command) { continue } entries = append(entries, autohandHookEntry{ Event: spec.Event, Command: command, Description: "AO activity hook", Enabled: true, Timeout: autohandHookTimeout, }) } // Autohand only fires hooks when the hooks section is enabled. hooksSection["enabled"] = json.RawMessage(`true`) if err := writeAutohandHooks(configPath, topLevel, hooksSection, entries); err != nil { return fmt.Errorf("autohand.GetAgentHooks: %w", err) } return nil } // UninstallHooks removes AO's Autohand hooks from the config's hooks.hooks // array, leaving user-defined hooks and the rest of the config untouched. A // missing file is a no-op. The hooks.enabled flag is left in place because it // enables every Autohand hook, not just AO's. func (p *Plugin) UninstallHooks(ctx context.Context, _ string) error { if err := ctx.Err(); err != nil { return err } configPath := autohandConfigPath() if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { return nil } topLevel, hooksSection, entries, err := readAutohandHooks(configPath) if err != nil { return fmt.Errorf("autohand.UninstallHooks: %w", err) } kept := make([]autohandHookEntry, 0, len(entries)) for _, entry := range entries { if !isAutohandManagedHook(entry.Command) { kept = append(kept, entry) } } if err := writeAutohandHooks(configPath, topLevel, hooksSection, kept); err != nil { return fmt.Errorf("autohand.UninstallHooks: %w", err) } return nil } // AreHooksInstalled reports whether any AO Autohand hook is present in the // config. A missing file means none are installed. func (p *Plugin) AreHooksInstalled(ctx context.Context, _ string) (bool, error) { if err := ctx.Err(); err != nil { return false, err } configPath := autohandConfigPath() if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { return false, nil } _, _, entries, err := readAutohandHooks(configPath) if err != nil { return false, fmt.Errorf("autohand.AreHooksInstalled: %w", err) } for _, entry := range entries { if isAutohandManagedHook(entry.Command) { return true, nil } } return false, nil } // autohandConfigPath returns the config file Autohand loads hooks from: the // AUTOHAND_CONFIG override if set, else ~/.autohand/config.json. func autohandConfigPath() string { if env := strings.TrimSpace(os.Getenv("AUTOHAND_CONFIG")); env != "" { return env } home, err := os.UserHomeDir() if err != nil { // Fall back to a relative path; callers surface the resulting error. return filepath.Join(autohandConfigDirName, autohandConfigFileName) } return filepath.Join(home, autohandConfigDirName, autohandConfigFileName) } // readAutohandHooks loads the config into a top-level raw map, the decoded // "hooks" section (preserving keys AO doesn't manage such as "enabled"), and the // decoded hooks array. A missing or empty file yields empty maps and a nil // slice. func readAutohandHooks(configPath string) (topLevel, hooksSection map[string]json.RawMessage, entries []autohandHookEntry, err error) { topLevel = map[string]json.RawMessage{} hooksSection = map[string]json.RawMessage{} data, err := os.ReadFile(configPath) //nolint:gosec // path is the user's own Autohand config if errors.Is(err, os.ErrNotExist) { return topLevel, hooksSection, nil, nil } if err != nil { return nil, nil, nil, fmt.Errorf("read %s: %w", configPath, err) } if strings.TrimSpace(string(data)) == "" { return topLevel, hooksSection, nil, nil } if err := json.Unmarshal(data, &topLevel); err != nil { return nil, nil, nil, fmt.Errorf("parse %s: %w", configPath, err) } if hooksRaw, ok := topLevel["hooks"]; ok { if err := json.Unmarshal(hooksRaw, &hooksSection); err != nil { return nil, nil, nil, fmt.Errorf("parse hooks in %s: %w", configPath, err) } } if arrRaw, ok := hooksSection["hooks"]; ok { if err := json.Unmarshal(arrRaw, &entries); err != nil { return nil, nil, nil, fmt.Errorf("parse hooks array in %s: %w", configPath, err) } } return topLevel, hooksSection, entries, nil } // writeAutohandHooks folds the entries back into the hooks section, the hooks // section back into topLevel, and writes the file atomically. An empty entries // slice drops the "hooks" array key. func writeAutohandHooks(configPath string, topLevel, hooksSection map[string]json.RawMessage, entries []autohandHookEntry) error { if len(entries) == 0 { delete(hooksSection, "hooks") } else { arrJSON, err := json.Marshal(entries) if err != nil { return fmt.Errorf("encode hooks array: %w", err) } hooksSection["hooks"] = arrJSON } if len(hooksSection) == 0 { delete(topLevel, "hooks") } else { hooksJSON, err := json.Marshal(hooksSection) if err != nil { return fmt.Errorf("encode hooks section: %w", err) } topLevel["hooks"] = hooksJSON } if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { return fmt.Errorf("create config dir: %w", err) } data, err := json.MarshalIndent(topLevel, "", " ") if err != nil { return fmt.Errorf("encode %s: %w", configPath, err) } data = append(data, '\n') if err := hookutil.AtomicWriteFile(configPath, data, 0o600); err != nil { return fmt.Errorf("write %s: %w", configPath, err) } return nil } func isAutohandManagedHook(command string) bool { return strings.HasPrefix(command, autohandHookCommandPrefix) } func autohandHookCommandExists(entries []autohandHookEntry, command string) bool { for _, entry := range entries { if entry.Command == command { return true } } return false }