From 0da88253a60f9b02e92e3e7b91020be45691e91f Mon Sep 17 00:00:00 2001 From: yyovil Date: Sat, 6 Jun 2026 05:23:27 +0530 Subject: [PATCH] feat(agents): add agy adapter Registers the agy harness, stacked on the agent platform. Includes its own activity deriver. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agent/activitydispatch/dispatch.go | 2 + .../internal/adapters/agent/agy/activity.go | 27 ++ .../adapters/agent/agy/activity_test.go | 32 ++ backend/internal/adapters/agent/agy/agy.go | 244 ++++++++++++++ .../internal/adapters/agent/agy/agy_test.go | 202 ++++++++++++ backend/internal/adapters/agent/agy/hooks.go | 305 ++++++++++++++++++ .../adapters/agent/registry/registry.go | 2 + backend/internal/daemon/wiring_test.go | 1 + 8 files changed, 815 insertions(+) create mode 100644 backend/internal/adapters/agent/agy/activity.go create mode 100644 backend/internal/adapters/agent/agy/activity_test.go create mode 100644 backend/internal/adapters/agent/agy/agy.go create mode 100644 backend/internal/adapters/agent/agy/agy_test.go create mode 100644 backend/internal/adapters/agent/agy/hooks.go diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index ccb94ed..d124bbf 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -9,6 +9,7 @@ package activitydispatch import ( + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agy" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" @@ -35,6 +36,7 @@ var Derivers = map[string]DeriveFunc{ "opencode": opencode.DeriveActivityState, "codex": codex.DeriveActivityState, "droid": droid.DeriveActivityState, + "agy": agy.DeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when diff --git a/backend/internal/adapters/agent/agy/activity.go b/backend/internal/adapters/agent/agy/activity.go new file mode 100644 index 0000000..d4ebca4 --- /dev/null +++ b/backend/internal/adapters/agent/agy/activity.go @@ -0,0 +1,27 @@ +package agy + +import ( + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// DeriveActivityState maps an Agy hook event onto an AO activity state. The +// bool is false when the event carries no activity signal. +// +// event is the AO hook sub-command name installed in agyManagedHooks: +// "session-start", "session-end", "before-agent", "after-agent", "after-tool". +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "before-agent": + return domain.ActivityActive, true + case "after-agent": + return domain.ActivityIdle, true + case "after-tool": + return domain.ActivityActive, true + case "session-end": + return domain.ActivityExited, true + case "session-start": + return "", false + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/agy/activity_test.go b/backend/internal/adapters/agent/agy/activity_test.go new file mode 100644 index 0000000..f77cda4 --- /dev/null +++ b/backend/internal/adapters/agent/agy/activity_test.go @@ -0,0 +1,32 @@ +package agy + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + name string + event string + want domain.ActivityState + wantOK bool + }{ + {"before agent -> active", "before-agent", domain.ActivityActive, true}, + {"after agent -> idle", "after-agent", domain.ActivityIdle, true}, + {"after tool -> active", "after-tool", domain.ActivityActive, true}, + {"session end -> exited", "session-end", domain.ActivityExited, true}, + {"session start -> no signal", "session-start", "", false}, + {"unknown event -> no signal", "unknown", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := DeriveActivityState(tt.event, []byte(`{}`)) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", + tt.event, got, ok, tt.want, tt.wantOK) + } + }) + } +} diff --git a/backend/internal/adapters/agent/agy/agy.go b/backend/internal/adapters/agent/agy/agy.go new file mode 100644 index 0000000..5758367 --- /dev/null +++ b/backend/internal/adapters/agent/agy/agy.go @@ -0,0 +1,244 @@ +// Package agy implements the Agy (Antigravity) agent adapter: launching new sessions, +// resuming sessions by native ID, installing workspace-local hooks, and reading +// hook-derived session info. +package agy + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + adapterID = "agy" + + // Normalized session-metadata keys. Shared vocabulary with the Codex and Claude Code + // adapters so the dashboard treats every agent uniformly. + agyTitleMetadataKey = "title" + agySummaryMetadataKey = "summary" +) + +// Plugin is the Agy agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.RWMutex + resolvedBinary string +} + +// New returns a ready-to-register Agy adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Agy", + Description: "Run Agy (Antigravity) worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Agy exposes none yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start an interactive Agy session. +// Shape: +// +// agy --add-dir [--dangerously-skip-permissions] [--prompt-interactive ] +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.agyBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + + if cfg.WorkspacePath != "" { + cmd = append(cmd, "--add-dir", cfg.WorkspacePath) + } + + if cfg.Permissions == ports.PermissionModeBypassPermissions { + cmd = append(cmd, "--dangerously-skip-permissions") + } + + if cfg.Prompt != "" { + cmd = append(cmd, "--prompt-interactive", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Agy receives its prompt in the +// launch command itself via --prompt-interactive. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Agy session: +// `agy --add-dir [--dangerously-skip-permissions] --conversation `. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.agyBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = []string{binary} + + if cfg.Session.WorkspacePath != "" { + cmd = append(cmd, "--add-dir", cfg.Session.WorkspacePath) + } + + if cfg.Permissions == ports.PermissionModeBypassPermissions { + cmd = append(cmd, "--dangerously-skip-permissions") + } + + cmd = append(cmd, "--conversation", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Agy hook-derived metadata. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[agyTitleMetadataKey], + Summary: session.Metadata[agySummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveAgyBinary returns the path to the agy binary on this machine, +// searching PATH then a handful of well-known install locations. +// Returns "agy" as a last-ditch fallback. +func ResolveAgyBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"agy.cmd", "agy.exe", "agy"} { + path, err := exec.LookPath(name) + if err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "agy.cmd"), + filepath.Join(appData, "npm", "agy.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "agy.exe")) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "agy", nil + } + + if path, err := exec.LookPath("agy"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/agy", + "/opt/homebrew/bin/agy", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "agy"), + filepath.Join(home, ".cargo", "bin", "agy"), + filepath.Join(home, ".npm", "bin", "agy"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "agy", nil +} + +func (p *Plugin) agyBinary(ctx context.Context) (string, error) { + // Fast path: a concurrent-safe read of the already-resolved binary. + p.binaryMu.RLock() + cached := p.resolvedBinary + p.binaryMu.RUnlock() + if cached != "" { + return cached, nil + } + + // Populate path: take the write lock and re-check, since another goroutine + // may have resolved the binary between releasing RLock and acquiring Lock. + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveAgyBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/agy/agy_test.go b/backend/internal/adapters/agent/agy/agy_test.go new file mode 100644 index 0000000..b512050 --- /dev/null +++ b/backend/internal/adapters/agent/agy/agy_test.go @@ -0,0 +1,202 @@ +package agy + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + plugin := New() + manifest := plugin.Manifest() + if manifest.ID != "agy" { + t.Fatalf("manifest id = %q, want agy", manifest.ID) + } +} + +func TestGetLaunchCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "fix this", + WorkspacePath: "/tmp/ws", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "agy", + "--add-dir", "/tmp/ws", + "--dangerously-skip-permissions", + "--prompt-interactive", "fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want in_command", got) + } +} + +func TestGetRestoreCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "native-id-123"}, + WorkspacePath: "/tmp/ws", + }, + }) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected ok=true") + } + + want := []string{ + "agy", + "--add-dir", "/tmp/ws", + "--dangerously-skip-permissions", + "--conversation", "native-id-123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandNoSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + _, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{}, + }, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected ok=false when agentSessionId is missing") + } +} + +func TestSessionInfo(t *testing.T) { + plugin := &Plugin{} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "native-id-123", + "title": "My Title", + "summary": "My Summary", + }, + }) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected ok=true") + } + if info.AgentSessionID != "native-id-123" || info.Title != "My Title" || info.Summary != "My Summary" { + t.Fatalf("unexpected SessionInfo: %#v", info) + } +} + +func TestHooksLifecycle(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agy-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + plugin := &Plugin{} + cfg := ports.WorkspaceHookConfig{ + WorkspacePath: tmpDir, + } + + // 1. Initially hooks should not be installed. + installed, err := plugin.AreHooksInstalled(context.Background(), tmpDir) + if err != nil { + t.Fatal(err) + } + if installed { + t.Fatal("expected hooks to not be installed initially") + } + + // 2. Install hooks. + err = plugin.GetAgentHooks(context.Background(), cfg) + if err != nil { + t.Fatal(err) + } + + installed, err = plugin.AreHooksInstalled(context.Background(), tmpDir) + if err != nil { + t.Fatal(err) + } + if !installed { + t.Fatal("expected hooks to be installed after GetAgentHooks") + } + + // Verify hooks.json structure + hooksJSONPath := filepath.Join(tmpDir, ".gemini", "hooks.json") + data, err := os.ReadFile(hooksJSONPath) + if err != nil { + t.Fatal(err) + } + + var hookFile agyHookFile + if err := json.Unmarshal(data, &hookFile); err != nil { + t.Fatal(err) + } + + if len(hookFile.Hooks) != len(agyManagedHooks) { + t.Fatalf("expected %d events in hooks, got %d", len(agyManagedHooks), len(hookFile.Hooks)) + } + + for _, spec := range agyManagedHooks { + groups, ok := hookFile.Hooks[spec.Event] + if !ok { + t.Fatalf("expected event %q in hooks.json", spec.Event) + } + found := false + for _, group := range groups { + for _, h := range group.Hooks { + if h.Command == spec.Command { + found = true + break + } + } + } + if !found { + t.Fatalf("expected command %q for event %q", spec.Command, spec.Event) + } + } + + // 3. Uninstall hooks. + err = plugin.UninstallHooks(context.Background(), tmpDir) + if err != nil { + t.Fatal(err) + } + + installed, err = plugin.AreHooksInstalled(context.Background(), tmpDir) + if err != nil { + t.Fatal(err) + } + if installed { + t.Fatal("expected hooks to be uninstalled after UninstallHooks") + } +} diff --git a/backend/internal/adapters/agent/agy/hooks.go b/backend/internal/adapters/agent/agy/hooks.go new file mode 100644 index 0000000..0168929 --- /dev/null +++ b/backend/internal/adapters/agent/agy/hooks.go @@ -0,0 +1,305 @@ +package agy + +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 ( + agyHooksDirName = ".gemini" + agyHooksFileName = "hooks.json" + + agyHookCommandPrefix = "ao hooks agy " +) + +type agyHookFile struct { + Hooks map[string][]agyMatcherGroup `json:"hooks"` +} + +type agyMatcherGroup struct { + Matcher *string `json:"matcher,omitempty"` + Hooks []agyHookEntry `json:"hooks"` +} + +type agyHookEntry struct { + Type string `json:"type"` + Command string `json:"command"` +} + +type agyHookSpec struct { + Event string + Command string +} + +var agyManagedHooks = []agyHookSpec{ + {Event: "SessionStart", Command: agyHookCommandPrefix + "session-start"}, + {Event: "SessionEnd", Command: agyHookCommandPrefix + "session-end"}, + {Event: "BeforeAgent", Command: agyHookCommandPrefix + "before-agent"}, + {Event: "AfterAgent", Command: agyHookCommandPrefix + "after-agent"}, + {Event: "AfterTool", Command: agyHookCommandPrefix + "after-tool"}, +} + +// GetAgentHooks installs AO's Agy hooks into the worktree-local +// .gemini/hooks.json file. Existing hook entries are preserved and duplicate +// AO commands are not appended. +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("agy.GetAgentHooks: WorkspacePath is required") + } + + hooksPath := agyHooksPath(cfg.WorkspacePath) + topLevel, rawHooks, err := readAgyHooks(hooksPath) + if err != nil { + return fmt.Errorf("agy.GetAgentHooks: %w", err) + } + + for event, specs := range groupAgyHooksByEvent() { + var existingGroups []agyMatcherGroup + if err := parseAgyHookType(rawHooks, event, &existingGroups); err != nil { + return fmt.Errorf("agy.GetAgentHooks: %w", err) + } + for _, spec := range specs { + if !agyHookCommandExists(existingGroups, spec.Command) { + entry := agyHookEntry{Type: "command", Command: spec.Command} + existingGroups = addAgyHook(existingGroups, entry) + } + } + if err := marshalAgyHookType(rawHooks, event, existingGroups); err != nil { + return fmt.Errorf("agy.GetAgentHooks: %w", err) + } + } + + if err := writeAgyHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("agy.GetAgentHooks: %w", err) + } + + return nil +} + +// UninstallHooks removes AO's Agy hooks from the workspace-local +// .gemini/hooks.json file, leaving user-defined hooks 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("agy.UninstallHooks: workspacePath is required") + } + + hooksPath := agyHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readAgyHooks(hooksPath) + if err != nil { + return fmt.Errorf("agy.UninstallHooks: %w", err) + } + + for _, event := range agyManagedEvents() { + var groups []agyMatcherGroup + if err := parseAgyHookType(rawHooks, event, &groups); err != nil { + return fmt.Errorf("agy.UninstallHooks: %w", err) + } + groups = removeAgyManagedHooks(groups) + if err := marshalAgyHookType(rawHooks, event, groups); err != nil { + return fmt.Errorf("agy.UninstallHooks: %w", err) + } + } + + if err := writeAgyHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("agy.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Agy 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("agy.AreHooksInstalled: workspacePath is required") + } + + hooksPath := agyHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readAgyHooks(hooksPath) + if err != nil { + return false, fmt.Errorf("agy.AreHooksInstalled: %w", err) + } + + for _, event := range agyManagedEvents() { + var groups []agyMatcherGroup + if err := parseAgyHookType(rawHooks, event, &groups); err != nil { + return false, fmt.Errorf("agy.AreHooksInstalled: %w", err) + } + for _, group := range groups { + for _, hook := range group.Hooks { + if isAgyManagedHook(hook.Command) { + return true, nil + } + } + } + } + return false, nil +} + +func agyHooksPath(workspacePath string) string { + return filepath.Join(workspacePath, agyHooksDirName, agyHooksFileName) +} + +// readAgyHooks loads the hooks file into a top-level raw map plus the decoded +// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty +// file yields empty maps. +func readAgyHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeAgyHooks folds rawHooks back into topLevel and writes the file. An +// empty hooks map drops the "hooks" key entirely. +func writeAgyHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("create hook dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + 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 +} + +func groupAgyHooksByEvent() map[string][]agyHookSpec { + byEvent := map[string][]agyHookSpec{} + for _, spec := range agyManagedHooks { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +func agyManagedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(agyManagedHooks)) + for _, spec := range agyManagedHooks { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +func isAgyManagedHook(command string) bool { + return strings.HasPrefix(command, agyHookCommandPrefix) +} + +func removeAgyManagedHooks(groups []agyMatcherGroup) []agyMatcherGroup { + result := make([]agyMatcherGroup, 0, len(groups)) + for _, group := range groups { + kept := make([]agyHookEntry, 0, len(group.Hooks)) + for _, hook := range group.Hooks { + if !isAgyManagedHook(hook.Command) { + kept = append(kept, hook) + } + } + if len(kept) > 0 { + group.Hooks = kept + result = append(result, group) + } + } + return result +} + +func parseAgyHookType(rawHooks map[string]json.RawMessage, event string, target *[]agyMatcherGroup) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalAgyHookType(rawHooks map[string]json.RawMessage, event string, groups []agyMatcherGroup) error { + if len(groups) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func agyHookCommandExists(groups []agyMatcherGroup, command string) bool { + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +func addAgyHook(groups []agyMatcherGroup, hook agyHookEntry) []agyMatcherGroup { + for i, group := range groups { + if group.Matcher == nil { + groups[i].Hooks = append(groups[i].Hooks, hook) + return groups + } + } + return append(groups, agyMatcherGroup{Matcher: nil, Hooks: []agyHookEntry{hook}}) +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index d20a4bc..ca63a1a 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agy" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/amp" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" @@ -29,6 +30,7 @@ func Constructors() []adapters.Adapter { grok.New(), droid.New(), amp.New(), + agy.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 08945c2..f2acfe9 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -96,6 +96,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessGrok, "grok"}, {domain.HarnessDroid, "droid"}, {domain.HarnessAmp, "amp"}, + {domain.HarnessAgy, "agy"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness)