From 2518bc24dd6632788302873de7c0d6c763ee391c Mon Sep 17 00:00:00 2001 From: yyovil Date: Sat, 6 Jun 2026 05:24:03 +0530 Subject: [PATCH 1/4] feat(agents): add copilot adapter Registers the copilot 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 + .../adapters/agent/copilot/activity.go | 36 ++ .../adapters/agent/copilot/copilot.go | 274 +++++++++++ .../adapters/agent/copilot/copilot_test.go | 436 ++++++++++++++++++ .../internal/adapters/agent/copilot/hooks.go | 285 ++++++++++++ .../adapters/agent/registry/registry.go | 2 + backend/internal/daemon/wiring_test.go | 1 + 7 files changed, 1036 insertions(+) create mode 100644 backend/internal/adapters/agent/copilot/activity.go create mode 100644 backend/internal/adapters/agent/copilot/copilot.go create mode 100644 backend/internal/adapters/agent/copilot/copilot_test.go create mode 100644 backend/internal/adapters/agent/copilot/hooks.go diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index 4486e8b..89c7181 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -12,6 +12,7 @@ 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/copilot" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" @@ -41,6 +42,7 @@ var Derivers = map[string]DeriveFunc{ "agy": agy.DeriveActivityState, "cursor": cursor.DeriveActivityState, "qwen": qwen.DeriveActivityState, + "copilot": copilot.DeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when diff --git a/backend/internal/adapters/agent/copilot/activity.go b/backend/internal/adapters/agent/copilot/activity.go new file mode 100644 index 0000000..3b311ec --- /dev/null +++ b/backend/internal/adapters/agent/copilot/activity.go @@ -0,0 +1,36 @@ +package copilot + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Copilot CLI 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 copilotManagedHooks +// ("session-start", "user-prompt-submit", "permission-request", "stop"), NOT the +// native Copilot event name. Keeping this beside hooks.go means the events AO +// installs and what they mean live in one place. +// +// Copilot CLI documents that prompt-style hooks (userPromptSubmitted) do NOT +// fire in non-interactive `-p` mode, while permissionRequest is "especially +// useful in CLI pipe mode (-p)". AO still installs every event so interactive +// resume and future modes report activity; the permission-request → waiting_input +// mapping is the one that always fires under AO's headless launch. +// +// TODO(copilot): ActivityExited is still runtime-observation-owned. If Copilot's +// sessionEnd/agentStop hook proves reliable in `-p` mode, map a real +// session-end here. Until then, the lifecycle reaper marks a dead Copilot +// runtime exited even when the last hook signal was sticky waiting_input. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/copilot/copilot.go b/backend/internal/adapters/agent/copilot/copilot.go new file mode 100644 index 0000000..12d5349 --- /dev/null +++ b/backend/internal/adapters/agent/copilot/copilot.go @@ -0,0 +1,274 @@ +// Package copilot implements the GitHub Copilot CLI agent adapter: launching new +// headless sessions, resuming hook-tracked sessions, installing workspace-local +// hooks, and reading hook-derived session info. +// +// This adapter targets the standalone agentic GitHub Copilot CLI (binary +// "copilot", installed via npm "@github/copilot"), NOT the older `gh copilot` +// suggest/explain extension. +// +// Launch runs the CLI in non-interactive ("programmatic") mode with `-p +// ` so it executes the task and exits. Permission modes map onto the +// CLI's allow flags (`--allow-tool`, `--allow-all-tools`, `--allow-all`). +// Restore continues an existing session via `--resume `; the +// native session id (a UUID under ~/.copilot/session-state/) is captured by the +// SessionStart hook AO installs (see hooks.go). +// +// AO-managed sessions derive native session identity and display metadata from +// Copilot hooks instead of transcript/cache scans. +package copilot + +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 = "copilot" + + copilotTitleMetadataKey = "title" + copilotSummaryMetadataKey = "summary" +) + +// Plugin is the GitHub Copilot CLI agent adapter. It is safe for concurrent use; +// the binary path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Copilot 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: "GitHub Copilot", + Description: "Run GitHub Copilot CLI worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Copilot 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 a new headless Copilot session: +// +// copilot [permission flags] [-p ] +// +// The prompt is delivered with `-p`, which runs the prompt in non-interactive +// mode and exits when done. Copilot CLI does not have a documented +// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.copilotBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + appendApprovalFlags(&cmd, cfg.Permissions) + + if cfg.Prompt != "" { + cmd = append(cmd, "-p", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Copilot receives its prompt in the +// launch command itself (via `-p`). +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 Copilot +// session: `copilot [permission flags] --resume [-p ]`. +// ok is false when the hook-derived native session id has not landed yet, so +// callers can fall back to fresh launch behavior. +// +// ports.RestoreConfig carries no Prompt field, so resume is issued without a new +// `-p`; the manager re-sends the prompt through its own delivery path when one is +// needed. +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.copilotBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 8) + cmd = append(cmd, binary) + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--resume", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Copilot hook-derived metadata. Metadata is intentionally +// nil for Copilot: callers get the normalized fields directly. +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[copilotTitleMetadataKey], + Summary: session.Metadata[copilotSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveCopilotBinary returns the path to the copilot binary on this machine, +// searching PATH then a handful of well-known install locations (npm global, +// Homebrew). Returns "copilot" as a last-ditch fallback so callers see a clear +// "command not found" rather than an empty argv. +func ResolveCopilotBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"copilot.cmd", "copilot.exe", "copilot"} { + if path, err := exec.LookPath(name); 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", "copilot.cmd"), + filepath.Join(appData, "npm", "copilot.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".copilot", "bin", "copilot.exe")) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "copilot", nil + } + + if path, err := exec.LookPath("copilot"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/copilot", + "/opt/homebrew/bin/copilot", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".copilot", "bin", "copilot"), + filepath.Join(home, ".npm", "bin", "copilot"), + filepath.Join(home, ".local", "bin", "copilot"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "copilot", nil +} + +func (p *Plugin) copilotBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveCopilotBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +// appendApprovalFlags maps AO's 4 permission modes onto Copilot CLI approval +// flags (https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-programmatic-reference): +// +// default → no flag (defer to ~/.copilot config / per-tool prompts) +// accept-edits → --allow-tool 'write' (auto-approve file edits only) +// auto → --allow-all-tools (auto-approve every tool, still scoped paths/urls) +// bypass-permissions → --allow-all (full bypass: tools, paths, urls) +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's ~/.copilot config / interactive prompts. + case ports.PermissionModeAcceptEdits: + *cmd = append(*cmd, "--allow-tool", "write") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--allow-all-tools") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--allow-all") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go new file mode 100644 index 0000000..37d35f6 --- /dev/null +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -0,0 +1,436 @@ +package copilot + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifestID(t *testing.T) { + got := New().Manifest() + if got.ID != "copilot" { + t.Fatalf("Manifest().ID = %q, want %q", got.ID, "copilot") + } + if got.Name != "GitHub Copilot" { + t.Fatalf("Manifest().Name = %q, want %q", got.Name, "GitHub Copilot") + } +} + +func TestGetLaunchCommandBuildsArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"copilot", "--allow-all", "-p", "-fix this"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandOmitsPromptWhenEmpty(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if contains(cmd, "-p") { + t.Fatalf("command %#v unexpectedly contains -p", cmd) + } +} + +func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected []string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: []string{"--allow-tool", "--allow-all-tools", "--allow-all"}, + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: []string{"--allow-tool", "write"}, + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: []string{"--allow-all-tools"}, + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"--allow-all"}, + }, + { + name: "empty falls back to default", + permission: "", + notExpected: []string{"--allow-tool", "--allow-all-tools", "--allow-all"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + for _, ne := range tt.notExpected { + if contains(cmd, ne) { + t.Fatalf("command %#v unexpectedly contains %q", cmd, ne) + } + } + }) + } +} + +func TestGetLaunchCommandRespectsCanceledContext(t *testing.T) { + plugin := New() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := plugin.GetLaunchCommand(ctx, ports.LaunchConfig{Prompt: "hi"}); err == nil { + t.Fatal("GetLaunchCommand with canceled context: err = nil, want non-nil") + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "uuid-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{"copilot", "--allow-all-tools", "--resume", "uuid-123"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "uuid-123", + copilotTitleMetadataKey: "Fix login redirect", + copilotSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "uuid-123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for Copilot", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func TestGetAgentHooksInstallsCopilotHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + workspace := t.TempDir() + + hooksPath := copilotHooksPath(workspace) + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + // Seed a user-owned agentStop hook plus an unrelated top-level field; both + // must survive install. + existing := `{"version":1,"disableAllHooks":false,"hooks":{"agentStop":[{"type":"command","bash":"custom stop hook","powershell":"custom stop hook"}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + // A second install must not duplicate AO hook commands. + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var file copilotHookFile + if err := json.Unmarshal(data, &file); err != nil { + t.Fatal(err) + } + if file.Version != copilotHooksVersion { + t.Fatalf("version = %d, want %d", file.Version, copilotHooksVersion) + } + if file.DisableAllHooks == nil || *file.DisableAllHooks { + t.Fatalf("disableAllHooks not preserved: %#v", file.DisableAllHooks) + } + for _, spec := range copilotManagedHooks { + command := copilotHookCommandPrefix + spec.Command + if count := countCopilotHookCommand(file.Hooks[spec.Event], command); count != 1 { + t.Fatalf("%s command count = %d, want 1 in %#v", spec.Event, count, file.Hooks[spec.Event]) + } + } + if countCopilotHookCommand(file.Hooks["agentStop"], "custom stop hook") != 1 { + t.Fatalf("existing agentStop hook was not preserved: %#v", file.Hooks["agentStop"]) + } +} + +func TestUninstallHooksRemovesCopilotHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + workspace := t.TempDir() + hooksPath := copilotHooksPath(workspace) + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own agentStop hook; it must survive uninstall. + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"version":1,"hooks":{"agentStop":[{"type":"command","bash":"custom stop hook","powershell":"custom stop hook"}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var file copilotHookFile + if err := json.Unmarshal(data, &file); err != nil { + t.Fatal(err) + } + for _, spec := range copilotManagedHooks { + command := copilotHookCommandPrefix + spec.Command + if got := countCopilotHookCommand(file.Hooks[spec.Event], command); got != 0 { + t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, command, got) + } + } + if countCopilotHookCommand(file.Hooks["agentStop"], "custom stop hook") != 1 { + t.Fatalf("user agentStop hook not preserved: %#v", file.Hooks["agentStop"]) + } +} + +func TestAreHooksInstalledMissingFile(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + installed, err := plugin.AreHooksInstalled(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + if installed { + t.Fatal("AreHooksInstalled on empty workspace = true, want false") + } +} + +func TestHookMethodsRequireWorkspacePath(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + ctx := context.Background() + + if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); err == nil { + t.Fatal("GetAgentHooks with empty WorkspacePath: err = nil, want non-nil") + } + if err := plugin.UninstallHooks(ctx, ""); err == nil { + t.Fatal("UninstallHooks with empty path: err = nil, want non-nil") + } + if _, err := plugin.AreHooksInstalled(ctx, ""); err == nil { + t.Fatal("AreHooksInstalled with empty path: err = nil, want non-nil") + } +} + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + event string + wantState domain.ActivityState + wantOK bool + }{ + {"session-start", domain.ActivityActive, true}, + {"user-prompt-submit", domain.ActivityActive, true}, + {"stop", domain.ActivityIdle, true}, + {"permission-request", domain.ActivityWaitingInput, true}, + {"unknown", "", false}, + {"", "", false}, + } + for _, tt := range tests { + t.Run(tt.event, func(t *testing.T) { + state, ok := DeriveActivityState(tt.event, nil) + if state != tt.wantState || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, state, ok, tt.wantState, tt.wantOK) + } + }) + } +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func containsSubsequence(values []string, needle []string) bool { + if len(needle) == 0 { + return true + } + for start := range values { + if start+len(needle) > len(values) { + return false + } + ok := true + for offset, want := range needle { + if values[start+offset] != want { + ok = false + break + } + } + if ok { + return true + } + } + return false +} + +func countCopilotHookCommand(entries []copilotHookEntry, command string) int { + count := 0 + for _, entry := range entries { + if entry.Bash == command || entry.Powershell == command { + count++ + } + } + return count +} diff --git a/backend/internal/adapters/agent/copilot/hooks.go b/backend/internal/adapters/agent/copilot/hooks.go new file mode 100644 index 0000000..533ec4d --- /dev/null +++ b/backend/internal/adapters/agent/copilot/hooks.go @@ -0,0 +1,285 @@ +package copilot + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "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. agentStop is mapped to the +// "stop" sub-command (turn end → idle). +var copilotManagedHooks = []copilotHookSpec{ + {Event: "sessionStart", Command: "session-start"}, + {Event: "userPromptSubmitted", Command: "user-prompt-submit"}, + {Event: "permissionRequest", 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) + } + 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.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 +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index e6f437f..4eb56d7 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -12,6 +12,7 @@ import ( "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" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/crush" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" @@ -39,6 +40,7 @@ func Constructors() []adapters.Adapter { aider.New(), cursor.New(), qwen.New(), + copilot.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index d3e1be9..db9a67a 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -101,6 +101,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessAider, "aider"}, {domain.HarnessCursor, "cursor"}, {domain.HarnessQwen, "qwen"}, + {domain.HarnessCopilot, "copilot"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness) From 85619b58d5974133c0c16e22a5b7dd41457eaf25 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Sat, 6 Jun 2026 20:02:13 +0530 Subject: [PATCH 2/4] Update backend/internal/adapters/agent/copilot/hooks.go Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- backend/internal/adapters/agent/copilot/hooks.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/internal/adapters/agent/copilot/hooks.go b/backend/internal/adapters/agent/copilot/hooks.go index 533ec4d..2b96ce3 100644 --- a/backend/internal/adapters/agent/copilot/hooks.go +++ b/backend/internal/adapters/agent/copilot/hooks.go @@ -250,6 +250,10 @@ func atomicWriteFile(path string, data []byte, perm os.FileMode) error { _ = tmp.Close() return err } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } if err := tmp.Close(); err != nil { return err } From f1f540dc8847ad72aef86bd09981bbdd0d5009e2 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sat, 6 Jun 2026 21:03:53 +0530 Subject: [PATCH 3/4] fix(copilot): map permission-request to documented preToolUse event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot CLI does not document a "permissionRequest" hook event. Per https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks the documented camelCase events are sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse, errorOccurred, agentStop. Writing "permissionRequest" into .github/hooks/ao.json silently disables that hook because Copilot does not recognize the key. Remap AO's permission-request sub-command onto preToolUse (the closest documented signal — fires before any tool invocation, including ones that would prompt for approval) and add a tripwire test asserting the JSON keys AO writes match the documented camelCase names. Co-Authored-By: Claude Opus 4.7 --- .../adapters/agent/copilot/activity.go | 10 ++++--- .../adapters/agent/copilot/copilot_test.go | 26 +++++++++++++++++++ .../internal/adapters/agent/copilot/hooks.go | 14 +++++++--- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/backend/internal/adapters/agent/copilot/activity.go b/backend/internal/adapters/agent/copilot/activity.go index 3b311ec..a321105 100644 --- a/backend/internal/adapters/agent/copilot/activity.go +++ b/backend/internal/adapters/agent/copilot/activity.go @@ -11,10 +11,12 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain" // installs and what they mean live in one place. // // Copilot CLI documents that prompt-style hooks (userPromptSubmitted) do NOT -// fire in non-interactive `-p` mode, while permissionRequest is "especially -// useful in CLI pipe mode (-p)". AO still installs every event so interactive -// resume and future modes report activity; the permission-request → waiting_input -// mapping is the one that always fires under AO's headless launch. +// fire in non-interactive `-p` mode, while preToolUse fires before every tool +// invocation (including ones that would prompt the user for approval) and is +// the most reliable signal in CLI pipe mode (-p). AO still installs every event +// so interactive resume and future modes report activity; the +// permission-request → waiting_input mapping (driven by preToolUse) is the one +// that always fires under AO's headless launch. // // TODO(copilot): ActivityExited is still runtime-observation-owned. If Copilot's // sessionEnd/agentStop hook proves reliable in `-p` mode, map a real diff --git a/backend/internal/adapters/agent/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go index 37d35f6..eeb3c4c 100644 --- a/backend/internal/adapters/agent/copilot/copilot_test.go +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -371,6 +371,32 @@ func TestHookMethodsRequireWorkspacePath(t *testing.T) { } } +// TestCopilotManagedHooksUseDocumentedEventNames pins the JSON keys AO writes +// into .github/hooks/ao.json to the camelCase names Copilot CLI documents +// (https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks). +// Drifting back to lowercase-dashed or any other casing silently disables the +// hooks, so this is a tripwire for that class of regression. +func TestCopilotManagedHooksUseDocumentedEventNames(t *testing.T) { + wantEventByCommand := map[string]string{ + "session-start": "sessionStart", + "user-prompt-submit": "userPromptSubmitted", + "permission-request": "preToolUse", + "stop": "agentStop", + } + if len(copilotManagedHooks) != len(wantEventByCommand) { + t.Fatalf("copilotManagedHooks length = %d, want %d", len(copilotManagedHooks), len(wantEventByCommand)) + } + for _, spec := range copilotManagedHooks { + want, ok := wantEventByCommand[spec.Command] + if !ok { + t.Fatalf("unexpected AO sub-command %q in copilotManagedHooks", spec.Command) + } + if spec.Event != want { + t.Fatalf("command %q event = %q, want %q (Copilot CLI documented camelCase)", spec.Command, spec.Event, want) + } + } +} + func TestDeriveActivityState(t *testing.T) { tests := []struct { event string diff --git a/backend/internal/adapters/agent/copilot/hooks.go b/backend/internal/adapters/agent/copilot/hooks.go index 2b96ce3..f6fafe3 100644 --- a/backend/internal/adapters/agent/copilot/hooks.go +++ b/backend/internal/adapters/agent/copilot/hooks.go @@ -66,12 +66,20 @@ type copilotHookSpec struct { // 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. agentStop is mapped to the -// "stop" sub-command (turn end → idle). +// 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: "permissionRequest", Command: "permission-request"}, + {Event: "preToolUse", Command: "permission-request"}, {Event: "agentStop", Command: "stop"}, } From fa111db463b16665936307f99d11df5f9bf6853d Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sat, 6 Jun 2026 21:19:37 +0530 Subject: [PATCH 4/4] chore(copilot): gofmt the new tripwire test Co-Authored-By: Claude Opus 4.7 --- backend/internal/adapters/agent/copilot/copilot_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/internal/adapters/agent/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go index eeb3c4c..d4c4c0c 100644 --- a/backend/internal/adapters/agent/copilot/copilot_test.go +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -378,10 +378,10 @@ func TestHookMethodsRequireWorkspacePath(t *testing.T) { // hooks, so this is a tripwire for that class of regression. func TestCopilotManagedHooksUseDocumentedEventNames(t *testing.T) { wantEventByCommand := map[string]string{ - "session-start": "sessionStart", - "user-prompt-submit": "userPromptSubmitted", - "permission-request": "preToolUse", - "stop": "agentStop", + "session-start": "sessionStart", + "user-prompt-submit": "userPromptSubmitted", + "permission-request": "preToolUse", + "stop": "agentStop", } if len(copilotManagedHooks) != len(wantEventByCommand) { t.Fatalf("copilotManagedHooks length = %d, want %d", len(copilotManagedHooks), len(wantEventByCommand))