Documentation
¶
Overview ¶
Package attach implements live-tail + inject over HTTP/SSE for headless core-agent deployments. See docs/attach-mode-design.md.
Server side (agent process):
reg := attach.NewSessionRegistry()
ag, _ := agent.New(m, agent.WithSessionRegistry(reg), ...)
srv, _ := attach.NewServer(reg, attach.Options{
Addr: ":7777",
TLSCert: "/etc/certs/server.crt",
TLSKey: "/etc/certs/server.key",
ClientCA: "/etc/certs/ca.crt", // mTLS
ReadOnly: false,
})
go srv.ListenAndServe()
Client side (operator on a laptop, or another binary):
core-agent ls https://pod-ip:7777 core-agent attach https://pod-ip:7777/sessions/<app>/<sid>
The protocol is HTTP + Server-Sent Events. Four endpoints:
GET /sessions list active sessions GET /sessions/<app>/<sid>/events?since=N SSE event stream POST /sessions/<app>/<sid>/inject queue an inbox message POST /sessions/<app>/<sid>/wake wake a deferred subagent
URL shortcut: /sessions/<sid> works when <sid> is unambiguous across registered apps. Returns 409 with a helpful message on collision.
Index ¶
- Constants
- Variables
- func DecisionFromWire(s string) (permissions.Decision, bool)
- func RenderUsage(info UsageInfo) string
- type AgentCardConfig
- type AgentCardProvider
- type AgentCardSkill
- type AgentInfo
- type AgentRegistrarAdapter
- type AgentsProvider
- type ApprovalInfo
- type AuthConfig
- type Broadcaster
- type BroadcasterPool
- type Capabilities
- type CheckpointRequest
- type CheckpointResponse
- type CheckpointSlashProvider
- type CompactRequest
- type CompactResponse
- type CompactSlashProvider
- type ContextInfo
- type ContextProvider
- type DescriptionProvider
- type DigestMethodsInfo
- type DigestSavingsInfo
- type EmitTarget
- type Entry
- type ErrNotRegistrant
- type Frame
- type InboxEvent
- type InterruptProvider
- type MCPInfo
- type MCPProvider
- type MCPServerInfo
- type MCPToolInfo
- type MemoryProvider
- type MemorySource
- type ModelPricing
- type OperatorView
- func (o *OperatorView) AttachMCP() MCPInfo
- func (o *OperatorView) AttachMemory() []MemorySource
- func (o *OperatorView) AttachPricing() PricingInfo
- func (o *OperatorView) AttachRefreshPricing(ctx context.Context) (PricingRefreshResponse, error)
- func (o *OperatorView) AttachReload(ctx context.Context) ReloadResponse
- func (o *OperatorView) AttachSetManualPricing(req PricingSetRequest) error
- func (o *OperatorView) AttachSkills() []SkillInfo
- type Options
- type PatternsRequest
- type Peer
- type PeerClient
- func (c *PeerClient) Deregister(ctx context.Context, registrationID string) error
- func (c *PeerClient) Heartbeat(ctx context.Context, registrationID string) (*Peer, error)
- func (c *PeerClient) Register(ctx context.Context, req RegisterRequest) (*Peer, error)
- func (c *PeerClient) RegisterAndHeartbeat(ctx context.Context, req RegisterRequest) (cancel func(), err error)
- type PeerClientOption
- type PeerRegistry
- func (r *PeerRegistry) Close() error
- func (r *PeerRegistry) Deregister(id string)
- func (r *PeerRegistry) Heartbeat(id string) (*Peer, error)
- func (r *PeerRegistry) Len() int
- func (r *PeerRegistry) List(labelMatch map[string]string) []*Peer
- func (r *PeerRegistry) Prune() int
- func (r *PeerRegistry) Register(req RegisterRequest) (*Peer, error)
- type PeerRegistryOption
- type PermsController
- type PermsInfo
- type PermsProvider
- type PricingController
- type PricingInfo
- type PricingProvider
- type PricingRefreshResponse
- type PricingSetRequest
- type PromptBroker
- func (b *PromptBroker) AskApproval(ctx context.Context, req permissions.PromptRequest) (permissions.Decision, error)
- func (b *PromptBroker) Close()
- func (b *PromptBroker) Pending() []PromptFrame
- func (b *PromptBroker) Respond(id string, decision permissions.Decision) error
- func (b *PromptBroker) Subscribe(ctx context.Context) (<-chan PromptFrame, func())
- type PromptBrokerProvider
- type PromptFrame
- type PromptResponse
- type RegisterRequest
- type Registrant
- type ReloadResponse
- type Reloader
- type ReplanProvider
- type ReplanRequest
- type ReplanResponse
- type Server
- type SessionACLRow
- type SessionACLStore
- type SessionFactory
- type SessionRegistry
- func (r *SessionRegistry) EvictBefore(cutoff time.Time) int
- func (r *SessionRegistry) HardDelete(ctx context.Context, appName, userID, sessionID string) error
- func (r *SessionRegistry) Len() int
- func (r *SessionRegistry) List() []*Entry
- func (r *SessionRegistry) ListAuthorized(c auth.Caller) []*Entry
- func (r *SessionRegistry) Lookup(ctx context.Context, appName, sessionID string) (*Entry, error)
- func (r *SessionRegistry) LookupSingle(ctx context.Context, sessionID string) (*Entry, error)
- func (r *SessionRegistry) Register(ag Registrant) (*Entry, error)
- func (r *SessionRegistry) RegisterOwned(ag Registrant, owner string) (*Entry, error)
- func (r *SessionRegistry) RegisterOwnedWithCancel(ag Registrant, owner string, cancelOnEvict context.CancelFunc) (*Entry, error)
- func (r *SessionRegistry) SweepIdle(ctx context.Context, idleAfter time.Duration)
- func (r *SessionRegistry) TouchEntry(appName, sessionID string)
- func (r *SessionRegistry) Unregister(appName, userID, sessionID string)
- func (r *SessionRegistry) WithResumer(resumer SessionResumer) *SessionRegistry
- type SessionResumer
- type SideQueryProvider
- type SideQueryRequest
- type SideQueryResponse
- type SkillInfo
- type SkillsProvider
- type StatusInfo
- type StatusProvider
- type StatusUpdate
- type SubagentBudget
- type SubagentSpawnResponse
- type SubagentSpawner
- type SubagentSpec
- type ToolInfo
- type ToolsProvider
- type TurnComplete
- type TurnError
- type UsageByModel
- type UsageInfo
- type UsageLastTurn
- type UsageProvider
- type UsageTotals
- type UsageTurn
- type UsageUpdate
Constants ¶
const ( EventCapabilities = "capabilities" EventStatusUpdate = "status-update" EventUsageUpdate = "usage-update" EventInbox = "inbox" EventTurnComplete = "turn-complete" EventTurnError = "turn-error" // EventAgent is the legacy event type carrying ADK session.Event // payloads (stream-chunk / tool-call / tool-result are all // multiplexed onto this one event today). Kept for back-compat // indefinitely — Phase 1 clients in poll mode rely on it, and // even push-mode clients still consume it for the model's // streamed text output. EventAgent = "agent" )
SSE event-type names per the protocol spec (section 2).
const ( TurnStateIdle = "idle" TurnStateStreaming = "streaming" TurnStateAwaitingPermission = "awaiting_permission" TurnStateAwaitingElicit = "awaiting_elicit" )
Turn-state values per spec section 2.2.
const ( InboxStateQueued = "queued" InboxStateDequeued = "dequeued" )
Inbox states per spec section 2.4. The spec reserves room for future states (e.g. "injected"); consumers tolerate unknown values.
const ( TurnErrorConfig = "config_error" TurnErrorAuth = "auth_error" TurnErrorModelNotFound = "model_not_found" TurnErrorRateLimited = "rate_limited" TurnErrorTransientNet = "transient_network" // TurnErrorCostCeiling fires when a configured per-turn or // per-session cost ceiling is exceeded (#145). Agent refuses new // turns until the operator calls ResetCostCeiling on the agent // (typically via a slash command). Retryable=false on this kind // — the host should surface the message + halt automated retry. TurnErrorCostCeiling = "cost_ceiling" TurnErrorUnknown = "unknown" )
TurnError kinds per spec section 2.6. Consumers MUST treat unknown values as TurnErrorUnknown (forward-compat for new categories).
const ( ToolSourceBuiltin = "builtin" ToolSourceMCP = "mcp" ToolSourceSkill = "skill" ToolSourceOther = "other" )
Tool source classifications surfaced via GET /sessions/.../tools. Bare strings (not a typed enum) so JSON clients downstream — the TUI, an eventual WebUI, operator scripts — don't have to know a Go type to reason about them.
const ( AgentStateRunning = "running" AgentStateDeferred = "deferred" AgentStatePaused = "paused" AgentStateIdle = "idle" )
Agent run-states surfaced via GET /sessions/.../status. "running" covers any active turn; "deferred" means the scheduler is sleeping the agent until NextWakeAt; "paused" means the autonomous loop was explicitly paused (future, via /pause); "idle" means the agent is alive but not currently turning.
const AgentCardFileName = "agent-card.json"
AgentCardFileName is the filename inside .agents/ that the bundled core-agent binary reads to populate AgentCardConfig. Embedders building their own daemon populate Options.AgentCard directly and don't need this file at all.
const (
DefaultHeartbeatTTL = 60 * time.Second
)
Default TTL bounds. See docs/peer-registration-design.md "TTL + heartbeat policy" for the rationale.
const HeaderAttachToken = "X-Attach-Token" // #nosec G101 -- header name, not a credential
HeaderAttachToken is the side-channel header callers can use to present the attach token when an identity gateway (Cloud Run IAM, IAP, Cloudflare Access, etc.) owns the Authorization header for its own validation. Checked before Authorization: Bearer so a request carrying both gets evaluated against this header first.
const ProtocolVersion = "1.2.0"
ProtocolVersion is the SSE event-stream protocol semver this server speaks. Bumped on any change to the contract per go-steer/core-tui's docs/sse-event-stream-protocol.md. Clients fall back to poll-only mode if their major doesn't match.
v1.1.0 (core-tui#42): turn-complete.cost_usd demoted from required to optional with documented fallback semantics (the immediately- following usage-update carries authoritative cost). This server emits TurnComplete with CostUSD = nil so the field is omitted from the wire entirely — the "cost deferred" signal is explicit.
v1.2.0 (#277): tool-result response payloads now carry a `latency_ms` sidecar (int64, milliseconds) reporting the wall- clock time spent in the upstream tool call. Additive — consumers on older schema versions simply don't see the field. Populated by both the MCP digest wrap (pkg/mcp/digest_wrap.go) and the plain rename passthrough (pkg/mcp/namespace.go), so operators see per-call timing whether digest is enabled or not.
Variables ¶
var ErrAmbiguousSession = errors.New("attach: session id is ambiguous across registered apps; use the /sessions/<app>/<sessionID> form")
ErrAmbiguousSession is returned by LookupSingle when more than one registered session shares the same SessionID across different apps — the caller must use the qualified two-segment form.
var ErrCapabilityNotRegistered = errors.New("attach: capability not registered on this OperatorView")
ErrCapabilityNotRegistered is returned by mutation-capability methods on OperatorView when the corresponding func field is nil. Handlers check for this with errors.Is and convert to HTTP 501 so operators see "capability not registered" instead of a stack trace.
Reads use the empty-result convention instead (200 with zero data when the func is nil) — operators who hit a POST need to know if it took effect, while readers can accept "nothing here" silently.
var ErrPeerEndpointRequired = errors.New("attach: peer Endpoint is required")
ErrPeerEndpointRequired is returned when RegisterRequest.Endpoint is empty.
var ErrPeerNameRequired = errors.New("attach: peer Name is required")
ErrPeerNameRequired is returned when RegisterRequest.Name is empty.
var ErrPeerNotFound = errors.New("attach: peer registration not found")
ErrPeerNotFound is returned when Lookup / Heartbeat / Deregister can't find the registration ID.
var ErrPromptNotFound = errors.New("attach: prompt id not found (already responded, cancelled, or never issued)")
ErrPromptNotFound is returned by Respond when the id doesn't match a live pending prompt.
var ErrSessionACLNotFound = errors.New("attach: session ACL not found")
ErrSessionACLNotFound is returned by SessionACLStore.Get when no row exists for the triple. Distinct from a SQL error so callers can branch cleanly (404 vs 500 in the resumer).
var ErrSessionExists = errors.New("attach: session already registered")
ErrSessionExists is returned by Register when the (app, user, sid) triple is already registered.
var ErrSessionNotFound = errors.New("attach: session not found")
ErrSessionNotFound is returned by Lookup / Unregister when no matching entry exists.
var SupportedEventTypes = []string{ EventStatusUpdate, EventUsageUpdate, EventInbox, EventTurnComplete, EventTurnError, "stream-chunk", "tool-call", "tool-result", }
SupportedEventTypes lists every event type this server emits. Surfaced in the Capabilities event on stream open so consumers can detect push-mode support without probing. The list includes legacy sub-types (stream-chunk / tool-call / tool-result) even though they all ride on EventAgent today — the consumer cares about the logical surface, not the SSE event name they currently share.
Functions ¶
func DecisionFromWire ¶
func DecisionFromWire(s string) (permissions.Decision, bool)
DecisionFromWire maps the wire-format decision string back to the permissions.Decision enum. Returns false if the string isn't one of the documented values.
func RenderUsage ¶
RenderUsage projects a UsageInfo into the plain-text block the TUI's custom /usage slash prints. Lives in package attach rather than in a TUI-specific package so both the embedded (cmd/core-agent) and remote (internal/coretuiremote) adapters render identical output — this is the operator-facing view of GET /sessions/<id>/usage.
Layout (three sections; sections with nothing to say are omitted):
Session totals <turns> turns · <in>/<cached>/<uncached> in tokens · <out> out cost $<actual> (uncached ref $<ref> → cache saved $<delta>) Per model <name>: <turns> turns · in <in> (<cached> cached) · $<cost> Per turn #<n> <hh:mm:ss> <model> · in <in> (<cached> cached) · out <out> · $<cost>
Numbers use grouping commas + fixed 4-decimal dollar amounts to match the existing /stats formatting so operators can eyeball the two side-by-side without unit-conversion friction.
Types ¶
type AgentCardConfig ¶
type AgentCardConfig struct {
// Name is the human-readable agent name. Defaults to the first
// registered Registrant's AppName, else "core-agent".
Name string
// Description is required to enable the endpoint. The only piece
// of card metadata that can't be auto-derived from the running
// process — the operator has to say in one sentence what the
// agent does.
Description string
// ExternalURL, when set, is the literal value emitted as the
// card's `url` field — overrides the per-request derivation.
// Use when the binary serves on multiple addresses but you want
// consumers to see one canonical URL; otherwise leave empty and
// the handler echoes the fetch URL back.
ExternalURL string
// Version is the agent's own version string. Defaults to
// internal/version.Version (the ldflag-injected build version).
Version string
// Provider, when set, must have both Organization and URL — the
// A2A spec marks both as required if provider is present.
Provider AgentCardProvider
// DocumentationURL is optional; omitted from the card if empty.
DocumentationURL string
// ExtraSkills are curated skills merged with the registrant's
// AttachSkills() output. Curated entries win on ID collision.
ExtraSkills []AgentCardSkill
}
AgentCardConfig configures the /.well-known/agent-card.json endpoint. Zero value disables it; setting Description enables it.
The card's `url` field is derived from the request that fetched it (Host header + X-Forwarded-Proto/Host for proxied setups), so the operator doesn't have to know their own external address. ExternalURL is an optional override for the rare case of wanting to publish a canonical URL different from the fetch URL.
The card serves discovery metadata (e.g. Google Cloud Agent Registry indexes the skills array for keyword search) — it does NOT imply that the binary speaks the A2A JSON-RPC transport. See docs/agent-card-design.md.
func LoadAgentCardFile ¶
func LoadAgentCardFile(path string) (AgentCardConfig, bool, error)
LoadAgentCardFile reads the on-disk card config at path and returns the equivalent AgentCardConfig. A missing path is not an error — the returned config is the zero value (endpoint disabled) and the second return value is false.
Malformed JSON or an unknown envelope version is a startup error: the file's purpose is the public-discovery surface, and silently disabling the endpoint on misconfig is worse than failing closed.
A file that only sets extra_skills (no description) is also rejected — the loader refuses to treat the file as a side-channel skill library. external_url is optional (the card handler derives the URL from each request); set it only when overriding the fetch-URL with a canonical alternative.
func (AgentCardConfig) Enabled ¶
func (c AgentCardConfig) Enabled() bool
Enabled reports whether the endpoint should be registered. Description is the only required field — the URL is derived from each incoming request (overridable via ExternalURL). When Description is empty in the config, the handler still attempts a registry-based DescriptionProvider fallback at request time, but the route only registers if the config itself supplies a value — callers that want the registry fallback to gate the endpoint should set Description from their registrant's Description() at construction time.
func (AgentCardConfig) Validate ¶
func (c AgentCardConfig) Validate() error
Validate rejects half-populated nested fields. Zero AgentCardConfig is valid (endpoint disabled). Provider, if present at all, must have both Organization and URL per the A2A spec.
type AgentCardProvider ¶
AgentCardProvider mirrors the A2A AgentProvider definition.
type AgentCardSkill ¶
type AgentCardSkill struct {
ID string
Name string
Description string
Tags []string // defaults to ["curated"] if empty for curated entries
Examples []string
}
AgentCardSkill mirrors the A2A AgentSkill definition.
type AgentInfo ¶
type AgentInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // running | done | failed | paused
StartedAt time.Time `json:"started_at"`
ParentSessionID string `json:"parent_session_id,omitempty"`
LastReport string `json:"last_report,omitempty"` // most recent report body, truncated
}
AgentInfo is one background subagent the parent agent knows about, surfaced via GET /sessions/.../agents. Populated from the BackgroundAgentManager when one is wired; empty list otherwise.
type AgentRegistrarAdapter ¶
type AgentRegistrarAdapter struct {
// contains filtered or unexported fields
}
AgentRegistrarAdapter satisfies agent.attachRegistrar (an interface defined in package agent with method set `Register(agent.Registrant) (agent.RegisterEntry, error)` + Unregister) using a *SessionRegistry from this package. We keep the indirection because agent/ cannot import attach/ (would create a cycle — attach/ depends on agent's *Agent via the Registrant shape).
agent.Registrant and attach.Registrant have the same method set, so any *agent.Agent already satisfies the attach.Registrant we need internally. The adapter just relabels the registry's typed return for the agent package's RegisterEntry alias.
Typical wiring in a consumer binary:
reg := attach.NewSessionRegistry()
ag, err := agent.New(m,
agent.WithSessionRegistry(attach.NewAgentRegistrarAdapter(reg)),
// ...
)
func NewAgentRegistrarAdapter ¶
func NewAgentRegistrarAdapter(reg *SessionRegistry) *AgentRegistrarAdapter
NewAgentRegistrarAdapter wraps reg so it satisfies agent.attachRegistrar.
func (*AgentRegistrarAdapter) Register ¶
func (a *AgentRegistrarAdapter) Register(ag any) (any, error)
Register accepts any (called from agent/, which can't import attach/ for Registrant), type-asserts to Registrant, and forwards to the wrapped registry. Returns ErrNotRegistrant if the type assertion fails.
func (*AgentRegistrarAdapter) Unregister ¶
func (a *AgentRegistrarAdapter) Unregister(appName, userID, sessionID string)
Unregister forwards to the registry.
type AgentsProvider ¶
type AgentsProvider interface {
AttachAgents() []AgentInfo
}
AgentsProvider is the optional capability for GET /sessions/.../agents. Returns the background subagents tracked by the registrant's BackgroundAgentManager (if any).
type ApprovalInfo ¶
type ApprovalInfo struct {
Tool string `json:"tool"`
Key string `json:"key,omitempty"`
Decision string `json:"decision"` // "allow-once" | "allow-session" | etc.
At time.Time `json:"at"`
}
ApprovalInfo is one row in the per-session approval log. Mirrors permissions.ApprovalLog in a JSON-friendly shape.
type AuthConfig ¶
type AuthConfig struct {
// TLSCertFile / TLSKeyFile enable HTTPS on the listener. Required
// together. Files are loaded once at server start.
TLSCertFile string
TLSKeyFile string
// ClientCAFile, when set alongside TLSCertFile/TLSKeyFile,
// enables mTLS: clients must present a cert signed by this CA.
// If empty, the server is HTTPS-only (server auth, no client
// auth) — clients then rely on BearerToken (or no auth) for
// authorization.
ClientCAFile string
// BearerToken, when non-empty, requires every request to carry
// Authorization: Bearer <token>. Compared in constant time.
// Works alongside mTLS; both must pass if both are configured.
BearerToken string
// ReadOnly disables every write endpoint (POST /inject, POST
// /wake) regardless of auth. Read endpoints (GET /sessions,
// GET /events) stay open. Useful for read-only mirrors.
ReadOnly bool
}
AuthConfig describes how the attach server authenticates clients. Zero value (all fields empty) accepts everything — only safe over a Unix socket or other already-trusted transport. Per the design doc: mTLS is the primary auth; bearer-token is the fallback for when you don't have cert infrastructure handy.
func (AuthConfig) LoadTLSConfig ¶
func (a AuthConfig) LoadTLSConfig() (*tls.Config, error)
LoadTLSConfig builds a *tls.Config from the AuthConfig's TLS material. Returns nil (no TLS) when neither TLSCertFile nor TLSKeyFile is set. Returns an error if exactly one is set, or if the files can't be read.
func (AuthConfig) Middleware ¶
func (a AuthConfig) Middleware(next http.Handler) http.Handler
Middleware returns an http.Handler that wraps next with attach-token validation + ReadOnly enforcement. mTLS, if configured, is enforced by the TLS handshake itself (ClientAuth = RequireAndVerifyClientCert) so by the time a request reaches this middleware, the cert has already been validated.
Token validation accepts the attach token from either of two headers, checked in order:
- X-Attach-Token — the side-channel header for gateway-fronted deploys. If present but doesn't match, returns 401 immediately (no fall-through to Authorization); this matches operator intent: "I explicitly sent this; tell me if it's wrong."
- Authorization: Bearer <token> — the default path for direct attach (local, GKE internal LB, anywhere the operator owns the Authorization header).
Returns 401 Unauthorized on missing/wrong token; 403 Forbidden when ReadOnly + the request is a write.
type Broadcaster ¶
type Broadcaster struct {
// contains filtered or unexported fields
}
Broadcaster owns one goroutine per session that pumps events from eventlog.Stream.Watch into N subscriber channels. Subscribers can join any time; replay-then-tail is handled via the since parameter.
One Broadcaster per Entry. Lazily created on first Subscribe and torn down when the last subscriber leaves (refcount).
func NewBroadcaster ¶
func NewBroadcaster(entry *Entry) (*Broadcaster, error)
NewBroadcaster constructs a broadcaster for one registered session. The pump goroutine is NOT started until the first Subscribe — we don't want background goroutines for sessions nobody's watching.
func (*Broadcaster) Close ¶
func (b *Broadcaster) Close()
Close cancels the pump goroutine and closes every subscriber channel. Idempotent. Called from Server.Close.
func (*Broadcaster) Emit ¶
func (b *Broadcaster) Emit(eventType string, payload any)
Emit pushes a typed event to every current subscriber. Non-blocking per-subscriber: a subscriber whose buffer is full gets dropped (its channel is closed), same drop-the-subscriber policy as the legacy frame path.
Emit is the entry point for every event type defined in the SSE event-stream protocol spec — agent lifecycle hooks, perm-mode mutations, inbox queue/dequeue, and the usage tracker all call here when something happens that needs to reach the operator.
Safe to call concurrently from any goroutine.
func (*Broadcaster) Subscribe ¶
func (b *Broadcaster) Subscribe(ctx context.Context, since int64) <-chan Frame
Subscribe adds a new client and returns its frame channel. Replays every frame with seq > since before switching to live-tail (which is invisible to the caller; same channel).
The returned channel is closed when:
- ctx is cancelled (typical: HTTP request ends), OR
- the subscriber falls behind subscriberBufferSize frames (the drop-the-subscriber decision; better than stalling everyone).
Caller MUST drain the channel until close to release goroutine resources.
type BroadcasterPool ¶
type BroadcasterPool struct {
// contains filtered or unexported fields
}
BroadcasterPool lazily constructs and tracks one Broadcaster per Entry. Server uses this so multiple SSE clients for the same session share one pump goroutine.
func NewBroadcasterPool ¶
func NewBroadcasterPool() *BroadcasterPool
NewBroadcasterPool returns an empty pool.
func (*BroadcasterPool) Close ¶
func (p *BroadcasterPool) Close()
Close stops every broadcaster in the pool. Used by Server.Close.
func (*BroadcasterPool) For ¶
func (p *BroadcasterPool) For(entry *Entry) (*Broadcaster, error)
For returns a Broadcaster for entry, constructing it on first use. Returns an error when the entry's agent has no eventlog (attach requires it).
func (*BroadcasterPool) Remove ¶
func (p *BroadcasterPool) Remove(entry *Entry) *Broadcaster
Remove pulls the broadcaster for entry out of the pool and returns it, without lazily constructing one on miss. Returns nil when no broadcaster exists (e.g. a session with no subscribers this session). Callers should Close() the returned broadcaster to disconnect active subscribers — used by DELETE /sessions to force-hang up SSE clients streaming the deleted session.
type Capabilities ¶
type Capabilities struct {
ProtocolVersion string `json:"protocol_version"`
EventTypes []string `json:"event_types"`
Server string `json:"server,omitempty"`
}
Capabilities is the first frame on every newly-opened stream (spec section 2.1). Required so clients can decide push vs poll.
type CheckpointRequest ¶
type CheckpointRequest struct {
Note string `json:"note,omitempty"`
}
CheckpointRequest is the POST body for /slash/done. Note is the optional task-note the operator typed after `/done <note>`. Empty when the operator didn't supply one (the checkpointer can derive a default).
type CheckpointResponse ¶
type CheckpointResponse struct {
CheckpointEventID string `json:"checkpoint_event_id,omitempty"`
SummaryText string `json:"summary_text,omitempty"`
TaskNote string `json:"task_note,omitempty"`
DurationMS int64 `json:"duration_ms"`
Skipped bool `json:"skipped,omitempty"`
}
CheckpointResponse is the response shape of POST /slash/done.
type CheckpointSlashProvider ¶
type CheckpointSlashProvider interface {
AttachCheckpoint(ctx context.Context, note string) (CheckpointResponse, error)
}
CheckpointSlashProvider is the optional capability for POST /sessions/.../slash/done.
type CompactRequest ¶
type CompactRequest struct {
Focus string `json:"focus,omitempty"`
}
CompactRequest is the POST body for /slash/compact. Focus is the optional steer text the operator typed after `/compact <focus>` (e.g. "preserve the test failures"). Empty for a default-focus run.
type CompactResponse ¶
type CompactResponse struct {
SummaryEventID string `json:"summary_event_id,omitempty"`
SummaryText string `json:"summary_text,omitempty"`
DurationMS int64 `json:"duration_ms"`
Skipped bool `json:"skipped,omitempty"`
}
CompactResponse is the response shape of POST /slash/compact. Mirrors the agent.CompactionResult fields the remote TUI needs to render the post-compaction confirmation row.
type CompactSlashProvider ¶
type CompactSlashProvider interface {
AttachCompact(ctx context.Context, focus string) (CompactResponse, error)
}
CompactSlashProvider is the optional capability for POST /sessions/.../slash/compact.
type ContextInfo ¶
type ContextInfo struct {
Compactions int `json:"compactions"`
Checkpoints int `json:"checkpoints"`
LastTaskNote string `json:"last_task_note,omitempty"`
TotalCharsSummarized int `json:"total_chars_summarized"`
SubtaskTurns int `json:"subtask_turns"`
SubtaskInputTokens int64 `json:"subtask_input_tokens"`
SubtaskOutputTokens int64 `json:"subtask_output_tokens"`
SubtaskCostUSD float64 `json:"subtask_cost_usd"`
// DigestSavings surfaces the MCP wrap's cumulative effect (#223).
// Zero-valued when the wrap layer never fired this session (no
// MCP servers, wrap disabled, or every response was under the
// threshold). Structural + agentic counts break out separately
// because their cost math differs — see agent.ContextStats /
// usage.DigestSavingsTotals for details.
DigestSavings *DigestSavingsInfo `json:"digest_savings,omitempty"`
}
ContextInfo is the response shape of GET /sessions/.../context. Backs the remote TUI's /context slash. Mirrors agent.ContextStats but with json tags + a fixed scalar shape so the wire format is stable across agent-package refactors.
type ContextProvider ¶
type ContextProvider interface {
AttachContext() ContextInfo
}
ContextProvider is the optional capability for GET /sessions/.../context.
type DescriptionProvider ¶
type DescriptionProvider interface {
Description() string
}
DescriptionProvider is the optional capability the agent-card handler consults when AgentCardConfig.Description is empty. Returns a one-line summary of what the agent does — fed by the same source as ADK's llmagent.Config.Description, so the operator writes it once and it flows to both the LLM's system prompt and the public discovery card.
type DigestMethodsInfo ¶
type DigestMethodsInfo struct {
Counts map[string]int64 `json:"counts,omitempty"`
BytesSaved map[string]int64 `json:"bytes_saved,omitempty"`
}
DigestMethodsInfo carries the pkg/digest telemetry snapshot in the /usage response. Counts is calls-per-method; BytesSaved is the cumulative byte reduction (raw - digest) accrued per method. Passthrough always contributes 0 to BytesSaved by definition.
type DigestSavingsInfo ¶
type DigestSavingsInfo struct {
StructuralCalls int `json:"structural_calls"`
StructuralTokensSaved int64 `json:"structural_tokens_saved"`
AgenticCalls int `json:"agentic_calls"`
AgenticTokensSaved int64 `json:"agentic_tokens_saved"`
AgenticSubagentInTokens int64 `json:"agentic_subagent_input_tokens"`
AgenticSubagentOutTokens int64 `json:"agentic_subagent_output_tokens"`
AgenticSubagentCostUSD float64 `json:"agentic_subagent_cost_usd"`
PassthroughCalls int `json:"passthrough_calls"`
}
DigestSavingsInfo is the wire-format view of one session's cumulative MCP digest-wrap savings. Nil on ContextInfo when the session has recorded no digest-wrap activity. Broken out so remote TUI renderers pick out structural vs. agentic without recomputing.
type EmitTarget ¶
EmitTarget is the optional capability a Registrant can implement so the broadcaster wires its Emit method as the agent's typed-event callback at first-subscriber time, and clears it when the last subscriber disconnects.
Agents that don't implement EmitTarget still get the legacy `event: agent` frames pumped from the eventlog (back-compat with every poll-mode client) — they just won't emit typed events (capabilities still fires from the broadcaster directly, and the snapshot frames still flow because they read agent state via StatusProvider / UsageProvider, not via Emit).
type Entry ¶
type Entry struct {
AppName string
UserID string
SessionID string
// Agent is the live registrant. The registry holds it by
// reference; lifetime is the registrant's, not the registry's.
Agent Registrant
// ACL governs which Callers may interact with this session in a
// multi-session deployment. Zero value (empty Owner / nil slices)
// means "no owner" — only Admin Callers may access it, which is
// the documented behavior for legacy sessions registered via
// Register (vs. RegisterOwned). See
// docs/multi-session-design.md §"Migration story".
ACL auth.SessionACL
// contains filtered or unexported fields
}
Entry is one registered session as the registry sees it.
func (*Entry) LastTouchedAt ¶
LastTouchedAt returns the entry's last-touched wall time. Safe for concurrent access. Zero time if the entry has never been touched (which shouldn't happen — registration always seeds it).
type ErrNotRegistrant ¶
type ErrNotRegistrant struct{ Got any }
ErrNotRegistrant is returned by Register when the supplied agent doesn't satisfy attach.Registrant. Practically this can't happen when *agent.Agent is the consumer (it implements all the methods); the check is defensive against future agent constructor refactors.
func (*ErrNotRegistrant) Error ¶
func (e *ErrNotRegistrant) Error() string
type Frame ¶
type Frame struct {
Seq int64 `json:"seq"`
Event *session.Event `json:"event"`
Type string `json:"-"`
TypedData any `json:"-"`
}
Frame is one item the SSE stream emits. Two shapes carried in one struct, distinguished by whether Type is set:
Legacy form (Type == ""): carries an eventlog seq + ADK session.Event. The writer emits this as `event: agent` with the JSON Frame as the data block (back-compat for poll-mode clients and any consumer of the legacy stream).
Typed form (Type != ""): carries a protocol event type and a payload that gets marshaled directly. The writer emits this as `event: <Type>` with JSON(TypedData) as the data block. Used for every event defined in the SSE event-stream protocol spec (capabilities, status-update, usage-update, inbox, turn-complete, turn-error).
Type and TypedData carry the `json:"-"` tag so they never appear in the legacy frame's serialized form — the writer handles them out-of-band based on the Type discriminator.
type InboxEvent ¶
type InboxEvent struct {
State string `json:"state"`
PromptID string `json:"prompt_id"`
QueuedAt time.Time `json:"queued_at,omitempty"`
}
InboxEvent fires when an operator-typed prompt changes inbox state. PromptID is the correlation handle that links this event to downstream turn-complete / turn-error for the same turn.
type InterruptProvider ¶
type InterruptProvider interface {
AttachInterrupt() bool
}
InterruptProvider is the optional capability for POST /sessions/.../interrupt. Returns true if there was an in-flight turn to cancel, false if the agent was idle (no-op). Agents that don't implement it get an HTTP 412 from the /interrupt handler — interrupt is a write to agent state, and silently no-op'ing would mislead operators about whether their intent took effect.
type MCPInfo ¶
type MCPInfo struct {
Servers []MCPServerInfo `json:"servers"`
}
MCPInfo is the response shape of GET /sessions/.../mcp — backs the remote TUI's /mcp slash. Each Server carries its lifecycle status plus the tools it exposes.
type MCPProvider ¶
type MCPProvider interface {
AttachMCP() MCPInfo
}
MCPProvider is the optional capability for GET /sessions/.../mcp.
type MCPServerInfo ¶
type MCPServerInfo struct {
Name string `json:"name"`
Status string `json:"status"` // "running" | "starting" | "failed" | "stopped"
Transport string `json:"transport"` // "stdio" | "http"
Tools []MCPToolInfo `json:"tools,omitempty"`
}
MCPServerInfo describes one declared MCP server.
type MCPToolInfo ¶
type MCPToolInfo struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
MCPToolInfo describes one tool exposed by an MCP server.
type MemoryProvider ¶
type MemoryProvider interface {
AttachMemory() []MemorySource
}
MemoryProvider is the optional capability for GET /sessions/.../memory.
type MemorySource ¶
type MemorySource struct {
Scope string `json:"scope"` // "user-global" | "project"
Path string `json:"path"`
Size int `json:"size"`
}
MemorySource is one row in GET /sessions/.../memory — backs the remote TUI's /memory slash. Mirrors instruction.Source.
type ModelPricing ¶
type ModelPricing struct {
InputUSDPerMTok float64 `json:"input_usd_per_mtok"`
OutputUSDPerMTok float64 `json:"output_usd_per_mtok"`
CachedUSDPerMTok float64 `json:"cached_usd_per_mtok,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
ModelPricing describes one model's rate breakdown.
UpdatedAt records when the rate was last verified against its provider — LiteLLM refresh time for external entries, generator run time for builtin entries, operator edit time for manual overrides. Zero when unknown. Surfaced via GET /sessions/.../pricing so operators can spot stale rates. The catalog-layer attribution ("which source served this rate") lives on the enclosing PricingInfo.Source, not here — one field per snapshot avoids implying the ModelPricing block would carry per-entry sources if PricingInfo ever grows to return multiple models.
type OperatorView ¶
type OperatorView struct {
Registrant
Memory func() []MemorySource
Skills func() []SkillInfo
MCP func() MCPInfo
Pricing func() PricingInfo
// PR A2 (mutation endpoints) func fields. nil means the
// corresponding POST returns 501 (capability not registered).
RefreshPricing func(ctx context.Context) (PricingRefreshResponse, error)
SetPricing func(req PricingSetRequest) error
Reload func(ctx context.Context) ReloadResponse
}
OperatorView wraps a base Registrant (typically *agent.Agent) with the caller-held operator-display state — instruction memory, skill bundles, MCP servers, pricing snapshot. Library callers construct one and register THAT instead of the bare agent, so the operator TUI sees /memory, /skills, /mcp, /pricing alongside /tools and /status.
Each func field is optional. A nil func means the corresponding /sessions/.../<endpoint> returns 404 (capability not registered). Pass populated snapshot funcs only for the surfaces you want exposed.
The funcs are called per-request so callers can return fresh snapshots (e.g., after /pricing refresh updates the in-memory rate table). The funcs should be cheap — they typically just project an existing in-memory snapshot into the wire shape.
Typical wiring:
view := &attach.OperatorView{
Registrant: ag,
Memory: func() []attach.MemorySource { return attach.SnapshotMemory(loadedMemory) },
Skills: func() []attach.SkillInfo { return skillsToAttachInfos(loadedSkills) },
MCP: func() attach.MCPInfo { return mcpToAttachInfo(mcpServers) },
Pricing: func() attach.PricingInfo { return pricingSnapshot(cfg) },
}
reg.Register(view)
func (*OperatorView) AttachMCP ¶
func (o *OperatorView) AttachMCP() MCPInfo
AttachMCP satisfies MCPProvider when MCP is non-nil.
func (*OperatorView) AttachMemory ¶
func (o *OperatorView) AttachMemory() []MemorySource
AttachMemory satisfies MemoryProvider when Memory is non-nil. Returns nil otherwise; the handler treats nil-result as "capability not registered" and returns 404.
func (*OperatorView) AttachPricing ¶
func (o *OperatorView) AttachPricing() PricingInfo
AttachPricing satisfies PricingProvider when Pricing is non-nil.
func (*OperatorView) AttachRefreshPricing ¶
func (o *OperatorView) AttachRefreshPricing(ctx context.Context) (PricingRefreshResponse, error)
AttachRefreshPricing satisfies PricingController. Returns ErrCapabilityNotRegistered when RefreshPricing is nil so the handler emits 501.
func (*OperatorView) AttachReload ¶
func (o *OperatorView) AttachReload(ctx context.Context) ReloadResponse
AttachReload satisfies Reloader. Returns a ReloadResponse with Errors populated by the sentinel string when Reload is nil so the handler emits 501.
func (*OperatorView) AttachSetManualPricing ¶
func (o *OperatorView) AttachSetManualPricing(req PricingSetRequest) error
AttachSetManualPricing satisfies PricingController.
func (*OperatorView) AttachSkills ¶
func (o *OperatorView) AttachSkills() []SkillInfo
AttachSkills satisfies SkillsProvider when Skills is non-nil.
type Options ¶
type Options struct {
// Registry is the SessionRegistry the server consults to resolve
// URL session IDs to live agents. Required.
Registry *SessionRegistry
// PeerRegistry, when non-nil, enables peer-registration endpoints
// (POST /peers, GET /peers, etc.) — turning this listener into a
// discovery hub for other agents. Nil means the peer endpoints
// are not registered and POST /peers returns 404. See
// docs/peer-registration-design.md.
PeerRegistry *PeerRegistry
// Auth controls TLS + bearer + read-only enforcement. Zero value
// accepts everything (safe only over a Unix socket or other
// already-trusted transport).
Auth AuthConfig
// Addr is the TCP listen address (e.g. ":7777"). Mutually
// exclusive with UnixSocket — set exactly one.
Addr string
// UnixSocket is the Unix domain socket path (e.g.
// "/var/run/core-agent.sock"). Mutually exclusive with Addr.
// When set, Auth.TLS* and Auth.BearerToken are usually omitted
// — filesystem permissions on the socket file are the auth.
UnixSocket string
// ShutdownTimeout caps how long Server.Close waits for in-flight
// SSE clients to drain. Default 5 seconds.
ShutdownTimeout time.Duration
// AgentCard, when its Description and ExternalURL are both
// non-empty, enables the unauthenticated discovery endpoint
// GET /.well-known/agent-card.json. Zero value disables the
// endpoint (404). See docs/agent-card-design.md.
AgentCard AgentCardConfig
// Authenticator resolves the per-request Caller for the
// multi-session attach layer. Nil defaults to auth.AnonymousAuth
// with DefaultCaller as the identity — every request resolves to
// the same anonymous Caller and downstream code sees a
// Caller-on-context just like in a multi-session deployment.
//
// α.1 wiring is intentionally additive: the resolved Caller is
// available via auth.CallerFromContext but no handler enforces
// authorization yet. α.2 layers enforcement on top without
// changing this field's shape. See
// docs/multi-session-design.md and issue #162.
Authenticator auth.Authenticator
// DefaultCaller is the Caller stamped onto requests when
// Authenticator is nil, or when the Authenticator returns
// ErrUnauthenticated under the α.1 no-behavior-change posture.
// Zero value resolves to auth.Anonymous.
DefaultCaller auth.Caller
// MultiSessionEnabled turns on per-session ACL enforcement in
// session-scoped handlers (Authorize against entry.ACL), 401 on
// unauthenticated requests when AllowAnonymous is false, and the
// X-Asserted-Caller proxy-header resolution path. Default false
// preserves single-user behavior end-to-end.
//
// Operators set this via config.AttachConfig.MultiSession.Enabled.
MultiSessionEnabled bool
// AllowAnonymous, when MultiSessionEnabled is true, lets requests
// without a valid credential resolve to DefaultCaller rather than
// returning 401. Dangerous in shared environments — every
// unauthenticated request becomes the same Caller. Default false
// (matches the config default).
AllowAnonymous bool
// ProxyHeader is the header name a proxy Caller uses to assert
// the effective identity. Empty defaults to
// auth.HeaderAssertedCaller ("X-Asserted-Caller"). Honored only
// when MultiSessionEnabled is true.
ProxyHeader string
// UI, when non-nil, registers a /ui/* route that serves a SPA
// bundle from the supplied filesystem (typically the
// internal/webui embedded mast-web release, or a local checkout
// via os.DirFS for development).
//
// The SPA loads same-origin against this listener so the attach
// API and the UI share auth boundary and TLS cert — no separate
// listener, no CORS allowlist required.
//
// Nil disables the route entirely (the default).
UI fs.FS
// SessionFactory, when non-nil, enables the POST /sessions
// endpoint — operators can create owned sessions on demand
// instead of being limited to the single startup-time session.
// The factory is invoked once per POST /sessions request with
// the request context and the authenticated Caller; it returns
// a fresh Registrant that the handler then registers via
// RegisterOwned(ag, caller.Identity) so the session's ACL stamps
// the creator as Owner.
//
// Nil leaves POST /sessions returning 501 — the older
// single-session deployment model where the daemon constructs
// exactly one agent at startup.
//
// Per docs/multi-session-design.md §"Open questions" #1
// (resolved 2026-06-12): explicit session-creation API is the
// preferred pattern; the factory closure lives in cmd-level
// wiring so the daemon can capture model/gate-template/tools/
// MCP/eventlog config and synthesize fresh agents under the
// same configuration.
SessionFactory SessionFactory
// Resumer, when non-nil, reconstructs sessions that exist on
// disk but not in the in-memory registry. Wired by the daemon
// from buildSessionResumer; nil leaves the legacy "miss = 404"
// behavior in place (pre-v2.5 deployments). See
// docs/session-resume-design.md.
Resumer SessionResumer
// SessionIdleTimeout, when > 0, enables the background sweep
// that evicts in-memory Entries idle longer than this duration.
// Evicted sessions remain resumable via the Resumer — eviction
// is memory-only, not delete-from-disk. Zero (the default)
// disables the sweep: sessions stay in memory until the daemon
// stops. See docs/session-resume-design.md §"Lifecycle
// primitive".
SessionIdleTimeout time.Duration
}
Options configures NewServer. Zero value is invalid — Registry is required at minimum.
type PatternsRequest ¶
type PatternsRequest struct {
Patterns []string `json:"patterns"`
}
PatternsRequest is the POST body for /perms/allow + /perms/deny. Lets the operator add one or more patterns in a single call.
type Peer ¶
type Peer struct {
RegistrationID string `json:"registration_id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Labels map[string]string `json:"labels,omitempty"`
RegisteredAt time.Time `json:"registered_at"`
LastHeartbeat time.Time `json:"last_heartbeat"`
LeaseExpiresAt time.Time `json:"lease_expires_at"`
}
Peer is one entry in the hub's PeerRegistry. Carries the registration identity, the peer's reachable endpoint, opaque labels for filtering, and the lease state for liveness tracking.
type PeerClient ¶
type PeerClient struct {
// contains filtered or unexported fields
}
PeerClient is a thin wrapper around http.Client for talking to a hub agent's peer-registration endpoints. Used by peer binaries (typically wired via cmd/core-agent's --attach-register-to flag).
Single-use lifecycle: construct, RegisterAndHeartbeat, defer Deregister. The goroutine spawned by RegisterAndHeartbeat handles the heartbeat cadence; the caller doesn't have to track it.
func NewPeerClient ¶
func NewPeerClient(hubURL string, opts ...PeerClientOption) *PeerClient
NewPeerClient builds a client targeting hubURL (e.g. "https://hub.default.svc:7777").
func (*PeerClient) Deregister ¶
func (c *PeerClient) Deregister(ctx context.Context, registrationID string) error
Deregister removes the registration from the hub. Idempotent on the server side; this client also treats network errors as best-effort (graceful shutdown shouldn't fail because the hub is momentarily unreachable).
func (*PeerClient) Register ¶
func (c *PeerClient) Register(ctx context.Context, req RegisterRequest) (*Peer, error)
Register POSTs the registration request to the hub. Returns the assigned RegistrationID + lease expiry. Doesn't start a heartbeat goroutine — use RegisterAndHeartbeat for the standard lifecycle.
func (*PeerClient) RegisterAndHeartbeat ¶
func (c *PeerClient) RegisterAndHeartbeat(ctx context.Context, req RegisterRequest) (cancel func(), err error)
RegisterAndHeartbeat registers the peer and starts a background heartbeat goroutine that runs until ctx is cancelled. Returns a cancel function that triggers Deregister + goroutine shutdown.
Heartbeat cadence is the registered TTL divided by c.heartbeatFraction (default 1/3 of TTL). If the registered TTL is 0 (peer wanted default), the hub-supplied lease expiry is used to derive cadence.
Heartbeat failures are logged but don't fatal — a peer that loses its registration via expired lease just re-registers on the next attempt (the hub's name-based upsert makes this clean).
type PeerClientOption ¶
type PeerClientOption func(*PeerClient)
PeerClientOption configures NewPeerClient.
func WithPeerBearerToken ¶
func WithPeerBearerToken(token string) PeerClientOption
WithPeerBearerToken sets the Authorization: Bearer header used on every request to the hub. Required when the hub has Auth.BearerToken set.
func WithPeerHTTPClient ¶
func WithPeerHTTPClient(client *http.Client) PeerClientOption
WithPeerHTTPClient overrides the default http.Client. Useful for mTLS (caller supplies a Transport with a configured TLSConfig) or for tests with a custom round-tripper.
type PeerRegistry ¶
type PeerRegistry struct {
// contains filtered or unexported fields
}
PeerRegistry is the hub-side state. Independent from SessionRegistry — sessions and peers are orthogonal: a peer's endpoint may itself host its own sessions.
func NewPeerRegistry ¶
func NewPeerRegistry(opts ...PeerRegistryOption) *PeerRegistry
NewPeerRegistry returns an empty hub registry plus a started prune goroutine that drops expired leases every pruneTick. Call Close to stop the goroutine.
func (*PeerRegistry) Close ¶
func (r *PeerRegistry) Close() error
Close stops the prune goroutine. Idempotent.
func (*PeerRegistry) Deregister ¶
func (r *PeerRegistry) Deregister(id string)
Deregister removes the peer by ID. No-op on unknown id — keeps graceful shutdown paths idempotent.
func (*PeerRegistry) Heartbeat ¶
func (r *PeerRegistry) Heartbeat(id string) (*Peer, error)
Heartbeat extends the lease on the named registration. Returns the new lease expiry. ErrPeerNotFound when id is unknown (peer should re-Register on this error).
func (*PeerRegistry) Len ¶
func (r *PeerRegistry) Len() int
Len returns the count of live peers (post-prune).
func (*PeerRegistry) List ¶
func (r *PeerRegistry) List(labelMatch map[string]string) []*Peer
List returns a sorted snapshot of every live peer. labelMatch, if non-empty, filters to peers whose Labels contain every k=v in the match map. Returns a defensive copy of each Peer so callers can't mutate registry state.
func (*PeerRegistry) Prune ¶
func (r *PeerRegistry) Prune() int
Prune drops every peer whose lease has expired. Returns the count pruned. Called from the background goroutine on a tick; exposed for tests + manual triggering.
func (*PeerRegistry) Register ¶
func (r *PeerRegistry) Register(req RegisterRequest) (*Peer, error)
Register adds (or upserts on Name match) a peer. Returns the assigned RegistrationID + lease expiry. Name-based upsert avoids orphaned entries when a peer restarts.
type PeerRegistryOption ¶
type PeerRegistryOption func(*PeerRegistry)
PeerRegistryOption configures NewPeerRegistry.
func WithMaxTTL ¶
func WithMaxTTL(d time.Duration) PeerRegistryOption
WithMaxTTL caps how long a peer-requested heartbeat TTL can run. Defaults to 5 minutes. Peers asking for longer get clamped.
type PermsController ¶
type PermsController interface {
AttachAddAllow(patterns []string) error
AttachAddDeny(patterns []string) error
}
PermsController is the optional capability for POST /sessions/.../perms/allow + /perms/deny. Mutates the gate's pattern list; the new patterns take effect for future tool calls without restarting the agent. Each method returns an error so the gate's own pattern-validation errors surface to the operator.
type PermsInfo ¶
type PermsInfo struct {
Mode string `json:"mode"`
Allow []string `json:"allow,omitempty"`
Deny []string `json:"deny,omitempty"`
Approvals []ApprovalInfo `json:"approvals,omitempty"`
}
PermsInfo is the response shape of GET /sessions/.../perms — backs the remote TUI's /permissions slash. Mirrors permissions.Snapshot plus the per-session approval log so the operator can review what was approved this session.
type PermsProvider ¶
type PermsProvider interface {
AttachPerms() PermsInfo
}
PermsProvider is the optional capability for GET /sessions/.../perms.
type PricingController ¶
type PricingController interface {
AttachRefreshPricing(ctx context.Context) (PricingRefreshResponse, error)
AttachSetManualPricing(req PricingSetRequest) error
}
PricingController is the optional capability for POST /sessions/.../pricing/refresh + /pricing/set. Implementations typically delegate to the binary's pricing layer (internal/pricing in cmd/core-agent) rather than reimplementing it.
type PricingInfo ¶
type PricingInfo struct {
// Source names the catalog layer that served CurrentModel's rate.
// Values are the pricing.SourceX constants:
// "cfg-override" | "project-file" | "user-manual" |
// "user-external" | "builtin"
// Empty when no rate resolved for CurrentModel (renders as "$—"
// downstream).
Source string `json:"source"`
LastRefresh time.Time `json:"last_refresh,omitempty"`
KnownModels int `json:"known_models"`
CurrentModel string `json:"current_model,omitempty"`
Current *ModelPricing `json:"current,omitempty"`
}
PricingInfo is the response shape of GET /sessions/.../pricing — backs the remote TUI's /pricing slash. Reports the layered-lookup state at request time: how many models have rates, which layer the current model resolved against, and the current model's rate breakdown.
type PricingProvider ¶
type PricingProvider interface {
AttachPricing() PricingInfo
}
PricingProvider is the optional capability for GET /sessions/.../pricing.
type PricingRefreshResponse ¶
type PricingRefreshResponse struct {
Updated bool `json:"updated"`
KnownModels int `json:"known_models"`
LastRefresh time.Time `json:"last_refresh"`
Detail string `json:"detail,omitempty"` // human-readable note when Updated=false
}
PricingRefreshResponse is the response shape of POST /pricing/refresh — reports whether the upstream fetch produced new data, the model count post-refresh, and the refreshed-at timestamp so the client can update its display.
type PricingSetRequest ¶
type PricingSetRequest struct {
Model string `json:"model"`
InputUSDPerMTok float64 `json:"input_usd_per_mtok"`
OutputUSDPerMTok float64 `json:"output_usd_per_mtok"`
}
PricingSetRequest is the POST body for /pricing/set.
type PromptBroker ¶
type PromptBroker struct {
// contains filtered or unexported fields
}
PromptBroker bridges a permissions.Gate (which expects an in-process Prompter) with one or more remote attach subscribers. AskApproval generates a request_id, fans the request out to every active /perms/stream subscriber, then blocks until Respond delivers the operator's decision or ctx cancels.
Headless safety: when no subscriber is attached at the moment of AskApproval, the request is still tracked so a subscriber that attaches mid-flight can drain the queue. Callers wanting fail-fast when no operator is around should cap ctx with a timeout — the gate's serializing wrapper already serializes prompts, so a hung AskApproval would block subsequent tool calls.
One broker per daemon process. Wire via agent.WithAttachPromptBroker so the agent surfaces it through the PromptBrokerProvider capability the attach server consults.
func NewPromptBroker ¶
func NewPromptBroker() *PromptBroker
NewPromptBroker returns a fresh broker. Safe for concurrent use.
func (*PromptBroker) AskApproval ¶
func (b *PromptBroker) AskApproval(ctx context.Context, req permissions.PromptRequest) (permissions.Decision, error)
AskApproval implements permissions.Prompter by round-tripping the request through whichever subscribers are attached. Blocks until Respond is called or ctx cancels. Treats "no subscribers attached" as a queued state — the request waits until either a subscriber shows up or ctx expires; the gate's typical ctx is the per-tool- call context, so a stuck prompt fails the tool call cleanly.
func (*PromptBroker) Close ¶
func (b *PromptBroker) Close()
Close unblocks every pending AskApproval with a closed-broker error and disconnects every active subscriber. Idempotent.
func (*PromptBroker) Pending ¶
func (b *PromptBroker) Pending() []PromptFrame
Pending returns a snapshot of currently-pending prompts. Useful for tests and for clients that want a poll-style fallback if SSE isn't available.
func (*PromptBroker) Respond ¶
func (b *PromptBroker) Respond(id string, decision permissions.Decision) error
Respond delivers the operator's decision to the blocked AskApproval call identified by id. Returns ErrPromptNotFound if the id doesn't match a live request (already responded, already cancelled, or never existed).
func (*PromptBroker) Subscribe ¶
func (b *PromptBroker) Subscribe(ctx context.Context) (<-chan PromptFrame, func())
Subscribe registers a /perms/stream listener. Returns a channel of PromptFrames + a cleanup func the caller must invoke when the subscription ends (typically deferred at the SSE handler). The returned channel is also seeded with every currently-pending frame so a late-attaching operator sees prompts that arrived before they connected.
type PromptBrokerProvider ¶
type PromptBrokerProvider interface {
AttachPromptBroker() *PromptBroker
}
PromptBrokerProvider is the optional capability for routes under /sessions/<sid>/perms/stream + /perms/respond. Agents that opted into prompt routing surface their broker via this interface; the attach server returns 501 / capability-not-registered otherwise.
type PromptFrame ¶
type PromptFrame struct {
ID string `json:"id"`
Kind string `json:"kind"` // "bash" | "file_write" | "path_scope" | "generic"
ToolName string `json:"tool"`
Detail string `json:"detail,omitempty"`
Verb string `json:"verb,omitempty"`
Source string `json:"source,omitempty"`
PersistTool string `json:"persist_tool,omitempty"`
PersistKey string `json:"persist_key,omitempty"`
Access string `json:"access,omitempty"` // "" for non-path-scope prompts
At time.Time `json:"at"`
}
PromptFrame is one record on the /perms/stream SSE channel. Mirrors permissions.PromptRequest in a JSON-friendly shape plus the server-generated ID used to correlate the eventual /perms/respond. Kind uses the wire-stable string form rather than the in-process enum's int value so renumbering the enum doesn't break attached clients.
type PromptResponse ¶
PromptResponse is the POST body for /perms/respond. Decision uses the wire-stable string form; see DecisionFromWire for the mapping.
type RegisterRequest ¶
type RegisterRequest struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Labels map[string]string `json:"labels,omitempty"`
HeartbeatTTLSec int `json:"heartbeat_ttl_sec,omitempty"`
}
RegisterRequest is the body the peer POSTs to /peers. Validated inside PeerRegistry.Register; bad values surface as errors the handler maps to 400.
type Registrant ¶
type Registrant interface {
// AppName / UserID / SessionID together form the ADK session
// key. The registry uses (AppName, SessionID) for URL lookup
// (userID is per-process today; see attach-mode-design.md) but
// stores all three so /sessions can expose full triples.
AppName() string
UserID() string
SessionID() string
// EventLog returns the agent's event log handle, or nil if the
// agent was constructed without WithEventLog. Attach requires a
// non-nil eventlog for live-tail (broadcaster pumps from
// Stream.Watch); the server returns a clear error for sessions
// without one.
EventLog() *eventlog.Handle
// Inject queues an inbox message that the next turn drains.
// Also fires the wake signal internally (see Agent.Inject).
Inject(message string) error
// InjectAs is Inject with a per-message originator identity. The
// caller is the auth.Caller resolved from the inbound HTTP
// request (see callerMiddleware). When the agent loop drains the
// inbox, the last non-empty caller becomes the turn originator
// stamped onto eventlog metadata and outbound MCP context.
//
// In single-user / pre-multi-session deployments, this method is
// called with a zero-value Caller and behaves identically to
// Inject.
InjectAs(message string, caller auth.Caller) error
// RequestWake fires the agent's wake signal without queuing a
// message. Used by POST /wake.
RequestWake()
}
Registrant is the subset of *agent.Agent the registry needs. Defined as an interface so attach/ doesn't import agent/ (avoids an import cycle — agent/ needs to call into attach to register itself).
type ReloadResponse ¶
type ReloadResponse struct {
Memory bool `json:"memory"`
Skills bool `json:"skills"`
MCP bool `json:"mcp"`
Errors []string `json:"errors,omitempty"`
}
ReloadResponse is the response shape of POST /reload — reports per-surface success so the operator sees which parts (memory / skills / mcp) succeeded and which failed without parsing logs.
type Reloader ¶
type Reloader interface {
AttachReload(ctx context.Context) ReloadResponse
}
Reloader is the optional capability for POST /sessions/.../reload. Re-walks the agent's project dependencies (memory / skills / MCP) and reports per-surface success. The implementation decides what "reload" means — e.g., re-load AGENTS.md, reload skills, restart MCP servers. Hot-swap semantics are the binary's concern.
type ReplanProvider ¶
type ReplanProvider interface {
AttachReplan(ctx context.Context, req ReplanRequest) (ReplanResponse, error)
}
ReplanProvider is the optional capability for POST /sessions/.../slash/replan. Implementations clear the gate's plan-recorded flag and archive the latest plan artifact to plan-<N>-revoked.md. Wired by binaries that set up plan-first gating (require_plan_artifact: true); binaries without plan-first support don't register this capability and the route 501s.
type ReplanRequest ¶
type ReplanRequest struct {
Reason string `json:"reason,omitempty"`
}
ReplanRequest is the POST body for /slash/replan. Today there's no body — operator clicks /replan and the agent revokes the latest plan, clears the gate flag, and waits for the next model turn. Future versions may add an optional Reason field for a system-note that primes the model's redraft.
type ReplanResponse ¶
type ReplanResponse struct {
// ArchivedPath is the full path of the file that was renamed
// from plan-<N>.md to plan-<N>-revoked.md. Empty if there was
// no active plan to revoke.
ArchivedPath string `json:"archived_path,omitempty"`
// PlanWasActive reports whether a plan was active before this
// call. False means the gate flag was clear and no file got
// renamed (still safe to call).
PlanWasActive bool `json:"plan_was_active"`
// Message is the operator-facing one-liner the TUI renders.
Message string `json:"message,omitempty"`
}
ReplanResponse is the response shape of POST /slash/replan. Mirrors what `tools.RevokeLatestPlan` returned plus a status flag for the no-plan-to-revoke case (which is not an error — /replan can be called defensively to ensure the gate is clear).
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server hosts the attach-mode HTTP endpoints. Construct via NewServer; start via ListenAndServe; stop via Close.
func NewServer ¶
NewServer builds a Server. Validates Options; returns an error for invalid combinations (both/neither of Addr/UnixSocket; missing registry; TLS material mismatch).
func (*Server) Addr ¶
Addr returns the actual listener address the server bound to. Useful for tests that use Addr=":0" to get an ephemeral port. Returns empty before ListenAndServe runs.
func (*Server) Bind ¶
Bind binds the listener and prepares TLS / http.Server state. Returns any bind error synchronously so callers can fail-fast (the original ListenAndServe path lost bind errors when run inside a background goroutine — operators who started a second daemon with the port already held silently fell through to REPL mode talking to the wrong process). Safe to call exactly once per Server.
func (*Server) Close ¶
Close stops the server, waits up to Options.ShutdownTimeout for in-flight SSE clients to disconnect, then tears down the broadcaster pool. Idempotent.
func (*Server) ListenAndServe ¶
ListenAndServe binds the listener and serves until Close or a fatal error. Blocks until the server stops. Returns nil on clean shutdown, the underlying error otherwise.
Equivalent to Bind followed by Serve. Callers that need to surface bind failures synchronously (e.g., to fail-fast on port-in-use instead of degrading) should call Bind directly in the main goroutine and Serve in a background goroutine.
func (*Server) Serve ¶
Serve serves on the already-bound listener. Bind must have been called first. Blocks until the server stops. Returns nil on clean shutdown, the underlying error otherwise.
func (*Server) TLSEnabled ¶
TLSEnabled reports whether the server is running with TLS (HTTPS endpoints) or plaintext (HTTP). Helpful for log lines / debugging.
type SessionACLRow ¶
type SessionACLRow struct {
AppName string
UserID string
SessionID string
Owner string
Viewers []string
Contributors []string
CreatedAt time.Time
LastTouchedAt time.Time
}
SessionACLRow is the public shape returned by SessionACLStore. The wire-format JSON fields on ViewersJSON / ContributorsJSON are only an implementation detail of the GORM-backed store; callers access the strongly-typed Viewers / Contributors slices directly via the ACL() helper.
func (SessionACLRow) ACL ¶
func (r SessionACLRow) ACL() auth.SessionACL
ACL returns the auth.SessionACL view of the row — what the Authorize matrix consumes when deciding whether a caller may access this session.
type SessionACLStore ¶
type SessionACLStore interface {
// Put upserts the ACL row for (app, user, sid). Called by
// RegisterOwned at session-creation time. Idempotent — re-Put
// of the same triple updates LastTouchedAt and any changed
// ACL fields.
Put(ctx context.Context, row SessionACLRow) error
// Get returns the persisted ACL row for the triple. Returns
// ErrSessionACLNotFound when no row exists (typical case for
// sessions that were registered before this store was wired in,
// or for fresh deployments with no prior sessions).
Get(ctx context.Context, app, user, sid string) (SessionACLRow, error)
// FindByAppSID returns the first persisted row matching the
// (app, sid) pair, scanning across users. Used by the resumer:
// SessionRegistry.Lookup is keyed by (app, sid) (no userID;
// userID is per-process today, see registry doc), so the
// resumer must locate the matching row without knowing user.
// Returns ErrSessionACLNotFound when no row exists.
//
// In practice SessionIDs are unique across users, so the
// "first match" behavior is unambiguous. The deterministic
// scan-order isn't specified — callers that need stable order
// across multi-user-same-sid rows (shouldn't happen) sort
// post-fetch.
FindByAppSID(ctx context.Context, app, sid string) (SessionACLRow, error)
// Delete removes the ACL row for the triple. Idempotent — no
// error when the row doesn't exist. Used by future
// DELETE /sessions hard-delete; NOT called by the eviction
// sweep (eviction is in-memory only).
Delete(ctx context.Context, app, user, sid string) error
// Touch updates the row's LastTouchedAt without rewriting the
// rest of the row. Called by the registry on every Lookup hit
// and every event broadcast so the eviction sweep has a
// recent timestamp to compare against.
Touch(ctx context.Context, app, user, sid string, when time.Time) error
// ListByOwner returns every ACL row where the owner matches.
// Used by future operator UX ("show me all sessions I own").
// Order is unspecified; callers that need stable order should
// sort by their preferred field (typically LastTouchedAt desc).
ListByOwner(ctx context.Context, owner string) ([]SessionACLRow, error)
// ListVisibleTo returns every ACL row the caller may
// SessionRead per the Authorize matrix: Owner, Viewer,
// Contributor, or Admin-sees-all. Powers the persisted half
// of GET /sessions (the in-memory half comes from
// Registry.List(); the handler unions + dedupes the two).
//
// An Admin caller gets every row in the table. A zero-identity
// caller gets nothing (matches Authorize's empty-identity
// deny-by-default).
ListVisibleTo(ctx context.Context, caller auth.Caller) ([]SessionACLRow, error)
}
SessionACLStore persists per-session ACL state across daemon restarts. Backs Phase 1 of the session-resume-on-restart design (docs/session-resume-design.md): RegisterOwned writes a row at session-creation time; the future Phase 2 SessionResumer reads it to reconstruct evicted sessions on Lookup miss.
Implementations are expected to be safe for concurrent use. The on-disk shape is operator-visible (SQLite database file), so the column layout MUST stay stable across releases.
func NewSessionACLStore ¶
NewSessionACLStore constructs a SessionACLStore backed by the supplied GORM connection (typically the one returned by eventlog.Open, exposed via Handle.SessionACL). AutoMigrates the agent_session_acl table; safe to call against an existing database — AutoMigrate is idempotent for additive schema changes.
Returns a constructed store on success. On AutoMigrate failure, returns the underlying GORM error wrapped with package context.
type SessionFactory ¶
type SessionFactory func(ctx context.Context, caller auth.Caller) (Registrant, context.CancelFunc, error)
SessionFactory constructs a fresh Registrant for the POST /sessions endpoint. Implementations capture the daemon-wide config (model, tools, MCP toolsets, eventlog handle, instruction roots) in a closure and synthesize a new *agent.Agent (or any other Registrant) per call.
caller is the authenticated identity that triggered the creation; the handler stamps this as the new session's ACL Owner via RegisterOwnedWithCancel, so the factory itself does NOT need to call Register / RegisterOwned. (That separation keeps the auth concerns in the handler and the construction concerns in the factory.)
The returned CancelFunc (may be nil) is stored on the resulting Entry and invoked when the registry evicts the session (idle sweep or explicit Unregister). Typically it cancels the ctx driving the per-session wake loop so background work exits cleanly instead of leaking past the session's lifetime.
ctx is the request context; honor cancellation if the factory does any IO (e.g., loading per-caller instructions).
type SessionRegistry ¶
type SessionRegistry struct {
// contains filtered or unexported fields
}
SessionRegistry holds every Agent that opted into attach-mode by calling agent.WithSessionRegistry at construction. Keys by the full (AppName, UserID, SessionID) triple but exposes a single-segment SessionID shortcut for the unambiguous case.
Optional aclStore (set via NewSessionRegistryWithStore) persists the ACL of every RegisterOwned call to disk so sessions survive daemon restart. Nil store keeps the legacy in-memory-only behavior — backward compatible for single-user and pre-resume deployments.
func NewSessionRegistry ¶
func NewSessionRegistry() *SessionRegistry
NewSessionRegistry returns an empty registry. Sessions registered via RegisterOwned are in-memory only — they do NOT survive daemon restart. Use NewSessionRegistryWithStore to opt into disk-backed ACL persistence (Phase 1 of session resume).
func NewSessionRegistryWithStore ¶
func NewSessionRegistryWithStore(store SessionACLStore) *SessionRegistry
NewSessionRegistryWithStore returns a registry wired to persist every RegisterOwned ACL to the supplied store. The store upserts on Register; the Phase 2 SessionResumer reads it back on Lookup miss to reconstruct evicted sessions (when wired via WithResumer).
Passing nil for the store is equivalent to NewSessionRegistry (no persistence). Callers wire this in their daemon startup once the eventlog handle exposes its DB connection (eventlog.Handle.DB).
func (*SessionRegistry) EvictBefore ¶
func (r *SessionRegistry) EvictBefore(cutoff time.Time) int
EvictBefore removes every in-memory entry whose lastTouchedNs is older than cutoff and returns how many were removed. The evicted entries' cancelOnEvict funcs (if any) are invoked outside the registry lock so wake-loop shutdown doesn't contend on lookups.
When aclStore is wired, the last-touched value at eviction time is persisted to the ACL row so GET /sessions and future resume see an accurate "last active" timestamp. Persistence is best-effort: a store error is logged upstream but doesn't block eviction (the in-memory removal is the correctness-critical half; the DB row is metadata).
Callers: the sweep goroutine started by SweepIdle. Test code may call directly to exercise the eviction path without the ticker overhead.
func (*SessionRegistry) HardDelete ¶
func (r *SessionRegistry) HardDelete(ctx context.Context, appName, userID, sessionID string) error
HardDelete removes the in-memory entry (like Unregister) AND deletes its persisted ACL row when a store is wired, so a subsequent Lookup miss can't lazy-resume the session from persistence. Idempotent — no error when either the in-memory entry or the ACL row is absent.
Not cleaned by HardDelete (out of scope; the registry owns session identity, not session data):
- Underlying ADK/SQLite session records (see pkg/eventlog). They become unreachable via the registry but stay on disk.
- Broadcaster subscribers — the caller must Close the per- entry broadcaster via BroadcasterPool.Remove.
The wake-loop cancel (cancelOnEvict) fires exactly as with Unregister, so goroutines tied to the session exit cleanly.
func (*SessionRegistry) Len ¶
func (r *SessionRegistry) Len() int
Len returns the number of registered sessions. Useful for tests + the listener's startup log.
func (*SessionRegistry) List ¶
func (r *SessionRegistry) List() []*Entry
List returns a snapshot of every registered entry, sorted by (AppName, UserID, SessionID) for stable output. Used by GET /sessions so the operator sees a deterministic ordering across requests.
func (*SessionRegistry) ListAuthorized ¶
func (r *SessionRegistry) ListAuthorized(c auth.Caller) []*Entry
ListAuthorized returns the subset of List() that c may auth.ActionSessionRead per each Entry's ACL. Used by the GET /sessions handler in multi-session mode so a Caller only sees sessions they have access to — hiding unauthorized session existence prevents leaking activity patterns.
An Admin Caller sees everything (same as List); an anonymous Caller (Identity == "") sees nothing unless an Entry's ACL has an empty Owner AND empty Viewers/Contributors (which Authorize also rejects — so practically nothing).
func (*SessionRegistry) Lookup ¶
Lookup returns the entry for the qualified (appName, sessionID) form. userID is not required for lookup — the registry searches across all registered userIDs for the (app, sid) pair.
On miss, when a SessionResumer is wired (Phase 2 of session-resume), Lookup attempts to reconstruct the session from its persisted ACL row and registers the resulting entry before returning it. Concurrent misses for the same (app, sid) collapse via singleflight to a single resumer call.
Returns ErrSessionNotFound when the session is neither in memory nor reconstructable (no persisted row, or no resumer configured). Returns the resumer's error verbatim for any other failure (factory error, store I/O error) so the handler can surface a 500 with the underlying cause.
func (*SessionRegistry) LookupSingle ¶
LookupSingle resolves the /sessions/<sessionID> shortcut. Returns ErrAmbiguousSession if the SessionID is registered against multiple apps — the caller should then use the qualified form and surface the helpful error to the client.
On miss, behaves the same as Lookup w.r.t. resume — the resumer receives a fixed app name ("core-agent" by default; see resumerDefaultApp) since the shortcut form can't carry it. In practice every session in scope for resume comes from the same single app per daemon, so this is the right behavior.
func (*SessionRegistry) Register ¶
func (r *SessionRegistry) Register(ag Registrant) (*Entry, error)
Register adds a session with no owner — legacy single-user behavior. Returns ErrSessionExists if the triple is already present (caller should not silently overwrite, since a double-register usually means an Agent construction race).
In a multi-session deployment, sessions registered via Register are admin-only-accessible — Authorize denies non-Admin Callers because the ACL.Owner is empty. New code that knows the owning identity should use RegisterOwned instead.
func (*SessionRegistry) RegisterOwned ¶
func (r *SessionRegistry) RegisterOwned(ag Registrant, owner string) (*Entry, error)
RegisterOwned adds a session and stamps the supplied caller as the Owner of the ACL. Use from session creation paths that know the originating Caller (the typical multi-session attach setup: the daemon resolves the caller from the credential, then creates the session under that identity).
Owner must be non-empty — pass Register if you intentionally want an unowned (admin-only-accessible) session.
func (*SessionRegistry) RegisterOwnedWithCancel ¶
func (r *SessionRegistry) RegisterOwnedWithCancel(ag Registrant, owner string, cancelOnEvict context.CancelFunc) (*Entry, error)
RegisterOwnedWithCancel is RegisterOwned with a cancel func the registry invokes when the entry is evicted (idle sweep, DELETE /sessions via HardDelete, or explicit Unregister). The cancel typically shuts down the session's wake-loop goroutine so it exits cleanly instead of leaking past the session's lifetime.
Passing nil is equivalent to RegisterOwned — no cancel invoked on evict. In-process consumers with no long-lived goroutines behind the Registrant (e.g., tests, admin-only utility sessions) can safely pass nil.
func (*SessionRegistry) SweepIdle ¶
func (r *SessionRegistry) SweepIdle(ctx context.Context, idleAfter time.Duration)
SweepIdle runs the eviction sweep on a ticker until ctx is done. Every idleAfter/4 tick, evicts entries idle longer than idleAfter. Blocks until ctx cancels — call as a goroutine.
idleAfter <= 0 disables the sweep (returns immediately). Callers wire the operator's session_idle_timeout config value here; the "0s → disabled" contract lives at the config parser, so a caller that passes 0 explicitly (rather than relying on defaults) means it.
Ticker fires at 1/4 the idle window — coarse enough that the wake-up cost is negligible against the eviction budget, fine enough that an evictable session isn't held for more than ~25% of the idle window past its idle threshold. Under 60s tick intervals are floored to 60s so tests with tiny idleAfter don't spin the CPU.
func (*SessionRegistry) TouchEntry ¶
func (r *SessionRegistry) TouchEntry(appName, sessionID string)
TouchEntry marks the entry for (app, sid) as active-right-now. The broadcaster calls this on every event pumped through the session so autonomous agent work (long-running tool calls, background compaction, etc.) counts as activity. Silent no-op on miss — the entry may have been evicted between the caller's Lookup and Touch, and we don't want to error on a benign race.
Cheap (single atomic store, no mutex on the entry itself). Safe for concurrent callers.
func (*SessionRegistry) Unregister ¶
func (r *SessionRegistry) Unregister(appName, userID, sessionID string)
Unregister removes a session by its full triple. No-op (returns nil) when the entry doesn't exist — keeps shutdown paths idempotent.
Does NOT delete the persisted ACL row when a store is wired — Unregister is about removing the in-memory entry (agent shutdown, eviction sweep). The ACL row stays on disk so the next Lookup miss can lazily resume the session (Phase 2). For hard removal that also clears the persisted ACL — as the DELETE /sessions endpoint requires — use HardDelete.
func (*SessionRegistry) WithResumer ¶
func (r *SessionRegistry) WithResumer(resumer SessionResumer) *SessionRegistry
WithResumer wires a SessionResumer onto an existing registry. Subsequent Lookup / LookupSingle misses consult the resumer to reconstruct sessions that exist on disk but not in memory (e.g. after a daemon restart, or after Phase 3's eviction sweep). Returns the same registry for chaining.
The resumer is configured separately from the store because the store is daemon-startup data (a SQL connection) while the resumer is a closure capturing the full sessionFactoryDeps — the two have different construction sites and lifetimes. Calling WithResumer(nil) is a no-op (preserves the existing resumer); to disable, construct a fresh registry.
type SessionResumer ¶
type SessionResumer interface {
Resume(ctx context.Context, app, sid string) (Registrant, auth.SessionACL, context.CancelFunc, error)
}
SessionResumer reconstructs a session that exists on disk (persisted ACL row) but not in the current daemon's in-memory SessionRegistry. Called by Registry.Lookup / LookupSingle on miss when a Resumer is configured.
Implementations:
- Look up the persisted ACL row by (app, sid) — typically via SessionACLStore.FindByAppSID.
- Materialize the original Caller from the row's Owner.
- Reconstruct the agent using the daemon's SessionFactory shape with the EXPLICIT sessionID (not a freshly minted one) so ADK's session.Service reattaches the prior conversation history from the eventlog.
- Return the new Registrant, the persisted ACL, and a cancelOnEvict CancelFunc the registry invokes when the entry is later evicted (idle sweep). The cancel typically shuts down the per-session wake loop the resumer spawned.
The caller (Registry.Lookup) registers the returned Registrant under the returned ACL via the internal registerResumed path, carrying the cancel func onto *Entry.cancelOnEvict.
Return ErrSessionACLNotFound when no ACL row exists for the triple — the registry maps that to ErrSessionNotFound so the handler returns 404. Any other error surfaces as 500 with the resume-failure message (see docs/session-resume-design.md OQ #2).
Implementations live in cmd/core-agent (see buildSessionResumer) because they need sessionFactoryDeps — model, gate template, tools, eventlog handle, MCP servers, … all cmd-level wiring. The interface stays in pkg/attach so the handlers can consult it without importing cmd/core-agent.
The cancelOnEvict return may be nil — the registry treats nil as "no background work to stop." Test implementations of the interface commonly return nil.
type SideQueryProvider ¶
type SideQueryProvider interface {
AttachAskSideQuestion(ctx context.Context, question string) (string, error)
}
SideQueryProvider is the optional capability for POST /sessions/.../slash/btw.
type SideQueryRequest ¶
type SideQueryRequest struct {
Question string `json:"question"`
}
SideQueryRequest is the POST body for /slash/btw — the operator's side question. The agent answers using its session history but doesn't persist the round-trip; results render as a dismissible overlay rather than a turn boundary.
type SideQueryResponse ¶
type SideQueryResponse struct {
Answer string `json:"answer"`
}
SideQueryResponse carries the agent's answer text.
type SkillInfo ¶
type SkillInfo struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
SkillInfo is one row in GET /sessions/.../skills — backs the remote TUI's /skills slash. Description is what the model sees; the operator uses it to verify why a skill did or didn't trigger.
type SkillsProvider ¶
type SkillsProvider interface {
AttachSkills() []SkillInfo
}
SkillsProvider is the optional capability for GET /sessions/.../skills.
type StatusInfo ¶
type StatusInfo struct {
State string `json:"state"` // running | deferred | paused | idle
ModelName string `json:"model_name,omitempty"`
NextWakeAt time.Time `json:"next_wake_at,omitempty"` // populated when State=deferred
CurrentTool string `json:"current_tool,omitempty"` // populated when State=running and a tool is in flight
}
StatusInfo is the response shape of GET /sessions/.../status. ModelName is what the TUI's usage panel labels with; the rest is agent-loop introspection useful for the /status slash command.
type StatusProvider ¶
type StatusProvider interface {
AttachStatus() StatusInfo
}
StatusProvider is the optional capability for GET /sessions/.../status. Returns the agent's current run-state + model identity.
type StatusUpdate ¶
type StatusUpdate struct {
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
PermMode string `json:"perm_mode,omitempty"`
TurnState string `json:"turn_state"`
ContextPct *int `json:"context_pct,omitempty"`
}
StatusUpdate is emitted on session-level state changes (turn start/end, model swap, perm-mode change, provider change) and also once right after Capabilities as a full snapshot.
Merge semantics: fields not present in an update are unchanged on the consumer side. TurnState is always present on every emission (snapshot or delta) per spec.
type SubagentBudget ¶
type SubagentBudget struct {
MaxTurns int `json:"max_turns,omitempty"`
MaxCostUSD float64 `json:"max_cost_usd,omitempty"`
MaxWallClockS int `json:"max_wall_clock_seconds,omitempty"`
}
SubagentBudget mirrors agent.BackgroundBudgets. Zero values mean "use the manager's default for that field".
type SubagentSpawnResponse ¶
type SubagentSpawnResponse struct {
Name string `json:"name"`
StartedAt time.Time `json:"started_at"`
}
SubagentSpawnResponse confirms the spawn. StartedAt is the manager's record of when the subagent's first turn dispatched.
type SubagentSpawner ¶
type SubagentSpawner interface {
AttachSpawnSubagent(ctx context.Context, spec SubagentSpec) (SubagentSpawnResponse, error)
}
SubagentSpawner is the optional capability for POST /sessions/.../slash/subagent.
type SubagentSpec ¶
type SubagentSpec struct {
Name string `json:"name"`
SystemPrompt string `json:"system_prompt,omitempty"`
Goal string `json:"goal"`
Tools []string `json:"tools,omitempty"`
Extras []string `json:"extras,omitempty"`
Budgets SubagentBudget `json:"budgets,omitempty"`
Scheduler string `json:"scheduler,omitempty"`
}
SubagentSpec is the POST body for /slash/subagent. Mirrors agent.BackgroundSpec in JSON-friendly form.
type ToolInfo ¶
type ToolInfo struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Source string `json:"source"` // builtin | mcp | skill | other
Server string `json:"server,omitempty"` // MCP server attribution when Source=mcp
GateState string `json:"gate_state,omitempty"`
}
ToolInfo is one entry in the GET /sessions/.../tools response.
GateState carries the pre-flight projection from permissions.Gate.ToolGateState — empty when no gate is wired (library callers with no permission policy). The TUI v1 fetches the field but doesn't surface it; v1.1 adds the column in the /tools modal.
type ToolsProvider ¶
type ToolsProvider interface {
AttachTools() []ToolInfo
}
ToolsProvider is the optional capability a Registrant can implement to surface its tool catalog over GET /sessions/.../tools. The handler type-asserts at request time; absence reports an empty list rather than 501 so old Registrant impls keep working.
Method is named AttachTools (not Tools) to avoid colliding with *agent.Agent.Tools() which already returns []tool.Tool. Agents that implement the attach surface define a distinct method with this shape that does the conversion internally.
type TurnComplete ¶
type TurnComplete struct {
PromptID string `json:"prompt_id"`
Model string `json:"model"`
TokensIn int `json:"tokens_in"`
TokensOut int `json:"tokens_out"`
CostUSD *float64 `json:"cost_usd,omitempty"`
LatencyMs int64 `json:"latency_ms"`
}
TurnComplete fires once per turn after the last stream-chunk for that turn and before the next turn's events.
CostUSD is *float64 (optional) per spec v1.1.0 §2.5 — servers whose model layer doesn't know pricing (this server, since agent.* deliberately has no pricing reference) leave it nil and the immediately-following usage-update carries authoritative cost. Servers with in-band pricing populate it.
type TurnError ¶
type TurnError struct {
Kind string `json:"kind"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
Hint string `json:"hint,omitempty"`
}
TurnError is emitted on a pipeline failure that should reach the operator. Successful retries do NOT emit this (the spec's "if something is wrong, tell the operator" contract — successful internal retries are not operator-facing failures).
func ClassifyTurnError ¶
ClassifyTurnError maps a raw error from the model-call path to a TurnError payload conforming to the SSE event-stream protocol's kind enum (spec section 2.6).
Classification is string-based rather than type-based because the genai / ADK / Vertex / Anthropic clients each wrap upstream errors differently and changing wrapper layers would silently regress a type-switch. String matching against the canonical status names (gRPC code names, HTTP status numbers, well-known substrings) survives wrapper churn.
The hint field is populated with the most actionable next step when one is obvious — operators reading these in a chat-bubble shouldn't need to leave the TUI to know what to try.
type UsageByModel ¶
type UsageByModel struct {
TokensIn int `json:"tokens_in"`
TokensOut int `json:"tokens_out"`
CostUSD float64 `json:"cost_usd"`
Turns int `json:"turns"`
}
UsageByModel is one model's bucket inside UsageUpdate.ByModel.
type UsageInfo ¶
type UsageInfo struct {
Overall UsageTotals `json:"overall"`
PerModel map[string]UsageTotals `json:"per_model,omitempty"`
PerTurn []UsageTurn `json:"per_turn,omitempty"`
DigestMethods *DigestMethodsInfo `json:"digest_methods,omitempty"`
}
UsageInfo is the response shape of GET /sessions/.../usage. Backs the remote TUI's /stats slash. PerModel is empty when only one model has been used (no breakdown needed). PerTurn is one entry per model call in submission order and is always populated when the tracker recorded any turns — see issue #222 for the motivating operator use case (per-turn cost + cache attribution).
DigestMethods is the digest wrapper's per-method call count (issue #130 / task #84). Present when at least one digest.Process call has fired process-wide. Feeds "which pruner path is dominating" without operators needing to scrape per-event metadata.
type UsageLastTurn ¶
type UsageLastTurn struct {
TokensIn int `json:"tokens_in"`
TokensInCached int `json:"tokens_in_cached,omitempty"`
TokensOut int `json:"tokens_out"`
CostUSD float64 `json:"cost_usd"`
Model string `json:"model,omitempty"`
}
UsageLastTurn captures the just-completed turn's per-turn footer fields — enough for a "◇ 12,345 in · 456 out · $0.0125 · gemini-3.1-pro" row without a follow-up round-trip. Cached input is surfaced when present so future TUI iterations can render a "· 8k cached" tag alongside the base tokens.
type UsageProvider ¶
type UsageProvider interface {
AttachUsage() UsageInfo
}
UsageProvider is the optional capability for GET /sessions/.../usage.
type UsageTotals ¶
type UsageTotals struct {
InputTokens int64 `json:"input_tokens"`
InputTokensCached int64 `json:"input_tokens_cached,omitempty"`
InputTokensUncached int64 `json:"input_tokens_uncached,omitempty"`
OutputTokens int64 `json:"output_tokens"`
ThoughtsTokens int64 `json:"thoughts_tokens,omitempty"`
Turns int `json:"turns"`
CostUSD float64 `json:"cost_usd"`
CostUSDUncachedReference float64 `json:"cost_usd_uncached_reference,omitempty"`
}
UsageTotals mirrors usage.Totals in a JSON-friendly shape.
InputTokens is the total effective prompt size and already includes InputTokensCached (Gemini semantics — see usage.Turn docstring). InputTokensUncached = InputTokens - InputTokensCached and is emitted as a convenience so operators don't have to do the subtraction.
CostUSD is the daemon's own cost estimate with the cached-vs-uncached rate split applied. CostUSDUncachedReference is what CostUSD would have been with zero cache hits — the delta between the two is the caching win, which the demo drive on 2026-07-13 confirmed operators have no other way to see.
Fields default to zero and use omitempty so a session that never touched the prompt cache still renders cleanly.
type UsageTurn ¶
type UsageTurn struct {
Turn int `json:"turn"`
At time.Time `json:"ts"`
Model string `json:"model,omitempty"`
InputTokens int64 `json:"input_tokens"`
InputTokensCached int64 `json:"input_tokens_cached,omitempty"`
InputTokensUncached int64 `json:"input_tokens_uncached,omitempty"`
OutputTokens int64 `json:"output_tokens"`
ThoughtsTokens int64 `json:"thoughts_tokens,omitempty"`
ToolUseTokens int64 `json:"tool_use_tokens,omitempty"`
TotalTokens int64 `json:"total_tokens"`
CostUSD float64 `json:"cost_usd"`
CostUSDUncachedReference float64 `json:"cost_usd_uncached_reference,omitempty"`
}
UsageTurn is one entry in UsageInfo.PerTurn — the per-model-call breakdown behind the aggregate Overall totals. Turn is 1-based in submission order; TotalTokens follows the genai convention (prompt + candidates + tool-use + thoughts).
type UsageUpdate ¶
type UsageUpdate struct {
TokensInTotal int `json:"tokens_in_total"`
TokensOutTotal int `json:"tokens_out_total"`
CostUSDTotal float64 `json:"cost_usd_total"`
TurnsTotal int `json:"turns_total"`
ByModel map[string]UsageByModel `json:"by_model,omitempty"`
LastTurn *UsageLastTurn `json:"last_turn,omitempty"`
}
UsageUpdate is emitted after each turn finalizes and as a cumulative snapshot on stream open. ByModel is optional — present when the tracker has more than one model bucketed (typical when --agentic-tools routes subtasks to a small model). LastTurn is optional — populated on turn-end emissions (the tracker.Append callback path), omitted on the snapshot emission at stream open where there's no meaningful "last turn" to attribute.
LastTurn carries the just-completed turn's per-turn cost so operator surfaces (remote TUI's per-turn footer) can render authoritative cost without needing client-side pricing lookups. The server's tracker owns the pricing catalog + cache-discount math; clients that recompute inevitably drift.
Source Files
¶
- agent_adapter.go
- agentcard.go
- agentcardfile.go
- auth.go
- broadcaster.go
- caller_middleware.go
- client_register.go
- debug.go
- errors.go
- events.go
- handlers.go
- handlers_create_session.go
- handlers_delete_session.go
- handlers_operator.go
- handlers_prompts.go
- handlers_slash.go
- handlers_ui.go
- peers.go
- peers_handlers.go
- prompter.go
- registry.go
- resumer.go
- server.go
- session_acl_store.go
- state.go
- types_prompts.go
- usage_render.go