Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 99 additions & 2 deletions adapter.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,101 @@
package agentstatus

// Adapter type, RegisterAdapter, and the adapter registry will live here.
// Intentionally empty in the scaffolding ticket — see specs/design.md.
import (
"errors"
"fmt"
"sort"
"sync"
)

// Adapter is the per-agent extension point. Built-in adapters live under
// adapters/<name> and self-register from init(); third parties register the
// same way.
//
// MapHookEvent is required: it translates a single native hook payload into a
// Signal (or returns (nil, nil) to silently drop, e.g. for unknown event
// names or metadata-only events).
//
// InstallHooks and UninstallHooks may be nil if an adapter does not yet
// implement them; the orchestrator (deferred to a later ticket) treats nil as
// "skipped — not implemented".
type Adapter struct {
Name Agent
MapHookEvent func(event string, payload map[string]any) (*Signal, error)
InstallHooks func(cfg InstallConfig) (InstallResult, error)
UninstallHooks func(cfg InstallConfig) (InstallResult, error)
}

// InstallConfig parameterizes hook installation. See specs/design.md.
type InstallConfig struct {
// Endpoint is the base URL the bridge POSTs hook payloads to (e.g.
// "http://localhost:9090/hook"). Adapters append /<agent> as needed.
Endpoint string
// Agents narrows install to a subset; empty means all registered agents.
Agents []Agent
// Project, when non-empty, targets a project-level config file instead of
// the user-level default.
Project string
}

// InstallResult is one adapter's outcome from an install or uninstall pass.
type InstallResult struct {
Agent Agent
Installed bool
Skipped bool
Reason string
Path string
}

// AllAgents enumerates the built-in agent identifiers, in a stable order
// suitable for InstallConfig.Agents.
var AllAgents = []Agent{Claude, Codex, OpenCode}

var (
registryMu sync.RWMutex
registry = map[Agent]Adapter{}
)

// ErrUnknownAgent is returned by Hub.Ingest when no adapter is registered
// under the given Agent name. The HTTP handler maps this to 404.
var ErrUnknownAgent = errors.New("agentstatus: unknown agent")

// RegisterAdapter adds an adapter to the package-level registry. It is
// goroutine-safe and intended to be called from init() in adapter
// subpackages. Returns an error if the name is empty or already registered.
func RegisterAdapter(a Adapter) error {
if a.Name == "" {
return errors.New("agentstatus: adapter name is empty")
}
if a.MapHookEvent == nil {
return fmt.Errorf("agentstatus: adapter %q has nil MapHookEvent", a.Name)
}
registryMu.Lock()
defer registryMu.Unlock()
if _, ok := registry[a.Name]; ok {
return fmt.Errorf("agentstatus: adapter %q already registered", a.Name)
}
registry[a.Name] = a
return nil
}

// Adapters returns a snapshot of registered adapters, sorted by Name. The
// returned slice is owned by the caller; mutating it does not affect the
// registry.
func Adapters() []Adapter {
registryMu.RLock()
out := make([]Adapter, 0, len(registry))
for _, a := range registry {
out = append(out, a)
}
registryMu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}

// lookupAdapter is the internal accessor used by Hub.Ingest.
func lookupAdapter(name Agent) (Adapter, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
a, ok := registry[name]
return a, ok
}
128 changes: 128 additions & 0 deletions adapter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package agentstatus

import (
"errors"
"strings"
"sync"
"testing"
)

// withCleanRegistry runs fn with a freshly-empty registry, restoring whatever
// adapters were registered (e.g. by init() in blank-imported subpackages)
// when fn returns. Tests share the package-level registry, so this isolation
// is required.
func withCleanRegistry(t *testing.T, fn func()) {
t.Helper()
registryMu.Lock()
saved := registry
registry = map[Agent]Adapter{}
registryMu.Unlock()

defer func() {
registryMu.Lock()
registry = saved
registryMu.Unlock()
}()
fn()
}

func okMap(string, map[string]any) (*Signal, error) { return nil, nil }

func TestRegisterAdapter_Duplicate(t *testing.T) {
withCleanRegistry(t, func() {
a := Adapter{Name: "fake", MapHookEvent: okMap}
if err := RegisterAdapter(a); err != nil {
t.Fatalf("first register: %v", err)
}
err := RegisterAdapter(a)
if err == nil {
t.Fatal("expected duplicate error")
}
if !strings.Contains(err.Error(), "already registered") {
t.Errorf("error text: %v", err)
}
})
}

func TestRegisterAdapter_EmptyName(t *testing.T) {
withCleanRegistry(t, func() {
err := RegisterAdapter(Adapter{MapHookEvent: okMap})
if err == nil {
t.Fatal("expected empty-name error")
}
if !strings.Contains(err.Error(), "empty") {
t.Errorf("error text: %v", err)
}
})
}

func TestRegisterAdapter_NilMap(t *testing.T) {
withCleanRegistry(t, func() {
err := RegisterAdapter(Adapter{Name: "fake"})
if err == nil {
t.Fatal("expected nil-map error")
}
})
}

func TestAdapters_SortedSnapshot(t *testing.T) {
withCleanRegistry(t, func() {
_ = RegisterAdapter(Adapter{Name: "zebra", MapHookEvent: okMap})
_ = RegisterAdapter(Adapter{Name: "alpha", MapHookEvent: okMap})
_ = RegisterAdapter(Adapter{Name: "mango", MapHookEvent: okMap})

got := Adapters()
if len(got) != 3 {
t.Fatalf("len: %d", len(got))
}
want := []Agent{"alpha", "mango", "zebra"}
for i, a := range got {
if a.Name != want[i] {
t.Errorf("[%d]: got %q, want %q", i, a.Name, want[i])
}
}

// Mutating the returned slice must not affect the registry.
got[0] = Adapter{Name: "tampered"}
again := Adapters()
if again[0].Name != "alpha" {
t.Errorf("registry mutated via snapshot: %q", again[0].Name)
}
})
}

func TestRegisterAdapter_Concurrent(t *testing.T) {
withCleanRegistry(t, func() {
var wg sync.WaitGroup
for i := range 16 {
wg.Add(1)
go func(i int) {
defer wg.Done()
_ = RegisterAdapter(Adapter{
Name: Agent("a-" + string(rune('a'+i))),
MapHookEvent: okMap,
})
_ = Adapters()
}(i)
}
wg.Wait()
if got := len(Adapters()); got != 16 {
t.Errorf("registered: got %d, want 16", got)
}
})
}

func TestErrUnknownAgent_IsSentinel(t *testing.T) {
withCleanRegistry(t, func() {
h, err := NewHub(HubConfig{})
if err != nil {
t.Fatalf("NewHub: %v", err)
}
t.Cleanup(func() { _ = h.Close() })

err = h.Ingest("nope", []byte(`{}`))
if !errors.Is(err, ErrUnknownAgent) {
t.Fatalf("Ingest err: %v", err)
}
})
}
57 changes: 57 additions & 0 deletions adapters/claude/adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package claude

import (
agentstatus "github.com/kareemaly/agentstatus"
)

// Adapter is the registered Claude Code adapter. Imported for side effects
// from package init().
//
// Caveats (per specs/design.md §"Known gaps"):
//
// - Auto-approved tools: when a user has pre-approved a tool, Claude does
// not fire PermissionRequest, so the "awaiting_input" status will be rarer
// than for users running with default permissions.
// - "Thinking" gap: between UserPromptSubmit and the next hook event no
// status signal fires. Status remains "working" (inferred from
// UserPromptSubmit) until PreToolUse or Stop. This is acceptable.
// - Subagent identity: per the Claude hooks schema, SubagentStart and
// SubagentStop fire under the parent session's `session_id` and carry the
// subagent's stable id in `agent_id`. We model the subagent as an
// independent session: emitted Event.SessionID = agent_id, and
// Event.ParentSessionID = parent's session_id.
var Adapter = agentstatus.Adapter{
Name: agentstatus.Claude,
MapHookEvent: MapHookEvent,
InstallHooks: installHooks,
UninstallHooks: uninstallHooks,
}

func init() {
if err := agentstatus.RegisterAdapter(Adapter); err != nil {
// Registry collisions during init mean a programming error in the
// importing binary (double-import of this package is impossible; a
// duplicate Name in another adapter is the only way). Panic so the
// binary fails fast at startup.
panic(err)
}
}

// installHooks is a placeholder for the next ticket. It returns
// Skipped: true so the orchestrator can fan out without the Claude adapter
// claiming success.
func installHooks(_ agentstatus.InstallConfig) (agentstatus.InstallResult, error) {
return agentstatus.InstallResult{
Agent: agentstatus.Claude,
Skipped: true,
Reason: "not yet implemented",
}, nil
}

func uninstallHooks(_ agentstatus.InstallConfig) (agentstatus.InstallResult, error) {
return agentstatus.InstallResult{
Agent: agentstatus.Claude,
Skipped: true,
Reason: "not yet implemented",
}, nil
}
Loading
Loading