Documentation
¶
Overview ¶
Package cats is dbc's client for the cats terminal multiplexer it may be running inside: the control socket (one JSON request per line, one response back), the event stream that socket can be upgraded to, and the separate hook socket a pane program reports its state on.
It is stdlib only, and it obeys two house rules:
- NO UI STATE. Nothing in here touches the UI's model. Background goroutines hand results to callbacks whose only job is to post a message to the UI goroutine; the UI's Update is the sole mutator. See tui/cats.go.
- SILENT DEGRADATION. Every failure — not inside cats, socket missing, socket dead, cats restarted, protocol mismatch — ends at Tier 0, where dbc behaves exactly as it does in any other terminal. No feature may exist only at Tier 1.
THE WIRE STRUCTS ARE HAND-COPIED MIRRORS of cats' own (internal/app/command_vocab.go, internal/app/events.go, cmd/catway/hooks.go), deliberately minimal — only the fields dbc reads or sends. dbc must NOT import the cats module: it has to stay buildable and installable on a machine that has never heard of cats, and a shared type package would make the database client's build depend on the multiplexer's.
Detection is split in two on purpose:
DetectEnv() reads four env vars. No IO, so startup can call it inline.
Caps.Probe() dials the control socket and pings. Milliseconds when the
socket is live or absent, but a wedged listener could hold
it for ProbeTimeout — so the app runs it on a goroutine and
posts the answer back.
The hook reporter: how dbc tells cats what it is doing, so cats can badge the pane, raise a toast, or push to a phone when a long query finishes — the same channel the coding-agent hooks use, and the reason dbc gets that whole notification story for ~150 lines rather than by growing a notifier of its own.
This is a DIFFERENT socket from the control API (CATS_SOCKET_PATH, not CATS_CONTROL_SOCKET) with a different envelope, so it is available even when the control socket is not — a distinction worth keeping, because the attention story is the one that matters when you are not looking at the screen. It is also why the reporter is armed from the environment alone, before the control-socket probe has answered.
Three design points:
- SOURCE IS "dbc", A CUSTOM SOURCE. The reserved "cats:<agent>" sources (cats:claude, cats:codex …) have their state driven by cats' own process detection, and a hook claiming one of those names gets its state ignored. dbc is not one of those agents, so it takes an unreserved name and receives real state authority for its pane.
- SEQ IS ALLOCATED AT CALL TIME, NOT AT SEND TIME, AND IS SEEDED FROM THE CLOCK. Reports are sent on their own goroutines, so two of them can reach the socket out of order; the server keeps a per-source high-water mark and drops anything stale. Stamping the number in ReportState — on the UI goroutine, in the order the transitions actually happened — is what makes that mechanism protect us instead of a race we lose at random. The seed matters just as much: the high-water mark lives on the PANE and outlives this process, so a counter starting at 1 would have every report from a re-launched dbc in the same pane silently dropped as stale.
- SENDING IS FIRE AND FORGET. The reply is not read and failures are not surfaced. A hook socket that is gone, wedged, or belongs to a cats that forgot this pane must cost dbc nothing at all; there is no user-facing decision that a failed status report could inform.
Index ¶
- Constants
- Variables
- func ParsePaneHandle(handle string) (uint32, bool)
- type Caps
- type Client
- func (c *Client) Call(method string, params, out any) error
- func (c *Client) ChatSend(text string) error
- func (c *Client) ConfigGet() (ConfigGetResult, error)
- func (c *Client) PaneFocus(pane uint32) error
- func (c *Client) PaneList() ([]PaneInfo, error)
- func (c *Client) PaneSendInput(pane uint32, text string, submit bool) error
- func (c *Client) Ping() (Pong, error)
- func (c *Client) ResolvePane(handle string) (uint32, error)
- type ConfigGetResult
- type ConfigTheme
- type Event
- type PaneAgentEvent
- type PaneInfo
- type PaneListResult
- type PaneNotifyEvent
- type PaneRefEvent
- type Pong
- type Reporter
- type Stream
- type SubscribeFilter
- type ThemeChangedEvent
Constants ¶
const ( MethodPing = "ping" MethodPaneList = "pane.list" MethodPaneFocus = "pane.focus" MethodPaneSendIn = "pane.send_input" MethodChatSend = "chat.send" MethodConfigGet = "config.get" MethodSubscribe = "events.subscribe" )
Control-API verb names (cats internal/app/command_vocab.go §7 table). Only the ones dbc uses are mirrored; the table is much larger.
The deliberate omissions are worth naming, because they are the growth path rather than an oversight: capture + pane.wait_for_output would let dbc read a sibling agent's answer back, and pane.split would let it open a result in a second dbc. Neither has a consumer yet, and a mirror struct with no caller is a wire contract nobody is checking.
const ( EnvMarker = "CATS_ENV" EnvPaneID = "CATS_PANE_ID" EnvControlSocket = "CATS_CONTROL_SOCKET" EnvHookSocket = "CATS_SOCKET_PATH" )
The environment cats gives every pane it spawns (cats internal/integration + cmd/catway's pane env). CATS_ENV is the "you are inside cats" marker; the other three are the addresses.
const ( EventPaneExited = "pane_exited" EventPaneAgent = "pane_agent" EventPaneNotify = "pane_notify" EventPaneAdded = "pane_added" EventPaneRemoved = "pane_removed" EventFocusChanged = "focus_changed" // EventThemeChanged is the one SESSION-scoped event: it names no pane, so // cats emits it with pane 0 and a pane-filtered subscription would never // see it. dbc subscribes by event name and never by pane, so that costs // nothing here — it is written down because a later narrowing of the // filter to "our own pane" would silently lose the theme. EventThemeChanged = "theme_changed" )
Event names (cats internal/app/events.go).
const ( StateIdle = "idle" StateWorking = "working" StateBlocked = "blocked" StateUnknown = "unknown" )
The four states the hook API accepts. Anything else is rejected by the server, so callers use these rather than raw strings.
const ( SourceDBC = "dbc" AgentDBC = "dbc" )
SourceDBC is the reporter's source name and AgentDBC the label cats shows for the pane. Deliberately not "cats:dbc": that prefix names cats' own built-in agent integrations, whose state is detection-driven and whose hook reports are dropped.
Variables ¶
var ProbeTimeout = 500 * time.Millisecond
ProbeTimeout bounds the ping round trip that decides Tier 1. Short on purpose: the socket may belong to a wedged server, and the right answer to that is to fall back to Tier 0 quickly, not to diagnose it. A package var so tests can collapse the window rather than sleep through it.
Functions ¶
func ParsePaneHandle ¶
ParsePaneHandle extracts the internal pane id from the "p_<n>" handle form cats uses when a pane has no public id assigned yet. The public "w1:p3" form encodes workspace and per-workspace index, not the internal id, so it reports false — resolving it takes a pane.list round trip.
Types ¶
type Caps ¶
type Caps struct {
// InCats reports the env marker plus a pane handle: we are running in a
// cats pane. True even when the control socket is unusable — the hook
// socket is a separate address and may still work.
InCats bool
// Control reports that the control socket answered a ping. This is the
// Tier-1 gate: a socket FILE proves nothing (a crashed server leaves one
// behind), and even a successful dial proves only that something is
// listening. Only a reply identifies a live cats.
Control bool
// Hooks reports that a hook socket path was given and exists. Not
// pinged: the hook API is one-shot fire-and-forget with no liveness
// verb, and a reporter that silently no-ops is exactly the degradation
// we want anyway.
Hooks bool
// PaneHandle is CATS_PANE_ID verbatim — the public "w1:p3" form, or the
// "p_<raw>" fallback cats emits when a pane has no public id yet. It is
// what the HOOK api addresses a pane by, so it is used as-is there.
PaneHandle string
// PaneID is the internal numeric id every CONTROL command addresses a
// pane by, and PaneIDKnown reports whether we have it. Only the "p_<n>"
// handle carries it inline; the public "w1:p3" form has to be resolved
// against pane.list (Client.ResolvePane), which needs the socket that
// Control is still being decided for at detection time.
PaneID uint32
PaneIDKnown bool
ControlSocket string
HookSocket string
// Service and Protocol are the ping reply's self-identification, kept for
// the log line and for future protocol gating. The service name is
// deliberately NOT matched against a constant: catway answers "catway",
// a sibling implementation answers its own name, and a client that
// rejected an unfamiliar one would break on the very host it is meant to
// work with.
Service string
Protocol int
// Reason explains a Tier-0 verdict in one phrase ("not running inside a
// cats pane", "control socket not answering"). Empty at Tier 1. Nothing
// branches on it — it exists so the degradation is silent but not
// INVISIBLE when a user goes looking for why.
Reason string
}
Caps is what this process knows about its host. Zero value = Tier 0, which is also what every failure path produces, so a caller that ignores errors still gets correct behavior.
func Detect ¶
func Detect() Caps
Detect is DetectEnv followed by Probe — the whole answer in one call, for callers that can afford the IO (tests, a future `dbc --cats-status`). The app deliberately does not use it; see the package comment.
func DetectEnv ¶
func DetectEnv() Caps
DetectEnv reads the pane environment and nothing else — no IO, so startup can call it inline and know immediately whether a probe is even worth scheduling. Control is always false here; Probe decides it.
func (Caps) Probe ¶
Probe completes the detection: dial the control socket and ping it. The receiver is what DetectEnv returned; the result is the same value with Control (and Service/Protocol/Reason) filled in, so a caller can hand one value across the goroutine boundary and back.
A returned Caps is always usable: every error path leaves Control false with a Reason, which is Tier 0.
type Client ¶
type Client struct {
Socket string
// Timeout bounds one whole call (dial + write + read). Zero means
// defaultCallTimeout. It is a field rather than a constant so a caller
// with a different budget — a startup probe that must give up fast —
// can hand out a copy rather than mutate the shared client.
Timeout time.Duration
}
Client is a control socket address plus the timeout its calls run under. It holds no connection and no lock: every call dials fresh, so a Client is safe to share across goroutines and cheap to keep on the App forever.
func NewClient ¶
NewClient returns a client for the given control socket path. It performs no IO — an unreachable socket is discovered by the first call, which is also the only place a caller can do anything about it.
func (*Client) Call ¶
Call runs one control command. out may be nil for verbs whose reply carries no data; when non-nil it is json.Unmarshal'd from the response's data field.
func (*Client) ChatSend ¶
ChatSend posts one user turn to cats' own chat panel — the "ask cats about this query" path, which reaches the host's agent rather than a sibling pane.
func (*Client) ConfigGet ¶
func (c *Client) ConfigGet() (ConfigGetResult, error)
ConfigGet reads the host's configuration; dbc uses the theme section.
func (*Client) PaneFocus ¶
PaneFocus focuses a pane within its tab — the follow-through for "I sent that query next door", so the user lands where the text went.
func (*Client) PaneList ¶
PaneList returns every pane in the session, with the agent/title/cwd metadata cats' own sidebar shows.
func (*Client) PaneSendInput ¶
PaneSendInput injects text into a pane's PTY as though typed there. submit false stages the text for the user to read and press Enter on themselves — which is the setting dbc uses for "send this statement to the agent pane", because putting a query in an agent's mouth and running it is a different act from handing it one to look at.
func (*Client) Ping ¶
Ping asks the server to identify itself. It is the Tier-1 gate (see Caps.Probe) and the cheapest possible liveness check.
func (*Client) ResolvePane ¶
ResolvePane turns the public pane handle from the environment ("w1:p3") into the internal id control commands take. The "p_<n>" form is decoded locally with no round trip; anything else costs one pane.list.
Its real job is answering "which pane am I?", which nothing in the pane environment states directly and which a picker needs in order to leave dbc itself off the list of panes to talk to.
type ConfigGetResult ¶
type ConfigGetResult struct {
Path string `json:"path"`
Theme ConfigTheme `json:"theme"`
}
ConfigGetResult is config.get's payload, trimmed to the appearance fields. Theme is the EFFECTIVE appearance (the user's per-key overrides already folded in), which is the one a host-matching client should follow.
type ConfigTheme ¶
type ConfigTheme struct {
Name string `json:"name,omitempty"`
Colors map[string]string `json:"colors,omitempty"`
Font string `json:"font,omitempty"`
}
ConfigTheme is the appearance section of config.get: the active theme name and its resolved color map, which is what a host-matching dbc palette is built from.
type Event ¶
type Event struct {
Name string `json:"event"`
Data json.RawMessage `json:"data,omitempty"`
}
Event is one frame from the stream: the event name and its raw payload. Decoding is the consumer's job (see the typed payloads below) because the glue dispatches on the name anyway.
type PaneAgentEvent ¶
type PaneAgentEvent struct {
Pane uint32 `json:"pane"`
Agent string `json:"agent"`
State string `json:"state"`
}
PaneAgentEvent is EventPaneAgent's payload: a pane's detected agent identity or activity state changed. State is idle|working|blocked|unknown; Agent is "" for a plain shell.
type PaneInfo ¶
type PaneInfo struct {
Pane uint32 `json:"pane"`
Handle string `json:"handle,omitempty"`
Name string `json:"name,omitempty"`
Focused bool `json:"focused"`
Visible bool `json:"visible"`
Agent string `json:"agent,omitempty"`
AgentState string `json:"agent_state,omitempty"`
Title string `json:"title,omitempty"`
Cwd string `json:"cwd,omitempty"`
}
PaneInfo is one row of pane.list. Pane is the internal id every other command addresses a pane by; Handle is the public "w1:p3" label the pane environment carries — the pair is what makes ResolvePane possible, and what lets dbc recognize (and exclude) its own pane in a picker.
The agent fields are cats' arbitrated identity for the pane, the same one its sidebar shows. cats flattens them into this object rather than nesting a meta struct, so the mirror inlines them too.
type PaneListResult ¶
type PaneListResult struct {
Panes []PaneInfo `json:"panes"`
}
PaneListResult is pane.list's payload.
type PaneNotifyEvent ¶
type PaneNotifyEvent struct {
Pane uint32 `json:"pane"`
Agent string `json:"agent"`
Kind string `json:"kind"`
Message string `json:"message"`
}
PaneNotifyEvent is EventPaneNotify's payload: the moment cats itself considers notification-worthy — an agent hit a blocker ("attention") or a background run finished ("finished").
type PaneRefEvent ¶
PaneRefEvent is the payload of the structural events (added, removed, focus_changed): they name a pane and nothing more. For focus_changed, Pane is the newly focused pane (0 for none).
type Reporter ¶
type Reporter struct {
// contains filtered or unexported fields
}
Reporter reports dbc's state to cats. A nil *Reporter is the Tier-0 form and every method on it is a no-op, so call sites need no availability check.
func NewReporter ¶
NewReporter builds a reporter for a pane, or returns nil when either half of the address is missing. Callers keep the nil and go on.
The sequence is seeded from the clock so this instance's numbers land above whatever a previous dbc in the same pane left as the server's high-water mark — see the file comment.
func (*Reporter) Release ¶
func (r *Reporter) Release()
Release withdraws dbc's claim on the pane, so the pane stops being labeled "dbc" the moment the process exits instead of wearing a stale badge until something else claims it.
Unlike ReportState this one BLOCKS, briefly: it runs from shutdown, where a goroutine posted on the way out would be killed before it ever dialed. hookTimeout is the whole cost.
func (*Reporter) ReportState ¶
ReportState publishes a state transition. customStatus is the short phrase cats shows beside the pane ("query", "script report.go") — it is what turns a badge into a message worth acting on, so pass one whenever the state is anything but idle.
Returns immediately: the write happens on its own goroutine.
type Stream ¶
type Stream struct {
// contains filtered or unexported fields
}
Stream is a live subscription with its reader goroutine. Create one with Subscribe and stop it with Close; it needs no other handling — a dropped connection is the loop's own problem, not the caller's.
func Subscribe ¶
func Subscribe(socket string, filter SubscribeFilter, on func(Event), onState func(up bool, err error)) *Stream
Subscribe starts a subscription and returns immediately; the connection is made on the returned Stream's own goroutine, so a dead socket costs the caller nothing.
on receives every matching event. onState (may be nil) is called when the connection comes up or goes down — the stream is the only thing that notices a cats that died AFTER startup detection said Tier 1.
Both callbacks run on the reader goroutine. Neither may touch App state.
func (*Stream) Close ¶
func (s *Stream) Close()
Close stops the subscription and waits for its goroutine to finish, so a closed Stream can never deliver another event — which is what lets the app tear one down during shutdown without racing a callback that posts onto an event loop that has stopped. Safe to call twice, and on a nil Stream (the Tier-0 case, where nothing was ever subscribed).
type SubscribeFilter ¶
type SubscribeFilter struct {
Pane *uint32 `json:"pane,omitempty"`
Events []string `json:"events,omitempty"`
}
SubscribeFilter narrows a subscription server-side. Both fields are optional: no pane means every pane, no events means every name. dbc subscribes to the names it acts on rather than filtering client-side, so an idle process is not woken by traffic it would only discard.
type ThemeChangedEvent ¶
type ThemeChangedEvent = ConfigTheme
ThemeChangedEvent is EventThemeChanged's payload: the host's EFFECTIVE appearance after the change — resolved, with the user's per-key overrides already folded in, which is exactly what config.get's theme section carries.
It is deliberately the same type. The event and the startup poll are two deliveries of one answer, so they decode into one struct and feed one consumer; a second shape would be a second place for the host→dbc palette mapping to drift.