package main // hub.go — agent websocket hub (/agent/ws) and web websocket hub (/ws). import ( "encoding/json" "errors" "log" "net/http" "sync" "time" "github.com/gorilla/websocket" ) // Event types on the agent wire (and control frames on the web wire). const ( evHello string = "hello" evWelcome string = "welcome" evMessageStart string = "message_start" evMessageUpdate string = "message_update" evMessageEnd string = "message_end" evToolExecStart string = "tool_execution_start" evToolExecUpdate string = "tool_execution_update" evToolExecEnd string = "tool_execution_end" evAgentStart string = "agent_start" evAgentEnd string = "agent_end" evAgentSettled string = "agent_settled" evSessionInfo string = "session_info" evBye string = "bye" evPrompt string = "prompt" evAbort string = "abort" frameSessionList string = "session_list" frameEvents string = "events" frameSpawnStatus string = "spawn_status" frameSubscribe string = "subscribe" frameUnsubscribe string = "unsubscribe" evPersistedSession string = "session" // payload key of hello/session_info ) // Wire tuning constants. const ( maxFrameSize int64 = 1 << 20 // 1 MiB per inbound frame agentSendQueue int = 32 webSendQueue int = 128 webFlushInterval time.Duration = 40 * time.Millisecond webMaxPending int = 4096 // drop slow clients beyond this backlog promptSendTimeout time.Duration = 3 * time.Second writeWait time.Duration = 5 * time.Second daemonVersion int = 1 // envelope "v" ) // ErrOffline is returned when a prompt/abort targets a session with no live agent WS. var ErrOffline = errors.New("session offline") // frame is one decoded agent wire frame (envelope fields flattened with payload). type frame struct { raw []byte typ string sessionID string seq int64 ts int64 } func decodeFrame(data []byte) (frame, error) { var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return frame{}, err } f := frame{raw: data} if raw, ok := fields["type"]; ok { _ = json.Unmarshal(raw, &f.typ) } if raw, ok := fields["sessionId"]; ok { _ = json.Unmarshal(raw, &f.sessionID) } if raw, ok := fields["seq"]; ok { _ = json.Unmarshal(raw, &f.seq) } if raw, ok := fields["ts"]; ok { _ = json.Unmarshal(raw, &f.ts) } return f, nil } // SessionView is the /api/sessions item shape. type SessionView struct { ID string `json:"id"` Name *string `json:"name"` Cwd string `json:"cwd"` Model string `json:"model"` Provider string `json:"provider"` Agent bool `json:"agent"` Repo *string `json:"repo"` StartedAt int64 `json:"startedAt"` Online bool `json:"online"` LastEventAt int64 `json:"lastEventAt"` } // pendingEvent is one agent frame queued for a web subscriber. type pendingEvent struct { sessionID string seq int64 raw json.RawMessage } // webClient is one browser websocket with batched outbound delivery. type webClient struct { hub *Hub conn *websocket.Conn mu sync.Mutex sub string // subscribed sessionId, "" when none control [][]byte events []pendingEvent dropped bool closeOnce sync.Once done chan struct{} } func (c *webClient) drop() { c.closeOnce.Do(func() { close(c.done) _ = c.conn.Close() }) } func (c *webClient) deliverControl(b []byte) { c.mu.Lock() defer c.mu.Unlock() if c.dropped { return } if len(c.control)+len(c.events) >= webMaxPending { c.dropped = true go c.drop() return } c.control = append(c.control, b) } func (c *webClient) deliverEvent(e pendingEvent) { c.mu.Lock() defer c.mu.Unlock() if c.dropped { return } if len(c.control)+len(c.events) >= webMaxPending { c.dropped = true go c.drop() return } c.events = append(c.events, e) } // writePump flushes queued frames every webFlushInterval, batching events of // the subscribed session into single frames. Never blocks the hub. func (c *webClient) writePump() { ticker := time.NewTicker(webFlushInterval) defer ticker.Stop() for { select { case <-c.done: return case <-ticker.C: } c.mu.Lock() control := c.control c.control = nil events := c.events c.events = nil c.mu.Unlock() for _, b := range control { _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if err := c.conn.WriteMessage(websocket.TextMessage, b); err != nil { c.hub.dropWeb(c) return } } if len(events) == 0 { continue } items := make([]json.RawMessage, 0, len(events)) after := events[0].seq - 1 for _, e := range events { items = append(items, e.raw) } payload, err := json.Marshal(map[string]any{ "type": frameEvents, "sessionId": events[0].sessionID, "after": after, "events": items, }) if err != nil { continue } _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil { c.hub.dropWeb(c) return } } } // readPump consumes subscribe/unsubscribe frames; malformed input never kills the server. func (c *webClient) readPump() { defer c.hub.dropWeb(c) c.conn.SetReadLimit(maxFrameSize) for { _, data, err := c.conn.ReadMessage() if err != nil { return } var msg map[string]any if err := json.Unmarshal(data, &msg); err != nil { continue } typ, _ := msg["type"].(string) sessionID, _ := msg["sessionId"].(string) c.mu.Lock() switch typ { case frameSubscribe: c.sub = sessionID case frameUnsubscribe: c.sub = "" } c.mu.Unlock() } } func (c *webClient) subscription() string { c.mu.Lock() defer c.mu.Unlock() return c.sub } // agentConn is one plugin websocket bound to a session after hello. type agentConn struct { hub *Hub conn *websocket.Conn sessionID string send chan []byte done chan struct{} closeOnce sync.Once } func (a *agentConn) drop() { a.closeOnce.Do(func() { close(a.done) _ = a.conn.Close() }) } // writePump serializes daemon→plugin frames (prompt/abort/welcome). func (a *agentConn) writePump() { for { select { case <-a.done: return case b := <-a.send: _ = a.conn.SetWriteDeadline(time.Now().Add(writeWait)) if err := a.conn.WriteMessage(websocket.TextMessage, b); err != nil { a.drop() return } } } } // Hub tracks live agent conns and web subscribers; it is the only component // that mutates online state. type Hub struct { store *Store mu sync.Mutex agents map[string]*agentConn webs map[*webClient]struct{} // SpawnStatus lets the API push spawn job snapshots to web clients. SpawnStatus func() []SpawnJob upgrader websocket.Upgrader } func NewHub(store *Store) *Hub { return &Hub{ store: store, agents: make(map[string]*agentConn), webs: make(map[*webClient]struct{}), upgrader: websocket.Upgrader{ ReadBufferSize: 4096, WriteBufferSize: 4096, CheckOrigin: func(*http.Request) bool { return true }, }, } } // IsOnline reports whether the session currently has a live agent WS. func (h *Hub) IsOnline(sessionID string) bool { h.mu.Lock() defer h.mu.Unlock() _, ok := h.agents[sessionID] return ok } // SessionsView merges persisted session rows with live online flags. func (h *Hub) SessionsView() []SessionView { rows, err := h.store.Sessions() if err != nil { log.Printf("hub: list sessions: %v", err) return []SessionView{} } h.mu.Lock() defer h.mu.Unlock() out := make([]SessionView, 0, len(rows)) for _, row := range rows { v := SessionView{ ID: row.Info.ID, Name: row.Info.Name, Cwd: row.Info.Cwd, Model: row.Info.Model, Provider: row.Info.Provider, Agent: row.Info.Agent, Repo: row.Info.Repo, StartedAt: row.Info.StartedAt, LastEventAt: row.LastEventAt, } _, v.Online = h.agents[row.ID] out = append(out, v) } return out } func (h *Hub) dropWeb(c *webClient) { h.mu.Lock() if _, ok := h.webs[c]; !ok { h.mu.Unlock() return } delete(h.webs, c) h.mu.Unlock() c.drop() h.BroadcastSessionList() } // BroadcastSessionList pushes a full session list to every web client. func (h *Hub) BroadcastSessionList() { payload, err := json.Marshal(map[string]any{ "type": frameSessionList, "sessions": h.SessionsView(), }) if err != nil { return } h.mu.Lock() clients := make([]*webClient, 0, len(h.webs)) for c := range h.webs { clients = append(clients, c) } h.mu.Unlock() for _, c := range clients { c.deliverControl(payload) } } // BroadcastSpawnStatus pushes the spawn job snapshot (if a provider is set). func (h *Hub) BroadcastSpawnStatus() { if h.SpawnStatus == nil { return } jobs := h.SpawnStatus() payload, err := json.Marshal(map[string]any{ "type": frameSpawnStatus, "jobs": jobs, }) if err != nil { return } h.mu.Lock() clients := make([]*webClient, 0, len(h.webs)) for c := range h.webs { clients = append(clients, c) } h.mu.Unlock() for _, c := range clients { c.deliverControl(payload) } } // publishEvent fans one agent frame out to web subscribers of that session. func (h *Hub) publishEvent(sessionID string, seq int64, raw json.RawMessage) { h.mu.Lock() clients := make([]*webClient, 0, len(h.webs)) for c := range h.webs { clients = append(clients, c) } h.mu.Unlock() ev := pendingEvent{sessionID: sessionID, seq: seq, raw: raw} for _, c := range clients { if c.subscription() == sessionID { c.deliverEvent(ev) } } } // ServeAgentWS handles GET /agent/ws (bearer auth already enforced by middleware). func (h *Hub) ServeAgentWS(w http.ResponseWriter, r *http.Request) { ws, err := h.upgrader.Upgrade(w, r, nil) if err != nil { return // Upgrade already wrote the HTTP error } ac := &agentConn{hub: h, conn: ws, send: make(chan []byte, agentSendQueue), done: make(chan struct{})} go ac.writePump() h.readAgentLoop(ac) } func (h *Hub) readAgentLoop(ac *agentConn) { defer ac.drop() ac.conn.SetReadLimit(maxFrameSize) registered := false defer func() { if registered { h.unregister(ac) } }() for { _, data, err := ac.conn.ReadMessage() if err != nil { return } f, err := decodeFrame(data) if err != nil { log.Printf("hub: malformed frame from agent: %v", err) continue // never kill the conn loop on a bad frame } switch f.typ { case evHello: registered = true h.handleHello(ac, f) case evSessionInfo: h.handleSessionInfo(f) case evBye: return default: h.handleEvent(f) } } } // handleHello persists the session snapshot, replaces any older conn, replies welcome. func (h *Hub) handleHello(ac *agentConn, f frame) { var fields struct { Session SessionInfo `json:"session"` } if err := json.Unmarshal(f.raw, &fields); err != nil || fields.Session.ID == "" { log.Printf("hub: hello without session payload from %s", f.sessionID) return } sessionID := f.sessionID if sessionID == "" { sessionID = fields.Session.ID } fields.Session.ID = sessionID ac.sessionID = sessionID h.mu.Lock() if old, ok := h.agents[sessionID]; ok && old != ac { old.drop() // reconnect: new conn replaces old } h.agents[sessionID] = ac h.mu.Unlock() if err := h.store.UpsertSession(fields.Session); err != nil { log.Printf("hub: persist session %s: %v", sessionID, err) } if err := h.store.SetOnline(sessionID, true); err != nil { log.Printf("hub: mark online %s: %v", sessionID, err) } lastSeq, err := h.store.LastSeq(sessionID) if err != nil { log.Printf("hub: lastSeq %s: %v", sessionID, err) lastSeq = 0 } welcome, err := json.Marshal(map[string]any{ "v": daemonVersion, "type": evWelcome, "sessionId": sessionID, "seq": 0, "ts": time.Now().UnixMilli(), "lastSeq": lastSeq, }) if err != nil { return } select { case ac.send <- welcome: default: ac.drop() } h.BroadcastSessionList() } func (h *Hub) handleSessionInfo(f frame) { var fields struct { Session SessionInfo `json:"session"` } if err := json.Unmarshal(f.raw, &fields); err != nil || fields.Session.ID == "" { return } sessionID := f.sessionID if sessionID == "" { sessionID = fields.Session.ID } fields.Session.ID = sessionID if err := h.store.UpsertSession(fields.Session); err != nil { log.Printf("hub: persist session_info %s: %v", sessionID, err) } h.BroadcastSessionList() } // handleEvent persists (except message_update), updates bookkeeping, fans out live. func (h *Hub) handleEvent(f frame) { if f.sessionID == "" { return } if f.typ != evMessageUpdate { if err := h.store.AppendEvent(Event{ SessionID: f.sessionID, Seq: f.seq, TS: f.ts, Type: f.typ, Payload: json.RawMessage(f.raw), }); err != nil { log.Printf("hub: persist event %s#%d: %v", f.sessionID, f.seq, err) } if err := h.store.TouchSession(f.sessionID, f.seq, f.ts); err != nil { log.Printf("hub: touch session %s: %v", f.sessionID, err) } } h.publishEvent(f.sessionID, f.seq, json.RawMessage(f.raw)) } func (h *Hub) unregister(ac *agentConn) { h.mu.Lock() if cur, ok := h.agents[ac.sessionID]; ok && cur == ac { delete(h.agents, ac.sessionID) } h.mu.Unlock() if ac.sessionID != "" { if err := h.store.SetOnline(ac.sessionID, false); err != nil { log.Printf("hub: mark offline %s: %v", ac.sessionID, err) } } h.BroadcastSessionList() } // Prompt routes a prompt frame to the live plugin conn for sessionID. func (h *Hub) Prompt(sessionID, message string) error { return h.sendToAgent(sessionID, map[string]any{ "v": daemonVersion, "type": evPrompt, "sessionId": sessionID, "ts": time.Now().UnixMilli(), "promptId": newUUID(), "message": message, }) } // Abort routes an abort frame to the live plugin conn for sessionID. func (h *Hub) Abort(sessionID string) error { return h.sendToAgent(sessionID, map[string]any{ "v": daemonVersion, "type": evAbort, "sessionId": sessionID, "ts": time.Now().UnixMilli(), }) } func (h *Hub) sendToAgent(sessionID string, frameBody map[string]any) error { b, err := json.Marshal(frameBody) if err != nil { return err } h.mu.Lock() ac := h.agents[sessionID] h.mu.Unlock() if ac == nil { return ErrOffline } select { case ac.send <- b: return nil case <-ac.done: return ErrOffline case <-time.After(promptSendTimeout): return ErrOffline } } // ServeWebWS handles GET /ws?token=... — token arrives as query param. func (h *Hub) ServeWebWS(w http.ResponseWriter, r *http.Request) { ws, err := h.upgrader.Upgrade(w, r, nil) if err != nil { return } c := &webClient{hub: h, conn: ws, done: make(chan struct{})} h.mu.Lock() h.webs[c] = struct{}{} h.mu.Unlock() go c.writePump() go c.readPump() payload, err := json.Marshal(map[string]any{ "type": frameSessionList, "sessions": h.SessionsView(), }) if err == nil { c.deliverControl(payload) } }