package agents import ( "context" "strings" "sync" "lchat/internal/client" ) // maxTurnText keeps a session.turn's payload safely under the server's 64K // blob cap (ciphertext is base64 of the JSON payload plus overhead). A turn // this long is pathological anyway; the tail carries the detail. const maxTurnText = 40000 // Bridge is the running sidecar: one E2EE member (@claude) fronting this // machine's Claude Code sessions to every group the config grants. type Bridge struct { // OnReady, if set, fires each time the bridge finishes connecting (tests // wait on it; live frames sent before the connect are only seen via // catch-up on the NEXT connect, so callers who race sends need it). OnReady func() cfg *Config cl *client.Client logf func(string, ...any) uid string mu sync.Mutex sessions map[string]*session // by session id } // New wires a bridge onto a member client. The caller owns the session file // (bot.EnsureSession) exactly as the other sidecars do. func New(base string, sess *client.Session, cfg *Config, logf func(string, ...any)) *Bridge { b := &Bridge{cfg: cfg, logf: logf, sessions: map[string]*session{}} b.cl = &client.Client{Base: base, Session: sess, Logf: logf} b.cl.OnMessage = b.onMessage b.cl.OnEvent = b.onEvent return b } // Run connects and serves until ctx is cancelled. Sessions the bridge spawned // are closed on the way out — their worktrees are only removed by an explicit // session.end, so a bridge restart leaves work in place and the CLI's own // session files resumable. func (b *Bridge) Run(ctx context.Context) error { if b.uid == "" { if uid, err := b.cl.Me(); err == nil { b.uid = uid } } b.cl.OnReady = b.OnReady err := b.cl.Run(ctx) b.mu.Lock() open := make([]*session, 0, len(b.sessions)) for _, s := range b.sessions { open = append(open, s) } b.mu.Unlock() for _, s := range open { s.mu.Lock() ended := s.ended s.mu.Unlock() if !ended { // Terminate the process but keep the worktree: only session.end // means close-and-clean; a dying bridge must not eat work. wt := s.worktree s.worktree = "" s.close(b.logf) s.worktree = wt } } return err } // onMessage is the conversational trigger: an "@claude …" message in a // configured group steers the live session (or births one — the spec's // conversational birth). The bridge only ever acts on members' typed text and // events; session OUTPUT lands in chat as content, never as commands. func (b *Bridge) onMessage(m client.Message) { if m.Mine { return } group, ok := b.cfg.Groups[m.ThreadID] if !ok { return } text := strings.TrimSpace(m.Text) lower := strings.ToLower(text) if !strings.HasPrefix(lower, "@claude") { return } prompt := strings.TrimSpace(text[len("@claude"):]) if prompt == "" { return } live := b.liveSessions(m.ThreadID) switch len(live) { case 0: if len(group.Roots) == 0 { b.reply(m.ThreadID, "This group has no project roots configured on the workstation.") return } s, err := b.spawn(m.ThreadID, group.Roots[0], slugify(prompt), m.Name) if err != nil { b.logf("agents: spawn: %v", err) b.reply(m.ThreadID, "Couldn't start a session on the workstation — check the bridge log.") return } b.steer(s, m.Name, prompt) case 1: b.steer(live[0], m.Name, prompt) default: // Several desks: chat-only routing (reply-pinning, composer chips) is // client work still to come, so be honest rather than guess a target. b.reply(m.ThreadID, "Several sessions are live — steer one from the Agents board for now.") } } // onEvent handles members' typed steering events (the board's emits, and — // once composer routing ships — the prompt-is-the-message path). func (b *Bridge) onEvent(e client.Event) { if uid, _ := e.Payload["uid"].(string); uid != "" && uid == b.uid { return // our own echo } if _, ok := b.cfg.Groups[e.ThreadID]; !ok { return } id, _ := e.Payload["id"].(string) switch e.Type { case "session.prompt": text, _ := e.Payload["text"].(string) name, _ := e.Payload["name"].(string) if s := b.session(id); s != nil && text != "" { b.steer(s, name, text) } case "session.end": if s := b.session(id); s != nil { // The member's event is the durable record (the fold marks the // session ended); the bridge's job is the machine side — end the // process and remove the worktree (close-session-closes-worktree). s.close(b.logf) b.mu.Lock() delete(b.sessions, id) b.mu.Unlock() } } } func (b *Bridge) session(id string) *session { b.mu.Lock() defer b.mu.Unlock() return b.sessions[id] } func (b *Bridge) liveSessions(threadID string) []*session { b.mu.Lock() defer b.mu.Unlock() var out []*session for _, s := range b.sessions { s.mu.Lock() ended := s.ended s.mu.Unlock() if s.threadID == threadID && !ended { out = append(out, s) } } return out } // spawn births a scoped session under a configured root and announces it. func (b *Bridge) spawn(threadID, root, name, by string) (*session, error) { s := &session{ id: newSessionID(), name: name, flavor: "scoped", threadID: threadID, root: root, sink: b.onAct, } if err := s.spawn(b.cfg.Command, nil, b.logf); err != nil { return nil, err } b.mu.Lock() b.sessions[s.id] = s b.mu.Unlock() b.post(threadID, map[string]any{ "type": "session.spawn", "id": s.id, "session": s.name, "flavor": s.flavor, "root": root, "worktree": s.worktree, "by": by, }) return s, nil } func (b *Bridge) steer(s *session, speaker, text string) { if err := s.steer(speaker, text); err != nil { b.logf("agents: steer %s: %v", s.name, err) return } b.post(s.threadID, map[string]any{"type": "session.status", "id": s.id, "state": "running"}) } // onAct receives a session's translated stream acts and turns them into // thread events: turns and status transitions durably, tool chatter as // relay-only frames (the durable/ephemeral split is the design's // load-bearing wall — a busy session must not bloat every member's log). func (b *Bridge) onAct(s *session, act Act) { switch act.Kind { case "tool": if err := b.cl.SendEphemeral(s.threadID, map[string]any{ "type": "session.tool", "id": s.id, "line": act.Tool, }); err != nil { // Best-effort by design: an ephemeral frame needs a live socket, // and losing one loses nothing durable. b.logf("agents: %s: tool frame: %v", s.name, err) } case "turn": text := act.Text if len(text) > maxTurnText { text = text[:maxTurnText] + "…" } b.post(s.threadID, map[string]any{"type": "session.turn", "id": s.id, "session": s.name, "text": text}) b.post(s.threadID, map[string]any{"type": "session.status", "id": s.id, "state": "idle"}) case "error": b.post(s.threadID, map[string]any{"type": "session.turn", "id": s.id, "session": s.name, "text": "⚠️ " + act.Text}) b.post(s.threadID, map[string]any{"type": "session.status", "id": s.id, "state": "idle"}) case "eof": s.mu.Lock() ended := s.ended s.mu.Unlock() if !ended { // The process died under us — the board must say so, not linger // "running" (a dead bridge silently eating prompts is the worst // failure mode; a dead session is its little sibling). if s.cmd != nil { s.cmd.Wait() } b.post(s.threadID, map[string]any{"type": "session.status", "id": s.id, "state": "dead"}) } } } func (b *Bridge) post(threadID string, event map[string]any) { if err := b.cl.SendEvent(threadID, event); err != nil { b.logf("agents: post %s: %v", event["type"], err) } } func (b *Bridge) reply(threadID, text string) { if err := b.cl.Send(threadID, text); err != nil { b.logf("agents: reply: %v", err) } }