a2a

package
v0.22.157 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 23, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package a2a — Agent2Agent protocol surface for clawtool (ADR-024). Phase 1 ships only the Agent Card serializer + the JSON shape the protocol wants advertised at /.well-known/agent-card.json. mDNS announce, HTTP server, peer discovery, and capability tier enforcement layer on top in phase 2+.

We adopt Google A2A's wire format (now Linux Foundation; github.com/a2aproject/A2A) verbatim rather than inventing one, per ADR-007 (wrap, don't reinvent). The Card describes *what* this clawtool instance can do (capabilities + skills + auth); it deliberately does NOT enumerate every aggregated tool — per A2A's opacity model, peers see the agent's contract, not its internal toolset.

Package a2a — peer inbox. The discovery half (registry.go) surfaces *who* is on the host; the inbox half (this file) is *how they talk*. Each peer has an in-memory mailbox; senders enqueue via POST /v1/peers/{id}/messages, recipients drain via GET /v1/peers/{id}/messages or `clawtool peer inbox`.

Wire shape mirrors repowire/repowire/protocol/messages.py (Query / Response / Notification / Broadcast) so a runtime hook polling once per UserPromptSubmit can surface pending messages as additionalContext without inventing its own format.

Persistence: each peer's inbox is mirrored to ~/.config/clawtool/peers.d/<peer_id>.inbox.json on every mutation. A daemon crash mid-flight loses at most the last in-flight message; the rest survive a restart. Soft cap at 256 messages per peer — overflow drops the OLDEST so a chatty sender can't OOM the daemon. New peers start empty.

Package a2a — mDNS announce + browse (Phase 2a of ADR-024).

Two clawtool daemons on the same LAN broadcast `_clawtool._tcp.local` via Zeroconf/Bonjour and parallel browse the same service type to populate the existing same-host peer Registry. TXT records carry the agent_card URL + peer_id + product version so a discovering peer can dial the announcer's Agent Card without a second DNS round trip.

Library choice: github.com/grandcat/zeroconf — idiomatic Go wrapper, MIT, widely deployed. ADR-024 names it as the de-facto pick for clawtool's mDNS surface.

Lifecycle: StartAnnounce + StartBrowse return idempotent handles (second call with the same peer_id returns the existing instance). Both honour context cancellation; Close() on either is also idempotent. The daemon boot path in internal/server/http.go owns the context — SIGTERM cancels it, which triggers graceful deregister of the announce + shutdown of the browse loop.

Self-discovery: the browse loop filters its own announcement by matching the `peer_id` TXT field against the local Announcer's peer_id. Without this, every daemon would add itself to its own registry. The match is best-effort (TXT parsing failures fall through to "not us") but covers the standard case where the Announcer published a well-formed record.

WSL2 quirks: the WSL2 NAT layer drops UDP multicast on 224.0.0.251:5353 by default. We don't try to work around that here — `clawtool doctor` prints a hint pointing operators at docs/networking.md for the firewall / Tailscale / static-peer recipes.

Package a2a — peer registry. Phase 1 of ADR-024's local-mesh half: every running clawtool / claude-code / codex / gemini / opencode session on this host registers into a single in-memory table keyed on a stable peer_id, so `clawtool a2a peers` can surface the live roster.

