ws

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 8, 2026 License: MIT Imports: 28 Imported by: 0

Documentation

Overview

Package ws implements the WebSocket layer for tmux-webui.

Architecture

A single Hub manages the set of active connections. Each Conn represents one WebSocket client and owns three goroutines tied together by an errgroup:

  • reader – blocks on wsConn.Read, parses inbound JSON, dispatches handlers
  • writer – drains outChan → wsConn.Write (only goroutine allowed to write)
  • heartbeat – 15-second ticker that sends {type:"ping", ts:epoch_ms}

Polling is NOT per-conn. The Hub runs one shared sessionPoller goroutine per tmux session (poller.go), started when the first Conn subscribes and stopped when the last leaves. It does the adaptive-interval capture/diff (0.4s→0.8→1.2→1.6→2.0s, resets on change or poke), the 1s pane_states tick, and the metrics cadence ONCE per session, fanning typed frames out to each subscriber (each keeps its own activeWindow view). Sysmon metrics are cached at Hub level so N sessions share one fetch per interval (P3).

Any conn goroutine returning cancels the shared context, causing the others to exit. Deferred cleanup unsubscribes from the poller, restores layouts, calls Hub.Remove, and ws.Close.

Protocol (client → server)

focus              {pane: string}
input              {pane, text}
key                {pane, key, modifiers?: []string}
switch_window      {window: int}
new_window         {}
close_window       {window: int}
fit                {pane, cols, rows}
fit_enable         {}
fit_disable        {}
select_pane_direction {pane, direction: "left"|"right"|"up"|"down"}
autocomplete       {query: string}  (stub: returns empty results)
refresh_panes      {}
pong               {ts}            (client reply; ignored)

Protocol (server → client)

error              {message}
windows            {windows: [...], active: int}
panes              {panes: [...]}
output             {panes: {paneId: content}}  // incremental, changed only
metrics            {metrics: {}}
ping               {ts: epoch_ms}
prefix_active      {}
input_error        {message}
tts                {id, text}  // via Hub.Broadcast
autocomplete       {results: []}

Integration with server.go

Replace the /ws stub in server.go with the following:

import (
    "github.com/operonlab/tmux-webui/internal/prefix"
    "github.com/operonlab/tmux-webui/internal/ws"
)

// In New() or Mux(), after building tx and cfg:
prefixCache := prefix.New(tx)
prov := metrics.NewStub() // or metrics.NewHTTP(url)
hub := ws.NewHub(cfg, tx, prefixCache, prov)

// Replace the /ws stub in Mux():
//   mux.HandleFunc("/ws", notImplemented(...))
// with:
mux.Handle("/ws", hub.UpgradeHandler())

The Hub is safe for concurrent use and has no background goroutines of its own. All per-connection goroutines are started in Conn.Run (called from UpgradeHandler).

Index

Constants

This section is empty.

Variables

View Source
var LivenessTimeout = 45 * time.Second

LivenessTimeout is how long a conn may stay inbound-silent before the server tears it down as a zombie. The client answers every ping with a pong, so a healthy conn speaks at least once per ping interval; the ping interval is derived as LivenessTimeout/3, i.e. three missed pings = dead. Without this, a phone that vanishes mid-air (network flap, iOS PWA backgrounding — WebKit 247943 often never delivers a FIN) leaves a zombie conn holding its poller subscription until a TCP write finally errors. Package var (not config) so the liveness test can shorten it.

Functions

func RegisterAgentCommands

func RegisterAgentCommands(extra, hostExtra []string)

RegisterAgentCommands appends config-provided agent commands to the builtin classifier sets (never replaces them). Call once at startup, before serving, so the one-time write happens-before any poller/monitor read.

Types

type AgentStatus

type AgentStatus struct {
	Host        string `json:"host"`
	Session     string `json:"session"`
	Window      int    `json:"window"`
	WindowName  string `json:"windowName"`
	Pane        int    `json:"pane"`
	PaneID      string `json:"paneID"`
	Tool        string `json:"tool"`  // pane_current_command (claude/codex/…)
	State       string `json:"state"` // working | waiting | idle | unknown
	SinceUnix   int64  `json:"sinceUnix"`
	TailPreview string `json:"tailPreview"`

	// I5 context/token burn, parsed from the pane's status line
	// (`context: 12.3% (32.1k/262.1k)`). All three are 0 when the line is
	// absent/legacy — the deck renders a blank badge, never an error.
	ContextPct float64 `json:"contextPct"`
	TokensUsed int     `json:"tokensUsed"`
	TokensMax  int     `json:"tokensMax"`

	// I3 per-pane state timeline: the pane's recent state transitions (bounded
	// ring, oldest→newest, each = the state entered + the unix second it was
	// entered). Drives the deck's live "waiting 4m12s" + working/waiting/idle mini
	// split. omitempty so a legacy payload / never-classified pane stays lean.
	Recent []StateEvent `json:"recent,omitempty"`
}

AgentStatus is one agent pane's cross-session status, the unit of the Monitor's snapshot and the GET /api/agents payload. Field JSON tags are camelCase to match the deck's other agent frames (pane_states is enum-only; this is the richer status the triage/rail/push all read).

type Conn

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

Conn is the per-WebSocket-connection state. It must be created via Hub.UpgradeHandler; never construct directly.

func (*Conn) Run

func (c *Conn) Run()

Run starts the four goroutines and blocks until all exit (errgroup). On return the connection is fully torn down (layout restore + hub remove + ws close).

type Frame

type Frame struct {
	Content string `json:"content"`
	AtUnix  int64  `json:"atUnix"`
}

Frame is one captured pane content snapshot: the raw (ANSI-preserved) content and the unix second it was captured. Ships inside the GET frames payload; camelCase JSON to match the deck's other agent frames.

type Hub

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

Hub manages all active WebSocket connections. Safe for concurrent use.

func NewHub

func NewHub(cfg *config.Config, tx *tmuxctl.Client, pc *prefix.Cache, prov metrics.Provider) *Hub

NewHub constructs a Hub. cfg, tx, pc, and prov must not be nil. Pass metrics.NewStub() for prov when metrics are disabled.

func (*Hub) Broadcast

func (h *Hub) Broadcast(msg any)

Broadcast marshals msg as JSON and sends it to every registered connection. Slow or closed connections are dropped non-blocking (mirroring Conn.send).

func (*Hub) BroadcastTTS

func (h *Hub) BroadcastTTS(id, text string)

BroadcastTTS is a convenience wrapper for Hub.Broadcast with a TTS payload.

func (*Hub) Remove

func (h *Hub) Remove(c *Conn)

Remove unregisters a connection.

func (*Hub) UpgradeHandler

func (h *Hub) UpgradeHandler() http.Handler

UpgradeHandler returns an http.Handler that upgrades HTTP connections to WebSocket and runs the connection lifecycle.

It expects a "session" query parameter matching reSessionName; a missing or malformed session yields 400.

Usage in server.go:

mux.Handle("/ws", hub.UpgradeHandler())

type InboundMsg

type InboundMsg struct {
	Type string `json:"type"`

	// focus / input / key / fit / select_pane_direction
	Pane string `json:"pane,omitempty"`

	// input
	Text string `json:"text,omitempty"`

	// key
	Key       string   `json:"key,omitempty"`
	Modifiers []string `json:"modifiers,omitempty"`

	// switch_window / close_window / rename_window
	Window int `json:"window,omitempty"`

	// rename_window
	Name string `json:"name,omitempty"`

	// fit
	Cols int `json:"cols,omitempty"`
	Rows int `json:"rows,omitempty"`

	// select_pane_direction
	Direction string `json:"direction,omitempty"`

	// mouse (TUI passthrough): MButton left|middle|right|wheelup|wheeldown,
	// MPhase press|release (wheel ignores phase), MX/MY = 1-based col/row.
	MButton string `json:"mbutton,omitempty"`
	MPhase  string `json:"mphase,omitempty"`
	MX      int    `json:"mx,omitempty"`
	MY      int    `json:"my,omitempty"`

	// input ack correlation (echoed back in input_ok / input_error)
	Nonce string `json:"nonce,omitempty"`

	// autocomplete
	Query string `json:"query,omitempty"`

	// pong
	Ts int64 `json:"ts,omitempty"`

	// link mode (see link.go)
	Group      string `json:"group,omitempty"`
	ClientID   string `json:"client_id,omitempty"`
	Device     string `json:"device,omitempty"`
	Session    string `json:"session,omitempty"`
	Seq        int64  `json:"seq,omitempty"`
	Create     bool   `json:"create,omitempty"`
	LeaderOnly bool   `json:"leader_only,omitempty"`
}

InboundMsg is the envelope parsed from every client frame.

type Monitor

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

Monitor is the cross-session agent watcher: a server-lifetime goroutine that scans every session on an interval, classifies each agent pane, and both (a) maintains a live all-sessions status snapshot for the UI (GET /api/agents) and (b) POSTs a push payload when a pane transitions INTO the "waiting on you" state.

It generalizes the former Notifier — one scanner backs the push AND the UI, so a lamp the deck shows as "waiting" is the exact signal that fires a push: no second, drifting definition. The snapshot is ALWAYS maintained (the deck needs it regardless); only the push side is gated on cfg.WebhookURL, so a stock server keeps a live snapshot but never POSTs.

It reuses the poller's classification verbatim (isAgentCommand + classifyPane + stateDebouncer), so the states here are the same as the deck's pane lamps.

func NewMonitor

func NewMonitor(cfg config.NotifyConfig, tx monitorReader) *Monitor

func (*Monitor) Frames

func (m *Monitor) Frames(session, paneID string) ([]Frame, bool)

func (*Monitor) IsRecording

func (m *Monitor) IsRecording(session, paneID string) bool

func (*Monitor) ResolveAgentPane

func (m *Monitor) ResolveAgentPane(session, pane string) (paneID string, ok bool)

ResolveAgentPane maps a (session, pane) request — where pane is either the tmux pane id ("%5") or the pane index ("2") — to the pane's canonical PaneID, but ONLY if it currently appears in the snapshot (which holds agent panes only). This is the I4 gate: a non-agent / unknown pane returns ok=false, so the record/frames handlers reject it. Matching PaneID first keeps it unambiguous when the caller passes the id from /api/agents; the index match is a convenience for the first pane of that index.

func (*Monitor) Run

func (m *Monitor) Run(ctx context.Context)

Run blocks until ctx is cancelled; start it in a goroutine at server startup. Unlike the old Notifier it does NOT early-return on a missing WebhookURL — it always maintains the snapshot; the push is gated per-pane inside scan.

func (*Monitor) Snapshot

func (m *Monitor) Snapshot() ([]AgentStatus, time.Time)

Snapshot returns a copy of the current agent status list and the time it was generated. Safe to call from any goroutine.

func (*Monitor) StartRecording

func (m *Monitor) StartRecording(session, paneID string)

StartRecording / StopRecording / IsRecording / Frames are the I4 handler surface, keyed by the same session-namespaced pane key the scan uses.

func (*Monitor) StopRecording

func (m *Monitor) StopRecording(session, paneID string)

type StateEvent

type StateEvent struct {
	State  string `json:"state"`
	AtUnix int64  `json:"atUnix"`
}

StateEvent is one entry in a pane's state-transition timeline: the state the pane ENTERED and the unix second it entered it. Ships inside AgentStatus.recent[].

Jump to

Keyboard shortcuts

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