eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package agents
2
3import (
4 "context"
5 "strings"
6 "sync"
7
8 "lchat/internal/client"
9)
10
11// maxTurnText keeps a session.turn's payload safely under the server's 64K
12// blob cap (ciphertext is base64 of the JSON payload plus overhead). A turn
13// this long is pathological anyway; the tail carries the detail.
14const maxTurnText = 40000
15
16// Bridge is the running sidecar: one E2EE member (@claude) fronting this
17// machine's Claude Code sessions to every group the config grants.
18type Bridge struct {
19 // OnReady, if set, fires each time the bridge finishes connecting (tests
20 // wait on it; live frames sent before the connect are only seen via
21 // catch-up on the NEXT connect, so callers who race sends need it).
22 OnReady func()
23
24 cfg *Config
25 cl *client.Client
26 logf func(string, ...any)
27 uid string
28
29 mu sync.Mutex
30 sessions map[string]*session // by session id
31}
32
33// New wires a bridge onto a member client. The caller owns the session file
34// (bot.EnsureSession) exactly as the other sidecars do.
35func New(base string, sess *client.Session, cfg *Config, logf func(string, ...any)) *Bridge {
36 b := &Bridge{cfg: cfg, logf: logf, sessions: map[string]*session{}}
37 b.cl = &client.Client{Base: base, Session: sess, Logf: logf}
38 b.cl.OnMessage = b.onMessage
39 b.cl.OnEvent = b.onEvent
40 return b
41}
42
43// Run connects and serves until ctx is cancelled. Sessions the bridge spawned
44// are closed on the way out — their worktrees are only removed by an explicit
45// session.end, so a bridge restart leaves work in place and the CLI's own
46// session files resumable.
47func (b *Bridge) Run(ctx context.Context) error {
48 if b.uid == "" {
49 if uid, err := b.cl.Me(); err == nil {
50 b.uid = uid
51 }
52 }
53 b.cl.OnReady = b.OnReady
54 err := b.cl.Run(ctx)
55 b.mu.Lock()
56 open := make([]*session, 0, len(b.sessions))
57 for _, s := range b.sessions {
58 open = append(open, s)
59 }
60 b.mu.Unlock()
61 for _, s := range open {
62 s.mu.Lock()
63 ended := s.ended
64 s.mu.Unlock()
65 if !ended {
66 // Terminate the process but keep the worktree: only session.end
67 // means close-and-clean; a dying bridge must not eat work.
68 wt := s.worktree
69 s.worktree = ""
70 s.close(b.logf)
71 s.worktree = wt
72 }
73 }
74 return err
75}
76
77// onMessage is the conversational trigger: an "@claude …" message in a
78// configured group steers the live session (or births one — the spec's
79// conversational birth). The bridge only ever acts on members' typed text and
80// events; session OUTPUT lands in chat as content, never as commands.
81func (b *Bridge) onMessage(m client.Message) {
82 if m.Mine {
83 return
84 }
85 group, ok := b.cfg.Groups[m.ThreadID]
86 if !ok {
87 return
88 }
89 text := strings.TrimSpace(m.Text)
90 lower := strings.ToLower(text)
91 if !strings.HasPrefix(lower, "@claude") {
92 return
93 }
94 prompt := strings.TrimSpace(text[len("@claude"):])
95 if prompt == "" {
96 return
97 }
98 live := b.liveSessions(m.ThreadID)
99 switch len(live) {
100 case 0:
101 if len(group.Roots) == 0 {
102 b.reply(m.ThreadID, "This group has no project roots configured on the workstation.")
103 return
104 }
105 s, err := b.spawn(m.ThreadID, group.Roots[0], slugify(prompt), m.Name)
106 if err != nil {
107 b.logf("agents: spawn: %v", err)
108 b.reply(m.ThreadID, "Couldn't start a session on the workstation — check the bridge log.")
109 return
110 }
111 b.steer(s, m.Name, prompt)
112 case 1:
113 b.steer(live[0], m.Name, prompt)
114 default:
115 // Several desks: chat-only routing (reply-pinning, composer chips) is
116 // client work still to come, so be honest rather than guess a target.
117 b.reply(m.ThreadID, "Several sessions are live — steer one from the Agents board for now.")
118 }
119}
120
121// onEvent handles members' typed steering events (the board's emits, and —
122// once composer routing ships — the prompt-is-the-message path).
123func (b *Bridge) onEvent(e client.Event) {
124 if uid, _ := e.Payload["uid"].(string); uid != "" && uid == b.uid {
125 return // our own echo
126 }
127 if _, ok := b.cfg.Groups[e.ThreadID]; !ok {
128 return
129 }
130 id, _ := e.Payload["id"].(string)
131 switch e.Type {
132 case "session.prompt":
133 text, _ := e.Payload["text"].(string)
134 name, _ := e.Payload["name"].(string)
135 if s := b.session(id); s != nil && text != "" {
136 b.steer(s, name, text)
137 }
138 case "session.end":
139 if s := b.session(id); s != nil {
140 // The member's event is the durable record (the fold marks the
141 // session ended); the bridge's job is the machine side — end the
142 // process and remove the worktree (close-session-closes-worktree).
143 s.close(b.logf)
144 b.mu.Lock()
145 delete(b.sessions, id)
146 b.mu.Unlock()
147 }
148 }
149}
150
151func (b *Bridge) session(id string) *session {
152 b.mu.Lock()
153 defer b.mu.Unlock()
154 return b.sessions[id]
155}
156
157func (b *Bridge) liveSessions(threadID string) []*session {
158 b.mu.Lock()
159 defer b.mu.Unlock()
160 var out []*session
161 for _, s := range b.sessions {
162 s.mu.Lock()
163 ended := s.ended
164 s.mu.Unlock()
165 if s.threadID == threadID && !ended {
166 out = append(out, s)
167 }
168 }
169 return out
170}
171
172// spawn births a scoped session under a configured root and announces it.
173func (b *Bridge) spawn(threadID, root, name, by string) (*session, error) {
174 s := &session{
175 id: newSessionID(),
176 name: name,
177 flavor: "scoped",
178 threadID: threadID,
179 root: root,
180 sink: b.onAct,
181 }
182 if err := s.spawn(b.cfg.Command, nil, b.logf); err != nil {
183 return nil, err
184 }
185 b.mu.Lock()
186 b.sessions[s.id] = s
187 b.mu.Unlock()
188 b.post(threadID, map[string]any{
189 "type": "session.spawn", "id": s.id, "session": s.name, "flavor": s.flavor,
190 "root": root, "worktree": s.worktree, "by": by,
191 })
192 return s, nil
193}
194
195func (b *Bridge) steer(s *session, speaker, text string) {
196 if err := s.steer(speaker, text); err != nil {
197 b.logf("agents: steer %s: %v", s.name, err)
198 return
199 }
200 b.post(s.threadID, map[string]any{"type": "session.status", "id": s.id, "state": "running"})
201}
202
203// onAct receives a session's translated stream acts and turns them into
204// thread events: turns and status transitions durably, tool chatter as
205// relay-only frames (the durable/ephemeral split is the design's
206// load-bearing wall — a busy session must not bloat every member's log).
207func (b *Bridge) onAct(s *session, act Act) {
208 switch act.Kind {
209 case "tool":
210 if err := b.cl.SendEphemeral(s.threadID, map[string]any{
211 "type": "session.tool", "id": s.id, "line": act.Tool,
212 }); err != nil {
213 // Best-effort by design: an ephemeral frame needs a live socket,
214 // and losing one loses nothing durable.
215 b.logf("agents: %s: tool frame: %v", s.name, err)
216 }
217 case "turn":
218 text := act.Text
219 if len(text) > maxTurnText {
220 text = text[:maxTurnText] + "…"
221 }
222 b.post(s.threadID, map[string]any{"type": "session.turn", "id": s.id, "session": s.name, "text": text})
223 b.post(s.threadID, map[string]any{"type": "session.status", "id": s.id, "state": "idle"})
224 case "error":
225 b.post(s.threadID, map[string]any{"type": "session.turn", "id": s.id, "session": s.name, "text": "⚠️ " + act.Text})
226 b.post(s.threadID, map[string]any{"type": "session.status", "id": s.id, "state": "idle"})
227 case "eof":
228 s.mu.Lock()
229 ended := s.ended
230 s.mu.Unlock()
231 if !ended {
232 // The process died under us — the board must say so, not linger
233 // "running" (a dead bridge silently eating prompts is the worst
234 // failure mode; a dead session is its little sibling).
235 if s.cmd != nil {
236 s.cmd.Wait()
237 }
238 b.post(s.threadID, map[string]any{"type": "session.status", "id": s.id, "state": "dead"})
239 }
240 }
241}
242
243func (b *Bridge) post(threadID string, event map[string]any) {
244 if err := b.cl.SendEvent(threadID, event); err != nil {
245 b.logf("agents: post %s: %v", event["type"], err)
246 }
247}
248
249func (b *Bridge) reply(threadID, text string) {
250 if err := b.cl.Send(threadID, text); err != nil {
251 b.logf("agents: reply: %v", err)
252 }
253}
254