Mirrors the shape of repowire/daemon/peer_registry.py (prassanna-ravishankar/repowire) — the reference implementation for the discovery half. Differences from repowire:

  • Identity tuple: (backend, path, session_id, tmux_pane). The runtime-supplied session_id (claude-code's hook payload `.session_id`, etc.) is the primary disambiguator so two parallel sessions in the same cwd register as separate peers. tmux_pane is the secondary key when no session id exists.
  • REST + 30s heartbeat instead of WebSocket transport. The real-time push notifications repowire offers via websocket are deferred to Phase 2; Phase 1 ships the registry + polling because it's a fraction of the LoC and covers 80% of the operator value (visibility, cross-pane discovery).

Persistence: ~/.config/clawtool/peers.json (LF-delimited JSON, 0600). Atomic temp+rename writes so a crash mid-write doesn't leave a corrupt state file. Lazy repair on every read sweeps peers whose declared `path` no longer exists.

Index

Constants

View Source
const CurrentProtocolVersion = "0.2.0"

CurrentProtocolVersion is the A2A spec snapshot we conform to. Bump in lockstep with upstream as their stable snapshots advance.

View Source
const HeartbeatStaleAfter = 60 * time.Second

HeartbeatStaleAfter — peers whose last_seen is older than this are flipped to PeerOffline on the next list. Matches the 30 s heartbeat cadence we recommend in the registration docs (one missed heartbeat = grace period; two missed = offline).

View Source
const ServiceDomain = "local."

ServiceDomain is the DNS-SD domain. "local" is the only legal value for mDNS / Bonjour — we name it as a constant so the announce / browse pair don't drift.

View Source
const ServiceType = "_clawtool._tcp"

ServiceType is the DNS-SD service identifier every clawtool daemon advertises. Per ADR-024 §LAN discovery. Other clawtool instances browse the same string to find peers.

Variables

This section is empty.

Functions

func DefaultStatePath added in v0.22.36

func DefaultStatePath() string

DefaultStatePath returns ~/.config/clawtool/peers.json (or its XDG_CONFIG_HOME equivalent). Mirrors daemon.StatePath's convention so an operator inspecting the config dir sees daemon.json + peers.json side-by-side.

func PeersStateDir added in v0.22.36

func PeersStateDir() string

PeersStateDir returns the canonical ~/.config/clawtool/peers.d directory used by both the daemon (per-peer inbox files written by this package) and the CLI's `clawtool peer` verb (per-session id pointer files). One layout, one helper — exported so callers outside this package don't reinvent the path-resolution dance.

On-disk layout:

peers.d/<session>.id            — CLI's session→peer_id pointer
peers.d/<peer_uuid>.inbox.json  — daemon's per-peer mailbox

func SetGlobal added in v0.22.36

func SetGlobal(r *Registry)

SetGlobal installs the process-wide registry. Caller — typically internal/server/server.go's buildMCPServer — does this once at boot. Passing nil clears it (used by daemon shutdown).

func WaitForBrowseSettle added in v0.22.157

func WaitForBrowseSettle(d time.Duration)

WaitForBrowseSettle is a test-helper barrier — blocks until the browser has had a chance to drain its initial discovery burst. Production callers don't need it (the registry is updated lazily on every read). Lives here rather than the _test.go file because zeroconf's discovery latency varies by platform; centralising the wait avoids per-test tuning.

Types

type Announcer added in v0.22.157

type Announcer struct {
	// contains filtered or unexported fields
}

Announcer is the running mDNS announce handle. Returned by StartAnnounce; the caller holds it for the daemon's lifetime and invokes Close() on shutdown to send a goodbye packet.

func StartAnnounce added in v0.22.157

func StartAnnounce(ctx context.Context, card *Card, port int) (*Announcer, error)

StartAnnounce registers `_clawtool._tcp.local` on the host's network interfaces, advertising `port` with TXT records that carry the Agent Card URL + peer_id + product version. The caller is typically the daemon's serve handler, called after the HTTP listener is up so the advertised port is bindable.

Idempotent: a second call returns the existing instance rather than registering a duplicate. Callers MUST hold the returned *Announcer for the daemon's lifetime; calling Close() on the announcer deregisters cleanly.

Context cancellation: the announcer doesn't own a goroutine (zeroconf.Server runs its own mainloop), so ctx is used only to derive a deregister-on-cancel goroutine. Passing the daemon's root context lets SIGTERM unwind the announce.

func (*Announcer) Close added in v0.22.157

func (a *Announcer) Close() error

Close deregisters the announce and stops the underlying mDNS server. Idempotent — a second call is a no-op so daemon shutdown paths can call it without remembering whether they already did. Returns nil when the announcer was never started (defensive — callers should still nil-check).

Lock order: per-instance closeMu is acquired in a SHORT critical section to flip the closed flag and snapshot the server pointer, then released BEFORE the longer-running Shutdown() call and the announceMu acquisition for singleton clearing. The ctx-cancel goroutine spawned by StartAnnounce can race a direct Close from the daemon shutdown path; this ordering prevents deadlock against a caller holding announceMu (e.g. test reset paths).

func (*Announcer) PeerID added in v0.22.157

func (a *Announcer) PeerID() string

PeerID returns the announcer's stable identifier. Used by the browser to filter the local daemon's own announcement out of the discovered-peer stream.

func (*Announcer) Port added in v0.22.157

func (a *Announcer) Port() int

Port returns the advertised port (the daemon's HTTP listener). Exposed for the test suite + diagnostic surfaces; runtime callers should read it from daemon.json instead.

type Browser added in v0.22.157

type Browser struct {
	// contains filtered or unexported fields
}

Browser is the running mDNS browse handle. Owns the entries channel + the discovered-peer goroutine that translates zeroconf service events into Registry.Register calls.

func StartBrowse added in v0.22.157

func StartBrowse(ctx context.Context, reg *Registry, selfPeerID string) (*Browser, error)

StartBrowse spins up a parallel zeroconf browse loop watching `_clawtool._tcp.local`. Every discovered ServiceEntry whose peer_id TXT field differs from `selfPeerID` is registered into `reg` with `source: "mdns"` and a metadata block carrying the parsed TXT fields (agent_card_url, version).

Returns immediately after the resolver is set up; the worker goroutine runs until Close() (or ctx cancel) tears it down. Idempotent: a second call returns the existing instance.

selfPeerID may be empty when the local daemon has no announcer (browse-only mode, e.g. a CLI inspection verb). In that case every discovered peer is registered.

func (*Browser) Close added in v0.22.157

func (b *Browser) Close() error

Close stops the browse loop and waits for the worker goroutine to exit. Idempotent — second call is a no-op so daemon shutdown paths can call it unconditionally. Same two-phase lock ordering as Announcer.Close — release the per-instance mutex before grabbing the package-level mutex so a caller holding browseMu (e.g. test reset) can't deadlock against the worker goroutine waiting on closeMu.

type Capabilities

type Capabilities struct {
	// Streaming: server-sent events for long-running tasks. We
	// have BIAM (TaskNotify) which is conceptually the same;
	// phase 2 wires the HTTP transport.
	Streaming bool `json:"streaming"`

	// PushNotifications: webhook delivery on task transitions.
	// Same primitive as TaskNotify but cross-network. Phase 3+.
	PushNotifications bool `json:"pushNotifications"`

	// StateTransitionHistory: peer can replay every task
	// state. BIAM stores envelope history; we'll expose it
	// when phase 2 ships the HTTP /tasks/{id} endpoint.
	StateTransitionHistory bool `json:"stateTransitionHistory"`
}

Capabilities is A2A's feature-flag block. We model only the flags clawtool currently cares about; A2A's spec allows additional vendor extensions.

type Card

type Card struct {
	// Name is the human-readable agent name shown in registries
	// and peer dashboards. We use clawtool's instance name when
	// the operator has set one; otherwise the bare project
	// name.
	Name string `json:"name"`

	// Description is one paragraph of plain text describing what
	// this agent does. Peers may render it in discovery UIs.
	Description string `json:"description"`

	// URL is the JSON-RPC endpoint base. Empty in phase 1
	// (Card-only mode); populated when phase 2 lands the HTTP
	// server.
	URL string `json:"url,omitempty"`

	// Version is the agent's product version (clawtool's
	// internal/version.Resolved()). NOT the A2A protocol version;
	// that's protocolVersion below.
	Version string `json:"version"`

	// ProtocolVersion is the A2A spec the card conforms to.
	// Follow upstream as it evolves; pinning here lets peers
	// negotiate.
	ProtocolVersion string `json:"protocolVersion"`

	// Capabilities is the feature flag block. We surface only
	// the streaming + push-notifications primitives clawtool
	// can actually serve today; future phases flip more on as
	// the implementation lands.
	Capabilities Capabilities `json:"capabilities"`

	// Skills enumerates the high-level abilities this agent
	// advertises. We don't dump every internal tool — that
	// would leak the operator's private surface and overflow
	// the card. Skills are *coarse* groupings (research,
	// code-edit, dispatch) the peer chooses from.
	Skills []Skill `json:"skills"`

	// DefaultInputModes / DefaultOutputModes — A2A's MIME-typed
	// I/O contract. clawtool speaks plain text + JSON-RPC; we
	// don't ship audio / image modes today.
	DefaultInputModes  []string `json:"defaultInputModes"`
	DefaultOutputModes []string `json:"defaultOutputModes"`

	// SecuritySchemes describes the auth modes this agent
	// accepts. Phase 1 advertises an empty schemes block (peers
	// can read the card but can't authenticate yet). Phase 2+
	// adds Bearer / OAuth schemes per ADR-024 §Threat model.
	SecuritySchemes map[string]SecurityScheme `json:"securitySchemes,omitempty"`

	// PublishedAt is when this card snapshot was generated.
	// Useful for caches / freshness checks. Always UTC.
	PublishedAt time.Time `json:"publishedAt"`
}

Card is the canonical A2A Agent Card (Schema v0.2.x, the stable LF-A2A snapshot as of 2026-04). Field names match the spec verbatim — JSON-RPC clients consuming the card MUST be able to parse it without translation.

func NewCard

func NewCard(opts CardOptions) Card

NewCard builds the Card snapshot for this clawtool instance. Pure function — fields come from CardOptions + version.Resolved() + a static skill list. Caller serializes via json.Marshal.

func (Card) MarshalIndented

func (c Card) MarshalIndented() ([]byte, error)

MarshalIndented is the human-readable form for CLI output. Two-space indent matches GitHub Actions' workflow YAML convention so an operator copy-pasting the output into a gist / issue gets a readable layout.

func (Card) MarshalJSON

func (c Card) MarshalJSON() ([]byte, error)

MarshalJSON serializes the card to compact JSON — what an HTTP handler would write to /.well-known/agent-card.json. We use MarshalIndent for the CLI surface (`clawtool a2a card`) so humans can read it; the server-side path uses bare json.Marshal.

type CardOptions

type CardOptions struct {
	// Name overrides the default ("clawtool"); empty keeps default.
	Name string
	// URL is the JSON-RPC endpoint. Empty until phase 2 lands.
	URL string
	// ExtraSkills appends to the canonical skill list. Empty
	// gives just the canonical set.
	ExtraSkills []Skill
}

CardOptions carries the per-instance fields we don't want hard-coded into NewCard so a future supervisor can customise per dispatch (e.g. emit different skills depending on which instance is calling).

type Inbox added in v0.22.36

type Inbox struct {
	// contains filtered or unexported fields
}

Inbox is the per-peer message queue. One Inbox per registered peer; created lazily on first send. Methods are safe for concurrent calls — mu guards both the queue and the on-disk snapshot.

func (*Inbox) Drain added in v0.22.36

func (i *Inbox) Drain(peek bool) []Message

Drain returns every queued message and empties the inbox. Pass peek=true to read without consuming — the runtime's UserPromptSubmit hook uses peek to avoid losing messages if the recipient cancels the prompt.

func (*Inbox) Enqueue added in v0.22.36

func (i *Inbox) Enqueue(msg Message) Message

Enqueue appends `msg` to this inbox, capping to inboxCap and dropping the oldest if needed. Returns the persisted message (with assigned ID + timestamp when the caller didn't supply them). Idempotent on (FromPeer, Timestamp, Text) is NOT attempted — duplicate sends mean the sender retried; the recipient sees both.

type ListFilter added in v0.22.36

type ListFilter struct {
	Status  PeerStatus
	Path    string
	Backend string
	Circle  string
}

ListFilter narrows the result set returned by List. Empty fields are no-ops so callers can pass {Backend: "claude-code"} to see just claude peers.

type Message added in v0.22.36

type Message struct {
	ID            string      `json:"id"`
	Type          MessageType `json:"type"`
	FromPeer      string      `json:"from_peer"`
	ToPeer        string      `json:"to_peer,omitempty"` // omitted for broadcast
	Text          string      `json:"text"`
	CorrelationID string      `json:"correlation_id,omitempty"` // matches a prior query's ID
	Timestamp     time.Time   `json:"timestamp"`
}

Message is one envelope in the peer mesh.

type MessageType added in v0.22.36

type MessageType string

MessageType matches repowire's protocol/messages.py taxonomy. Locked at v0.22; new types are additive.

const (
	MsgQuery        MessageType = "query"        // expects a response
	MsgResponse     MessageType = "response"     // reply to a query (correlation_id required)
	MsgNotification MessageType = "notification" // fire-and-forget
	MsgBroadcast    MessageType = "broadcast"    // to all peers (to_peer ignored)
)

type Peer added in v0.22.36

type Peer struct {
	PeerID       string            `json:"peer_id"`
	DisplayName  string            `json:"display_name"`
	Path         string            `json:"path,omitempty"`
	Backend      string            `json:"backend"` // claude-code | codex | gemini | opencode | clawtool
	Circle       string            `json:"circle"`  // group name; defaults to tmux session or "default"
	Role         PeerRole          `json:"role"`
	Status       PeerStatus        `json:"status"`
	SessionID    string            `json:"session_id,omitempty"` // runtime-supplied session key (claude-code: hook payload .session_id)
	TmuxPane     string            `json:"tmux_pane,omitempty"`
	PID          int               `json:"pid,omitempty"`
	Metadata     map[string]string `json:"metadata,omitempty"`
	RegisteredAt time.Time         `json:"registered_at"`
	LastSeen     time.Time         `json:"last_seen"`
}

Peer is the single source of truth for one registered session. Field names are JSON-serialised verbatim so the wire shape (the `/v1/peers` endpoint) reflects the in-memory model directly.

type PeerRole added in v0.22.36

type PeerRole string

PeerRole differentiates dispatchers (orchestrators) from dispatchees (worker agents). Most peers are agents; an operator running multiple terminals manually flips one to orchestrator if they want it to coordinate the others.

const (
	RoleAgent        PeerRole = "agent"
	RoleOrchestrator PeerRole = "orchestrator"
)

type PeerStatus added in v0.22.36

type PeerStatus string

PeerStatus is the lifecycle marker every peer carries.

const (
	PeerOnline  PeerStatus = "online"
	PeerBusy    PeerStatus = "busy"
	PeerOffline PeerStatus = "offline"
)

type RegisterInput added in v0.22.36

type RegisterInput struct {
	DisplayName string            `json:"display_name"`
	Path        string            `json:"path,omitempty"`
	Backend     string            `json:"backend"`
	Circle      string            `json:"circle,omitempty"`
	Role        PeerRole          `json:"role,omitempty"`
	SessionID   string            `json:"session_id,omitempty"`
	TmuxPane    string            `json:"tmux_pane,omitempty"`
	PID         int               `json:"pid,omitempty"`
	Metadata    map[string]string `json:"metadata,omitempty"`
}

RegisterInput is the shape callers supply to Register. Mirrors the JSON body of POST /v1/peers/register so the HTTP handler is a thin marshaller.

type Registry added in v0.22.36

type Registry struct {
	// contains filtered or unexported fields
}

Registry is the process-wide peer table. One instance lives in the daemon for the lifetime of the process; constructed via NewRegistry which loads any persisted state.

func GetGlobal added in v0.22.36

func GetGlobal() *Registry

GetGlobal returns the process-wide registry, or nil when no daemon has set one. Read-side callers (CLI tools, MCP handlers) should nil-check; the returned value is safe for concurrent access.

func NewRegistry added in v0.22.36

func NewRegistry(statePath string) *Registry

NewRegistry constructs an empty registry, then attempts to load state from path. A missing / unreadable / corrupt file is non-fatal: we start with an empty table and log to stderr.

func (*Registry) Broadcast added in v0.22.36

func (r *Registry) Broadcast(msg Message) int

Broadcast enqueues `msg` into every currently-known peer's inbox (except the sender's own, identified by msg.FromPeer). Returns the count of recipients reached. Used by MsgBroadcast — one HTTP hit fans out to all live sessions.

func (*Registry) Deregister added in v0.22.36

func (r *Registry) Deregister(peerID string) (*Peer, error)

Deregister removes a peer outright. Used by SessionEnd hooks when the session is shutting down cleanly. Returns the removed peer (or nil) so callers can surface a "peer X went offline" event. Also drops the peer's inbox so deregistered sessions don't leave persisted mailboxes behind.

func (*Registry) DrainInbox added in v0.22.36

func (r *Registry) DrainInbox(peerID string, peek bool) []Message

DrainInbox returns the pending messages for peerID and clears them (or peeks, leaving them queued). Non-existent peers return an empty slice — the inbox is created lazily and an empty drain stays empty.

func (*Registry) Get added in v0.22.36

func (r *Registry) Get(peerID string) *Peer

Get returns one peer by ID, or nil when unknown. Pure read, no lazy-repair (the lazy sweep is List's job).

func (*Registry) Heartbeat added in v0.22.36

func (r *Registry) Heartbeat(peerID string, status PeerStatus) (*Peer, error)

Heartbeat refreshes a peer's last_seen + status. Returns nil-error / nil-peer when the peer_id is unknown; that's the "I just registered, then noticed my session ID was wrong" case — caller should re-register, not retry.

func (*Registry) List added in v0.22.36

func (r *Registry) List(filter ListFilter) []Peer

List returns every peer matching the filter. Lazy-repair runs inline: peers whose last_seen is older than HeartbeatStaleAfter flip to PeerOffline before the result is built; peers whose declared path no longer exists are dropped entirely. Sort order: online first, then by display_name lexicographic — so `clawtool a2a peers` reads top-down "currently active first".

func (*Registry) Register added in v0.22.36

func (r *Registry) Register(in RegisterInput) (*Peer, error)

Register adds a new peer (or refreshes an existing one with the same identity tuple) and returns the assigned peer_id. Idempotent: repeated calls with the same backend + path + tmux_pane + pubkey update the existing row's last_seen instead of creating a duplicate. Without this, every hook fire would multiply the peer table.

func (*Registry) Save added in v0.22.36

func (r *Registry) Save() error

Save persists the registry to its state path. Atomic via temp+rename so a crash mid-write doesn't leave a half-formed JSON. Idempotent — if dirty=false, no I/O happens.

func (*Registry) SaveAsync added in v0.22.39

func (r *Registry) SaveAsync()

SaveAsync runs Save on a background goroutine and tracks it on r.saves so test cleanup paths can drain in-flight writes via WaitForSaves before the state path's directory is removed. Use instead of `go r.Save()` from any handler that may execute under a t.TempDir-rooted state path.

func (*Registry) SendTo added in v0.22.36

func (r *Registry) SendTo(peerID string, msg Message) Message

SendTo enqueues `msg` into peerID's inbox. Returns the assigned message (with ID + timestamp). Caller must have validated peerID exists in the registry — the inbox creates lazily, so this would happily accept messages for a non-existent peer otherwise.

func (*Registry) WaitForSaves added in v0.22.39

func (r *Registry) WaitForSaves()

WaitForSaves blocks until every SaveAsync goroutine in flight has finished its atomicfile write. Tests using t.TempDir for the state path call this before letting the cleanup hook RemoveAll the dir; otherwise macOS's stricter unlinkat fails with "directory not empty" on the still-pending temp file.

type SecurityScheme

type SecurityScheme struct {
	Type        string `json:"type"`             // "http" | "oauth2" | "apiKey"
	Scheme      string `json:"scheme,omitempty"` // for http: "bearer" / "basic"
	Description string `json:"description,omitempty"`
}

SecurityScheme mirrors A2A's auth-scheme block. We expose only the fields clawtool's phase 2 will actually populate; A2A's full spec covers OAuth 2.1, mTLS, API key, etc.

type Skill

type Skill struct {
	ID          string   `json:"id"`
	Name        string   `json:"name"`
	Description string   `json:"description"`
	Tags        []string `json:"tags,omitempty"`
	// Examples are short prompts a peer can use to test the
	// skill. Optional. Keep them representative, not
	// exhaustive — the card is a contract, not a tutorial.
	Examples []string `json:"examples,omitempty"`
}

Skill is one coarse ability the agent advertises. A2A treats skills as the discovery primitive — a peer scanning a roster of cards looks at skill IDs to decide who can help.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL