attachclient

package
v2.9.0-dev.5 Latest Latest
Warning

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

Go to latest
Published: Sep 3, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package attachclient is the shared client for attach-mode endpoints, used by both `core-agent attach`/`ls` (in cmd/core-agent) and `core-agent-tui` (in cmd/core-agent-tui). The package is internal/ so the surface isn't part of the public API stability promise — it's a coordination point between two of our own binaries, not a SDK consumers should reach for.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type BearerCreds

type BearerCreds struct {
	Token string
}

BearerCreds sends the attach token as Authorization: Bearer <token>. Zero-value (Token == "") is auth-disabled — Apply is a no-op, matching the historical attach-over-Unix-socket convention.

func (BearerCreds) Apply

func (c BearerCreds) Apply(req *http.Request) error

Apply implements Credentials.

type Client

type Client struct {
	URL *ParsedURL

	// Token is kept for source-compat with the original constructor
	// (New stores its token here AND wraps it in a BearerCreds in
	// Credentials below). New code should prefer Credentials directly.
	// When both are set, Credentials wins — that's how callers opt
	// in to the gateway-fronted path while keeping the legacy field
	// for backward compatibility.
	Token       string
	Credentials Credentials
	// contains filtered or unexported fields
}

Client is a thin HTTP client for one attach-mode endpoint. Holds the parsed URL, bearer token (empty for no auth), and a configured http.Client (Unix-socket-aware when the URL scheme is unix://). Safe for concurrent use.

Three HTTP clients live inside: `http` for short-lived RPC calls with a request timeout, `slowHTTP` for the cost-bearing slash endpoints that block on a model call, and `streamHTTP` for SSE — no timeout, because the stream body stays open for as long as the agent runs and minutes can pass between frames. A single client with a Timeout would cut the SSE body mid-response on long model turns; the symptom is "stream ended: <nil>" reconnect-loops in the UI.

func New

func New(parsed *ParsedURL, token string, timeout time.Duration) *Client

New builds a Client wrapped in BearerCreds. ParseURL the rawURL first; Token may be empty (auth disabled — fine for Unix socket). timeout governs short-lived RPC calls. SSE streams ignore it (caller's ctx is the cancel signal). Zero timeout falls back to 30 s for RPCs.

Use NewWithCredentials to construct a Client with a non-Bearer auth strategy (Cloud Run IAM, IAP, …).

func NewWithCredentials

func NewWithCredentials(parsed *ParsedURL, creds Credentials, timeout time.Duration) *Client

NewWithCredentials builds a Client with an explicit Credentials implementation. Used by callers that need a non-Bearer auth path (e.g. cmd/core-agent-tui's --auth=google-id-token mode, which supplies a GoogleIDTokenCreds backed by ADC).

func (*Client) Agents

func (c *Client) Agents(ctx context.Context, sessionPath string) ([]attach.AgentInfo, error)

Agents calls GET <base>/sessions/<sid>/agents.

func (*Client) AllowPatterns

func (c *Client) AllowPatterns(ctx context.Context, sessionPath string, patterns []string) error

AllowPatterns calls POST <base>/sessions/<sid>/perms/allow with the given patterns. Backs the remote TUI's /allow slash. Returns nil on success (204), an error otherwise — including 501 when the agent doesn't implement PermsController and 400 when the gate rejects a pattern.

func (*Client) Context

func (c *Client) Context(ctx context.Context, sessionPath string) (attach.ContextInfo, error)

Context calls GET <base>/sessions/<sid>/context. Backs the remote TUI's /context slash. Returns zero ContextInfo on 501.

func (*Client) DenyPatterns

func (c *Client) DenyPatterns(ctx context.Context, sessionPath string, patterns []string) error

DenyPatterns calls POST <base>/sessions/<sid>/perms/deny. Backs the remote TUI's /deny slash.

func (*Client) Inject

func (c *Client) Inject(ctx context.Context, sessionPath, message string) error

Inject calls POST <base>/sessions/<sid>/inject with the given message. sessionPath is the /sessions/<sid> prefix (relative to BaseURL).

func (*Client) Interrupt

func (c *Client) Interrupt(ctx context.Context, sessionPath string, hold, stopSubagents bool) (InterruptResponse, error)

Interrupt calls POST <base>/sessions/<sid>/interrupt to cancel the in-flight turn on that session and park the loop. The returned InterruptResponse reports whether something was actually cancelled and whether the agent is now paused.

hold=false asks for the pre-v1.5.0 cancel-and-carry-on behavior (no park). stopSubagents additionally stops every running background subagent — off by default, since subagent runs aren't resumable.

func (*Client) ListPeers

func (c *Client) ListPeers(ctx context.Context) ([]PeerDescriptor, error)

ListPeers calls GET <base>/peers. Returns nil (not an error) when the listener doesn't have peer-registration enabled (HTTP 404).

func (*Client) ListSessions

func (c *Client) ListSessions(ctx context.Context) ([]SessionDescriptor, error)

ListSessions calls GET <base>/sessions.

func (*Client) MCP

func (c *Client) MCP(ctx context.Context, sessionPath string) (attach.MCPInfo, error)

MCP calls GET <base>/sessions/<sid>/mcp. Backs the remote TUI's /mcp slash. Returns zero MCPInfo on 501.

func (*Client) Memory

func (c *Client) Memory(ctx context.Context, sessionPath string) ([]attach.MemorySource, error)

Memory calls GET <base>/sessions/<sid>/memory. Backs the remote TUI's /memory slash. Returns empty slice (not nil) on 501.

func (*Client) NewSession

func (c *Client) NewSession(ctx context.Context) (NewSessionResponse, error)

NewSession calls POST <base>/sessions to create a fresh session owned by the authenticated caller. Returns the new session's descriptor on success.

Server-side behavior:

  • 201: new session created, response carries the triple + URL
  • 401: caller couldn't be resolved (anonymous request)
  • 501: daemon doesn't have a SessionFactory configured
  • 500: factory error
  • 409: triple collision (factory's SessionID generator clashed)

All non-2xx responses surface as errors.

func (*Client) Pause added in v2.9.0

func (c *Client) Pause(ctx context.Context, sessionPath, reason string) (attach.PauseResponse, error)

Pause calls POST <base>/sessions/<sid>/pause — park the loop without touching an in-flight turn ("stop after this one").

func (*Client) Perms

func (c *Client) Perms(ctx context.Context, sessionPath string) (attach.PermsInfo, error)

Perms calls GET <base>/sessions/<sid>/perms. Backs the remote TUI's /permissions slash. Returns zero PermsInfo on 501.

func (*Client) Pricing

func (c *Client) Pricing(ctx context.Context, sessionPath string) (attach.PricingInfo, error)

Pricing calls GET <base>/sessions/<sid>/pricing. Backs the remote TUI's /pricing slash. Returns zero PricingInfo on 501.

func (*Client) PromptStream

func (c *Client) PromptStream(ctx context.Context, sessionPath string) (<-chan attach.PromptFrame, error)

PromptStream subscribes to <base><sessionPath>/perms/stream and returns a channel of PromptFrames. Closes the channel on ctx cancel, stream error, or upstream EOF. 501 (capability not registered — agent wasn't constructed with WithAttachPromptBroker) is returned synchronously so callers can fall back gracefully.

func (*Client) QueueContext added in v2.9.0

func (c *Client) QueueContext(ctx context.Context, sessionPath, message string) error

QueueContext calls POST <base>/sessions/<sid>/inject with {"wake": false} — file the message for the next turn without causing one (#698). Use it for context the agent should have but need not act on now; use Inject when the message needs a turn.

Separate method rather than a bool on Inject: the two are different promises, not a variation on one, and a bool parameter at every call site would read as `Inject(ctx, path, msg, false)` with nothing on the line saying what false means. It also keeps Inject's signature stable for the callers that have it.

Returns an error on a daemon older than protocol 1.10.0 or a registrant without the capability (both answer 501) — deliberately not degraded to a waking inject, which would deliver exactly the preemption the caller asked to avoid.

func (*Client) RefreshPricing

func (c *Client) RefreshPricing(ctx context.Context, sessionPath string) (attach.PricingRefreshResponse, error)

RefreshPricing calls POST <base>/sessions/<sid>/pricing/refresh. Backs the remote TUI's /pricing refresh subcommand. Returns the outcome (whether the LiteLLM fetch actually pulled new data and the post-refresh model count) so the client can update its display.

func (*Client) Reload

func (c *Client) Reload(ctx context.Context, sessionPath string) (attach.ReloadResponse, error)

Reload calls POST <base>/sessions/<sid>/reload. Backs the remote TUI's /reload slash. Returns the per-surface success flags + any errors so the operator sees which parts (memory / skills / mcp) succeeded and which failed.

func (*Client) Replan

func (c *Client) Replan(ctx context.Context, sessionPath, reason string) (attach.ReplanResponse, error)

Replan calls POST <base>/sessions/<sid>/slash/replan. Backs the remote TUI's /replan slash. Reason is the optional free-text the operator typed after /replan; today it's surfaced in the archive's audit trail but doesn't drive any model-side behavior.

func (*Client) RespondToPrompt

func (c *Client) RespondToPrompt(ctx context.Context, sessionPath, id, decision string) error

RespondToPrompt POSTs the operator's decision to <base><sessionPath>/perms/respond. decision must be one of the wire-format strings (e.g. "allow-once"); see attach.DecisionFromWire for the mapping.

func (*Client) Resume added in v2.9.0

func (c *Client) Resume(ctx context.Context, sessionPath string, req attach.ResumeRequest) (attach.ResumeResponse, error)

Resume calls POST <base>/sessions/<sid>/resume with the operator's disposition. An empty req is a plain "carry on".

func (*Client) SetManualPricing

func (c *Client) SetManualPricing(ctx context.Context, sessionPath string, req attach.PricingSetRequest) error

SetManualPricing calls POST <base>/sessions/<sid>/pricing/set. Backs the remote TUI's /pricing set subcommand.

func (*Client) SetSessionTitle added in v2.9.0

func (c *Client) SetSessionTitle(ctx context.Context, sessionPath, title string) (attach.SessionTitleResponse, error)

SetSessionTitle calls POST <base>/sessions/<sid>/title. Backs the remote TUI's /title slash. Passing "" clears the title, which for an *agent.Agent also re-arms automatic generation on the next turn.

Takes the title by value rather than by pointer even though the wire field is a pointer: "don't send a title" is not a request any caller of this method wants to make (the daemon 400s it), so the pointer exists to catch a malformed body, not to give Go callers a third state to reason about.

func (*Client) Skills

func (c *Client) Skills(ctx context.Context, sessionPath string) ([]attach.SkillInfo, error)

Skills calls GET <base>/sessions/<sid>/skills. Backs the remote TUI's /skills slash.

func (*Client) SlashBtw

func (c *Client) SlashBtw(ctx context.Context, sessionPath, question string) (attach.SideQueryResponse, error)

SlashBtw calls POST <base>/sessions/<sid>/slash/btw. Synchronous. Backs the remote TUI's /btw slash. The answer renders as a dismissible overlay (no event-log persistence).

Returns the whole response rather than just the text: an answered call and an empty one are both 200s (protocol 1.5.0), and the caller needs Empty + Detail to tell the operator which one happened.

func (*Client) SlashCompact

func (c *Client) SlashCompact(ctx context.Context, sessionPath, focus string) (attach.CompactResponse, error)

SlashCompact calls POST <base>/sessions/<sid>/slash/compact. Synchronous: blocks until the compaction summarizer completes (5–30s typical for real model calls). The remote TUI should render the in-chat preamble row at dispatch — this call does NOT emit a preamble itself.

func (*Client) SlashDone

func (c *Client) SlashDone(ctx context.Context, sessionPath, note string) (attach.CheckpointResponse, error)

SlashDone calls POST <base>/sessions/<sid>/slash/done. Synchronous. Backs the remote TUI's /done slash.

func (*Client) SlashSubagent

func (c *Client) SlashSubagent(ctx context.Context, sessionPath string, spec attach.SubagentSpec) (attach.SubagentSpawnResponse, error)

SlashSubagent calls POST <base>/sessions/<sid>/slash/subagent. Backs the remote TUI's /subagent slash. Returns the spawn confirmation (name + started_at); the subagent's events flow through the existing SSE stream under a branch label so the operator sees its turns alongside the parent's.

func (*Client) Status

func (c *Client) Status(ctx context.Context, sessionPath string) (attach.StatusInfo, error)

Status calls GET <base>/sessions/<sid>/status.

func (*Client) StopAgent added in v2.9.0

func (c *Client) StopAgent(ctx context.Context, sessionPath, name string) (attach.StopAgentResponse, error)

StopAgent calls POST <base>/sessions/<sid>/agents/<name>/stop — stop one runaway background subagent, which interrupting the parent can't reach.

The response distinguishes "this call halted it" (Stopped) from "it had already finished" (Stopped=false with a terminal Status); a 404 means only that no subagent by that name was ever registered. A pre-1.12.0 daemon reports Stopped=true for both, and no Status.

func (*Client) Stream

func (c *Client) Stream(ctx context.Context, sessionPath string, since int64) (<-chan attach.Frame, error)

Stream connects to <base><sessionPath>/events?since=<since> and returns a channel of decoded frames. Closes the channel on ctx cancel, stream error, or upstream EOF. Errors that prevented the initial GET (network failure, non-200 status) are returned synchronously; downstream errors land in the returned channel's error field via the second return value being closed.

The lossless-replay property of the protocol means that passing a non-zero since value asks the server to replay any frames since that sequence before resuming live tail.

func (*Client) SubagentEvents added in v2.9.0

func (c *Client) SubagentEvents(ctx context.Context, sessionPath, name string, since int64, limit int) (attach.SubagentEventsResponse, error)

SubagentEvents calls GET <base>/sessions/<sid>/agents/<name>/events — one subagent's inner turns, paged from the seq cursor `since` (0 for the whole history). limit <= 0 leaves the page size to the server.

A name the server can't resolve comes back as a *SubagentNotFoundError carrying the names that would have resolved, not as an empty page: "no such subagent" and "this subagent recorded no turns" are different answers, and collapsing them is the failure #694 fixed server-side. Any other non-2xx stays an httpStatusError.

func (*Client) Tools

func (c *Client) Tools(ctx context.Context, sessionPath string) ([]attach.ToolInfo, error)

Tools calls GET <base>/sessions/<sid>/tools. Returns the parsed list; empty (not nil) if the session doesn't implement the provider.

func (*Client) Usage

func (c *Client) Usage(ctx context.Context, sessionPath string) (attach.UsageInfo, error)

Usage calls GET <base>/sessions/<sid>/usage. Backs the remote TUI's /stats slash. Returns zero UsageInfo if the agent doesn't implement the capability (server returns 501).

func (*Client) Wake

func (c *Client) Wake(ctx context.Context, sessionPath string) error

Wake calls POST <base>/sessions/<sid>/wake.

type Credentials

type Credentials interface {
	// Apply stamps headers on req. Returns an error when the
	// underlying credential source fails to produce a token —
	// callers propagate (the request is not sent in that case).
	Apply(req *http.Request) error
}

Credentials stamps authentication headers on outbound requests. Implementations must be safe for concurrent use from multiple goroutines (the Client uses one Credentials for every request, including parallel RPC + SSE).

type GoogleIDTokenCreds

type GoogleIDTokenCreds struct {
	Source      oauth2.TokenSource
	AttachToken string
}

GoogleIDTokenCreds is the audience-bound variant. The Source produces a Google ID token bound to a specific audience (the gateway's expected audience — service URL for Cloud Run, OAuth client ID for IAP). Use when audience-binding is required (IAP) or when a service explicitly requires ID tokens.

Important constraint: idtoken.NewTokenSource does NOT accept end-user (authorized_user) credentials — operators using gcloud auth application-default login will hit "unsupported credentials type: authorized_user" at construction time. Workarounds:

  • Re-login with impersonation: gcloud auth application-default login --impersonate-service-account=SA_EMAIL
  • Set GOOGLE_APPLICATION_CREDENTIALS to a service-account JSON key
  • Use --auth=google-oauth instead (Cloud Run IAM accepts access tokens; the audience-binding loss is mostly theoretical)

AttachToken may be empty when the daemon runs without --attach-token ("Posture B"). The header is omitted entirely in that case.

func (GoogleIDTokenCreds) Apply

func (c GoogleIDTokenCreds) Apply(req *http.Request) error

Apply implements Credentials.

type GoogleOAuthCreds

type GoogleOAuthCreds struct {
	Source      oauth2.TokenSource
	AttachToken string
}

GoogleOAuthCreds wraps a Google OAuth2 access-token source (typically google.FindDefaultCredentials's TokenSource) and stamps the access token on Authorization. Works with every ADC shape end users actually have on their workstations — end-user (authorized_user) creds, service-account JSON keys, metadata server, impersonation.

This is the right default for Cloud Run IAM: the gateway accepts either OAuth access tokens OR audience-bound ID tokens, and access tokens come for free from end-user ADC. Mirrors MCP's googleAuthTransport pattern (pkg/mcp/lifecycle.go:296).

AttachToken may be empty when the daemon runs without --attach-token ("Posture B"). The header is omitted entirely in that case.

func (GoogleOAuthCreds) Apply

func (c GoogleOAuthCreds) Apply(req *http.Request) error

Apply implements Credentials.

type InterruptResponse

type InterruptResponse = attach.InterruptResponse

InterruptResponse is the parsed body of POST /sessions/<sid>/interrupt. Interrupted reports whether there was an in-flight turn to cancel (server-side); false means the agent was idle and the call was a no-op. The TUI distinguishes these for its "nothing to interrupt" toast vs. "turn cancelled" rendering. Paused reports whether the loop is now parked (protocol v1.5.0).

Alias rather than a second declaration: this used to be a hand-copy of the server shape, which is how it silently missed every field v1.5.0 added.

type NewSessionResponse

type NewSessionResponse struct {
	AppName   string `json:"app"`
	UserID    string `json:"user"`
	SessionID string `json:"sessionID"`
	URL       string `json:"url"`
}

NewSessionResponse mirrors the attach server's POST /sessions 201 body — the new session's triple plus the absolute URL the client should attach to (events / inject / status / etc. live underneath).

type ParsedURL

type ParsedURL struct {
	Scheme     string // http | https | unix
	Host       string // host:port (empty for unix)
	SocketPath string // for unix scheme
	BaseURL    string // ready-to-use for HTTP client: http(s)://host OR http://unix placeholder
	Session    string // /sessions/<...> path; empty for list endpoints
}

ParsedURL holds the components of an attach-mode URL. Three schemes are accepted: http://, https://, unix:// (the last for Unix-socket listeners — convention is unix:///path/to/socket/sessions/<sid>).

Session is non-empty when the URL targets a specific session (e.g. /sessions/<sid> or /sessions/<app>/<sid>). For listing endpoints (GET /sessions) Session is empty.

func ParseURL

func ParseURL(raw string) (*ParsedURL, error)

ParseURL decodes raw into a ParsedURL. Returns a clear error for unsupported schemes so the caller can surface "want http, https, or unix" without digging into url.Parse internals.

func (*ParsedURL) IsHubURL

func (p *ParsedURL) IsHubURL() bool

IsHubURL is a heuristic: hub URLs target the root (no /sessions/<id> suffix). Used by the TUI to decide whether to enumerate peer sessions in the picker or just list this listener's sessions.

type PeerDescriptor

type PeerDescriptor struct {
	RegistrationID string            `json:"registration_id"`
	Name           string            `json:"name"`
	Endpoint       string            `json:"endpoint"`
	Labels         map[string]string `json:"labels,omitempty"`
}

PeerDescriptor mirrors the attach server's GET /peers row.

type RateLimitError added in v2.9.0

type RateLimitError struct {

	// RetryAfter is the server's Retry-After, rounded to whole
	// seconds. Zero when the header was absent or unparseable — the
	// message then just says the request was rate limited.
	RetryAfter time.Duration
	// contains filtered or unexported fields
}

RateLimitError is the typed form of the daemon's 429. The attach server's cost limiter (10/min, burst 5) sits in front of the cost-bearing operator endpoints, so a few quick /btw questions in a row hit it — and rendered as a bare "status 429: {...}" that reads as a broken daemon rather than as "you're going too fast".

Wraps the underlying httpStatusError, so callers that classify on the status code (errors.As on *httpStatusError, the stream's permanent-vs-transient check) keep working unchanged.

func (*RateLimitError) Error added in v2.9.0

func (e *RateLimitError) Error() string

func (RateLimitError) PermanentStreamErr added in v2.9.0

func (e RateLimitError) PermanentStreamErr() bool

PermanentStreamErr satisfies core-tui's PermanentStreamError interface. Return true on statuses that will not recover by retrying the same URL with the same token:

  • 404: the session was evicted (daemon restart, TTL expiry, operator DELETE /sessions).
  • 401: the bearer token is invalid or revoked.
  • 403: the caller is authenticated but the ACL denies session access (typical after an owner rotates the ACL).

Everything else (500s, transport errors, 429) stays retryable — those are transient by convention and the TUI's reconnect loop eventually succeeds.

func (*RateLimitError) Unwrap added in v2.9.0

func (e *RateLimitError) Unwrap() error

type SessionDescriptor

type SessionDescriptor struct {
	App         string `json:"app"`
	User        string `json:"user"`
	SessionID   string `json:"sessionID"`
	HasEventLog bool   `json:"has_event_log"`
	// Status is "active" (live in the listener's registry) or "idle"
	// (known only from the persisted ACL store — attaching triggers a
	// lazy resume, so the first frame costs more).
	Status string `json:"status"`
	// LastTouchedAt is the server's last-activity stamp. Omitted by
	// listeners that don't track it, hence the zero-value check at
	// every read site.
	LastTouchedAt time.Time `json:"last_touched_at"`
	// Title is the short operator-facing label for the session, derived
	// from its first prompt. Empty against a listener older than
	// protocol 1.6.0, against a session whose first turn hasn't landed
	// yet, and against a host that disabled titling — every read site
	// needs a fallback to the ID.
	Title string `json:"title"`
}

SessionDescriptor mirrors the attach server's GET /sessions row.

type SubagentNotFoundError added in v2.9.0

type SubagentNotFoundError struct {
	// Name is the subagent name that was queried.
	Name string
	// Available lists the subagent names that do resolve in this
	// session. Empty means no subagent has run here at all.
	Available []string
	// Message is the server's own phrasing, kept so the reason
	// ("no turns recorded ...") survives the projection.
	Message string
}

SubagentNotFoundError is the typed form of the 404 that GET /sessions/<sid>/agents/<name>/events returns for a name that resolves to nothing. Available carries the names that WOULD have resolved.

Deliberately NOT an httpStatusError: this 404 is an answer about the name asked for, not a statement about the session, so it must not be classified as a permanent stream error — a mistyped /subagents query shouldn't tear down the attach stream.

func (*SubagentNotFoundError) Error added in v2.9.0

func (e *SubagentNotFoundError) Error() string

Jump to

Keyboard shortcuts

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