eleven / ocho Public

server: lchat agents run — the workstation bridge for shared Claude Code sessions (#1455)

* server: internal/client learns typed-event delivery + ephemeral send/receive

The transport slice of the agents design (docs/superpowers/specs/
2026-08-05-eleven-claude-agents-design.md): OnEvent delivers decrypted typed
payloads (live + catch-up), OnEphemeral receives relay-only frames, and
SendEphemeral is the Go end of the web's ctx.emitEphemeral. All opt-in —
a client that sets neither hook behaves exactly as before. This is the
seam the code comments already pointed at (internal/client as the shared
member client layer); the bridge subcommand builds on it next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xxn758bWfMnEe9xZZuk5Mx

* server: lchat agents run — the workstation bridge for shared Claude Code sessions

The bridge half of the accepted design (docs/superpowers/specs/
2026-08-05-eleven-claude-agents-design.md): a bot-model sidecar subcommand
(internal/agents) that joins as @claude, spawns headless claude processes
(stream-json in/out) in fresh worktrees under config-granted roots, and
translates the stream into encrypted session.* events — turns and status
durable, tool chatter ephemeral. Members steer via @claude chat messages
and session.prompt events; session.end ends the process and removes the
worktree (branch survives). Config is fail-closed JSON on the box — the
blast radius in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xxn758bWfMnEe9xZZuk5Mx

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 6562964905a194d7a9c346198d2335fad3590473 parent a106585
10 files changed, +1136 −0
  • agents_bridge_test.go +233 −0
  • agentscmd.go +74 −0
  • internal/agents/bridge.go +253 −0
  • internal/agents/config.go +97 −0
  • internal/agents/config_test.go +62 −0
  • internal/agents/session.go +244 −0
  • internal/agents/translate.go +108 −0
  • internal/agents/translate_test.go +61 −0
  • main.go +3 −0
  • subcommands.go +1 −0
diff --git a/agents_bridge_test.go b/agents_bridge_test.go
new file mode 100644
index 0000000..050340e
--- /dev/null
+++ b/agents_bridge_test.go
@@ -0,0 +1,233 @@
+package main
+
+// 🤖 End-to-end test for the workstation bridge (`lchat agents run`,
+// internal/agents) against the real router: a stub `claude` binary stands in
+// for the CLI (emitting canned stream-json), so the test pins the whole loop
+// — conversational spawn, worktree creation, prompt attribution over stdin,
+// session.turn/status events landing encrypted in the thread, and
+// close-session-closes-worktree with the branch surviving.
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "lchat/internal/agents"
+)
+
+// stubClaude writes a shell script that speaks just enough stream-json: an
+// init line at boot, then one tool_use + one result per stdin line. Every
+// received stdin line is appended to logPath so the test can assert on what
+// the bridge actually sent (prompt attribution).
+func stubClaude(t *testing.T, logPath string) string {
+ t.Helper()
+ dir := t.TempDir()
+ p := filepath.Join(dir, "claude")
+ script := `#!/bin/sh
+printf '%s\n' '{"type":"system","subtype":"init","session_id":"cs-stub"}'
+while IFS= read -r line; do
+ printf '%s\n' "$line" >> "` + logPath + `"
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"go test ./..."}}]}}'
+ printf '%s\n' '{"type":"result","result":"done: fixed the thing","is_error":false}'
+done
+`
+ if err := os.WriteFile(p, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+// gitRoot creates a repo with one commit, so worktree creation has a HEAD.
+func gitRoot(t *testing.T) string {
+ t.Helper()
+ // The worktree lands BESIDE the root, so give the root a parent dir the
+ // test owns.
+ root := filepath.Join(t.TempDir(), "repo")
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ run := func(args ...string) {
+ cmd := exec.Command("git", append([]string{"-C", root}, args...)...)
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, out)
+ }
+ }
+ run("init", "-q")
+ if err := os.WriteFile(filepath.Join(root, "README"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run("add", "README")
+ run("commit", "-q", "-m", "init")
+ return root
+}
+
+// waitUntil polls until cond returns true or the deadline passes.
+func waitUntil(t *testing.T, what string, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ t.Fatalf("timed out waiting for %s", what)
+}
+
+func TestAgentsBridgeEndToEnd(t *testing.T) {
+ ts, _ := newTestServer(t)
+ bridgeMember := joinForTest(t, ts.URL, "Claude")
+ paul := joinForTest(t, ts.URL, "Paul")
+ thread := groupForTest(t, ts.URL, bridgeMember, paul)
+
+ root := gitRoot(t)
+ promptLog := filepath.Join(t.TempDir(), "prompts.log")
+ cfg := &agents.Config{
+ Groups: map[string]agents.GroupConfig{thread: {Roots: []string{root}}},
+ Command: stubClaude(t, promptLog),
+ }
+
+ b := agents.New(ts.URL, bridgeMember.Session, cfg, t.Logf)
+ ready := make(chan struct{}, 1)
+ b.OnReady = func() { ready <- struct{}{} }
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go b.Run(ctx)
+ select {
+ case <-ready:
+ case <-time.After(5 * time.Second):
+ t.Fatal("bridge never connected")
+ }
+
+ // Conversational birth: "@claude …" with zero live sessions spawns a
+ // scoped session in the group's default root and steers it.
+ if err := paul.Send(thread, "@claude fix the flaky reminder test"); err != nil {
+ t.Fatal(err)
+ }
+
+ events := func(typ string) []map[string]any {
+ evs, err := paul.Events(thread, "session.")
+ if err != nil {
+ return nil
+ }
+ var out []map[string]any
+ for _, e := range evs {
+ if e["type"] == typ {
+ out = append(out, e)
+ }
+ }
+ return out
+ }
+ var spawn map[string]any
+ waitUntil(t, "session.spawn", func() bool {
+ if s := events("session.spawn"); len(s) > 0 {
+ spawn = s[0]
+ return true
+ }
+ return false
+ })
+ if spawn["flavor"] != "scoped" || spawn["root"] != root || spawn["by"] != "Paul" {
+ t.Errorf("spawn: %+v", spawn)
+ }
+ worktree, _ := spawn["worktree"].(string)
+ if worktree == "" {
+ t.Fatal("scoped session in a git root got no worktree")
+ }
+ if _, err := os.Stat(worktree); err != nil {
+ t.Fatalf("worktree missing: %v", err)
+ }
+
+ // The stub echoes stdin: the prompt must arrive attributed to its speaker.
+ waitUntil(t, "attributed prompt on stdin", func() bool {
+ data, _ := os.ReadFile(promptLog)
+ return strings.Contains(string(data), "Paul: fix the flaky reminder test")
+ })
+
+ // The stub's canned result becomes a durable turn, and the board settles
+ // idle.
+ waitUntil(t, "session.turn", func() bool {
+ for _, e := range events("session.turn") {
+ if e["text"] == "done: fixed the thing" && e["session"] == spawn["session"] {
+ return true
+ }
+ }
+ return false
+ })
+ waitUntil(t, "idle status", func() bool {
+ sts := events("session.status")
+ return len(sts) > 0 && sts[len(sts)-1]["state"] == "idle"
+ })
+
+ // A second @claude message routes to the one live session — no new spawn.
+ if err := paul.Send(thread, "@claude also check the store hydration"); err != nil {
+ t.Fatal(err)
+ }
+ waitUntil(t, "second prompt steered", func() bool {
+ data, _ := os.ReadFile(promptLog)
+ return strings.Contains(string(data), "Paul: also check the store hydration")
+ })
+ if got := len(events("session.spawn")); got != 1 {
+ t.Errorf("spawn count = %d, want 1 (routing, not re-birth)", got)
+ }
+
+ // Closing from the board: the member's session.end makes the bridge end
+ // the process and remove the worktree; the branch survives.
+ id, _ := spawn["id"].(string)
+ if err := paul.SendEvent(thread, map[string]any{"type": "session.end", "id": id, "session": spawn["session"], "reason": "closed"}); err != nil {
+ t.Fatal(err)
+ }
+ waitUntil(t, "worktree removed", func() bool {
+ _, err := os.Stat(worktree)
+ return os.IsNotExist(err)
+ })
+ out, err := exec.Command("git", "-C", root, "branch", "--list", "agents/*").Output()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(out), "agents/") {
+ t.Errorf("session branch did not survive worktree removal: %q", out)
+ }
+}
+
+func TestAgentsBridgeIgnoresUnconfiguredGroups(t *testing.T) {
+ ts, _ := newTestServer(t)
+ bridgeMember := joinForTest(t, ts.URL, "Claude")
+ paul := joinForTest(t, ts.URL, "Paul")
+ thread := groupForTest(t, ts.URL, bridgeMember, paul)
+
+ other := t.TempDir()
+ cfg := &agents.Config{
+ // A different thread id is granted — THIS thread must get nothing.
+ Groups: map[string]agents.GroupConfig{"th-other": {Roots: []string{other}}},
+ Command: "/bin/false",
+ }
+ b := agents.New(ts.URL, bridgeMember.Session, cfg, t.Logf)
+ ready := make(chan struct{}, 1)
+ b.OnReady = func() { ready <- struct{}{} }
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go b.Run(ctx)
+ select {
+ case <-ready:
+ case <-time.After(5 * time.Second):
+ t.Fatal("bridge never connected")
+ }
+
+ if err := paul.Send(thread, "@claude do things"); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(500 * time.Millisecond)
+ evs, err := paul.Events(thread, "session.")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(evs) != 0 {
+ t.Errorf("unconfigured group got session events: %+v", evs)
+ }
+}
diff --git a/agentscmd.go b/agentscmd.go
new file mode 100644
index 0000000..961e8cd
--- /dev/null
+++ b/agentscmd.go
@@ -0,0 +1,74 @@
+// The `lchat agents` subcommand family (docs/superpowers/specs/
+// 2026-08-05-eleven-claude-agents-design.md):
+//
+// - `lchat agents run` IS the workstation bridge (internal/agents) — run on
+// the machine whose Claude Code sessions a group should share. A pure
+// outbound client in the bot-sidecar mould: it dials the instance, holds
+// its member key and the machine's authority HERE, and opens no ports.
+//
+// Unlike bot/hooks there is no add-agents control verb: the bridge runs on a
+// developer workstation, not the router box, so an operator stands it up by
+// hand — mint an agent invite, write the config, run the command.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "strings"
+
+ "lchat/internal/agents"
+ "lchat/internal/bot"
+)
+
+func runAgentsCmd(args []string) {
+ if len(args) < 1 {
+ fmt.Fprintln(os.Stderr, `usage: lchat agents <command>
+
+commands:
+ run run the workstation bridge: front this machine's Claude Code sessions
+ into the group threads the config grants (config: agents.json)`)
+ os.Exit(2)
+ }
+ switch args[0] {
+ case "run":
+ runAgentsRun(args[1:])
+ default:
+ die("agents: unknown command %q", args[0])
+ }
+}
+
+func runAgentsRun(args []string) {
+ fs := flag.NewFlagSet("agents run", flag.ExitOnError)
+ base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL (env: LCHAT_BASE)")
+ session := fs.String("session", envOr("LCHAT_SESSION", "agents-session.json"), "member session file (env: LCHAT_SESSION)")
+ config := fs.String("config", envOr("LCHAT_AGENTS_CONFIG", "agents.json"), "bridge config: which groups get which roots (env: LCHAT_AGENTS_CONFIG)")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ if *base == "" {
+ die("agents run: set -base (or LCHAT_BASE) to your instance URL")
+ }
+ // The config is the blast radius — fail-closed before touching the network.
+ cfg, err := agents.LoadConfig(*config)
+ if err != nil {
+ die("agents run: %v", err)
+ }
+ // Self-provision on first boot from a one-time agent invite, exactly like
+ // the bot sidecar (#806). Env-only — a flag would leak it into the process
+ // table.
+ sess, err := bot.EnsureSession(strings.TrimRight(*base, "/"), *session, os.Getenv("LCHAT_INVITE"), "Claude", log.Printf)
+ if err != nil {
+ die("agents run: %v", err)
+ }
+ if sess == nil {
+ die("agents run: no session at %s and no LCHAT_INVITE — mint an agent invite (admin → helpers) first", *session)
+ }
+
+ b := agents.New(strings.TrimRight(*base, "/"), sess, cfg, log.Printf)
+ log.Printf("agents run: bridging %d group(s) on %s", len(cfg.Groups), *base)
+ if err := b.Run(context.Background()); err != nil {
+ die("agents run: %v", err)
+ }
+}
diff --git a/internal/agents/bridge.go b/internal/agents/bridge.go
new file mode 100644
index 0000000..18ac3dc
--- /dev/null
+++ b/internal/agents/bridge.go
@@ -0,0 +1,253 @@
+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)
+ }
+}
diff --git a/internal/agents/config.go b/internal/agents/config.go
new file mode 100644
index 0000000..4bbef85
--- /dev/null
+++ b/internal/agents/config.go
@@ -0,0 +1,97 @@
+// Package agents is the workstation bridge behind `lchat agents run`
+// (docs/superpowers/specs/2026-08-05-eleven-claude-agents-design.md): an
+// ordinary E2EE bot member that owns headless Claude Code sessions on the
+// machine it runs on, translating their structured event stream into
+// encrypted session.* events in a group thread (the vocabulary pinned by
+// test/event-vectors/agents.json and folded by web/static/apps/agents.js),
+// and reading members' steering events back off the same thread.
+//
+// Trust model, in one line: the bridge holds this machine's authority, and
+// GROUP MEMBERSHIP IS THE CONSENT MOMENT — everyone in a configured group can
+// steer sessions, so the config below is the blast radius, written down in
+// one place on the box.
+package agents
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// GroupConfig is one group thread's authority grant.
+type GroupConfig struct {
+ // Roots are the directories scoped sessions may live under (each session
+ // gets a fresh git worktree beside its root). The first root is the
+ // default a conversational spawn lands in.
+ Roots []string `json:"roots"`
+ // Plain opts the group into whole-workstation sessions (cwd = home, no
+ // pinning). Off by default because it hands every member the entire box.
+ Plain bool `json:"plain"`
+}
+
+// Config is the bridge's on-disk config — a sidecar-local JSON beside the
+// session file, following the recipe-file convention (internal/bot/recipe.go):
+// loaded fail-closed at startup, never fetched from the server (the blast
+// radius must live on the box it describes).
+type Config struct {
+ Groups map[string]GroupConfig `json:"groups"`
+ // Command overrides the `claude` binary (tests point this at a stub).
+ Command string `json:"command,omitempty"`
+}
+
+// LoadConfig reads and validates the bridge config. Fail-closed: a missing or
+// broken file is an error, not an empty grant — a bridge with authority over
+// nothing is a misconfiguration someone should see, and a truncated file must
+// never silently widen or narrow what a group can reach.
+func LoadConfig(path string) (*Config, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ var cfg Config
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return nil, fmt.Errorf("%s: %v", path, err)
+ }
+ if len(cfg.Groups) == 0 {
+ return nil, fmt.Errorf("%s: no groups configured — the bridge would serve nothing", path)
+ }
+ home, _ := os.UserHomeDir()
+ for tid, g := range cfg.Groups {
+ if tid == "" {
+ return nil, fmt.Errorf("%s: empty group thread id", path)
+ }
+ if len(g.Roots) == 0 && !g.Plain {
+ return nil, fmt.Errorf("%s: group %s grants no roots and no plain — it could spawn nothing", path, tid)
+ }
+ roots := make([]string, 0, len(g.Roots))
+ for _, r := range g.Roots {
+ r = strings.TrimSpace(r)
+ if r == "" {
+ return nil, fmt.Errorf("%s: group %s has an empty root", path, tid)
+ }
+ if r == "~" || strings.HasPrefix(r, "~/") {
+ r = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(r, "~"), "/"))
+ }
+ abs, err := filepath.Abs(r)
+ if err != nil {
+ return nil, fmt.Errorf("%s: group %s root %q: %v", path, tid, r, err)
+ }
+ st, err := os.Stat(abs)
+ if err != nil {
+ return nil, fmt.Errorf("%s: group %s root %q: %v", path, tid, r, err)
+ }
+ if !st.IsDir() {
+ return nil, fmt.Errorf("%s: group %s root %q is not a directory", path, tid, r)
+ }
+ roots = append(roots, abs)
+ }
+ g.Roots = roots
+ cfg.Groups[tid] = g
+ }
+ if cfg.Command == "" {
+ cfg.Command = "claude"
+ }
+ return &cfg, nil
+}
diff --git a/internal/agents/config_test.go b/internal/agents/config_test.go
new file mode 100644
index 0000000..2a04988
--- /dev/null
+++ b/internal/agents/config_test.go
@@ -0,0 +1,62 @@
+package agents
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func writeConfig(t *testing.T, body string) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "agents.json")
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func TestLoadConfigHappyPath(t *testing.T) {
+ root := t.TempDir()
+ cfg, err := LoadConfig(writeConfig(t, `{"groups":{"th1":{"roots":["`+root+`"]}}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ g := cfg.Groups["th1"]
+ if len(g.Roots) != 1 || g.Roots[0] != root || g.Plain {
+ t.Errorf("group: %+v", g)
+ }
+ if cfg.Command != "claude" {
+ t.Errorf("command default: %q", cfg.Command)
+ }
+}
+
+// Fail-closed: the config is the blast radius, so every broken shape must be
+// an error, never a silent empty (or widened) grant.
+func TestLoadConfigFailsClosed(t *testing.T) {
+ missingDir := filepath.Join(t.TempDir(), "nope")
+ cases := map[string]string{
+ "empty groups": `{"groups":{}}`,
+ "broken json": `{"groups":`,
+ "no roots no plain": `{"groups":{"th1":{"roots":[]}}}`,
+ "empty root": `{"groups":{"th1":{"roots":[""]}}}`,
+ "missing root dir": `{"groups":{"th1":{"roots":["` + missingDir + `"]}}}`,
+ }
+ for name, body := range cases {
+ if _, err := LoadConfig(writeConfig(t, body)); err == nil {
+ t.Errorf("%s: LoadConfig accepted it", name)
+ }
+ }
+ if _, err := LoadConfig(filepath.Join(t.TempDir(), "absent.json")); err == nil {
+ t.Error("missing file: LoadConfig accepted it")
+ }
+}
+
+func TestLoadConfigPlainNeedsNoRoots(t *testing.T) {
+ cfg, err := LoadConfig(writeConfig(t, `{"groups":{"th1":{"plain":true}}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !cfg.Groups["th1"].Plain {
+ t.Error("plain not kept")
+ }
+}
diff --git a/internal/agents/session.go b/internal/agents/session.go
new file mode 100644
index 0000000..4a3c720
--- /dev/null
+++ b/internal/agents/session.go
@@ -0,0 +1,244 @@
+package agents
+
+import (
+ "bufio"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+)
+
+// session is one live headless Claude Code process. The bridge keeps exactly
+// one process per session for its whole life — steering rides stdin
+// (stream-json input), so a turn's context never leaves the process; if the
+// bridge dies, the CLI's own on-disk session survives and a human can
+// `claude --resume <claudeSessionID>` from a real terminal (the honest
+// handoff the spec settles on).
+type session struct {
+ id string // our id — the one session.* events carry
+ name string // the human tag ("fix-login")
+ flavor string // "scoped" | "plain"
+ threadID string
+ root string // scoped: the configured root it was spawned under
+ worktree string // scoped in a git root: the fresh worktree (else "")
+ branch string // the worktree's branch (survives worktree removal)
+
+ cmd *exec.Cmd
+ stdin io.WriteCloser
+ sink actSink // set before spawn; translated stream acts flow up through it
+
+ mu sync.Mutex
+ claudeSessionID string // from the init event; what --resume takes
+ ended bool
+}
+
+// actSink is the bridge's receiver for a session's translated stream acts.
+type actSink func(*session, Act)
+
+func newSessionID() string {
+ b := make([]byte, 6)
+ rand.Read(b)
+ return "s-" + hex.EncodeToString(b)
+}
+
+var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
+
+// slugify derives a session tag from the first words of a prompt —
+// "fix the flaky reminder test" → "fix-the-flaky".
+func slugify(text string) string {
+ words := strings.Fields(strings.ToLower(text))
+ if len(words) > 3 {
+ words = words[:3]
+ }
+ s := slugRe.ReplaceAllString(strings.Join(words, "-"), "-")
+ s = strings.Trim(s, "-")
+ if len(s) > 40 {
+ s = s[:40]
+ }
+ if s == "" {
+ s = "session"
+ }
+ return s
+}
+
+// spawn starts the claude process for a session. Scoped sessions in a git
+// root get a fresh worktree beside the root (parallel sessions never trample
+// each other; the repo's own .claude settings, CLAUDE.md and permission
+// config govern the session exactly as they would a human dev); a non-git
+// root runs in the root itself. Plain sessions run from the operator's home.
+func (s *session) spawn(command string, extraArgs []string, logf func(string, ...any)) error {
+ dir := s.root
+ if s.flavor == "plain" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+ dir = home
+ } else if isGitRepo(s.root) {
+ wt := filepath.Join(filepath.Dir(s.root), "agents-"+s.name+"-"+strings.TrimPrefix(s.id, "s-")[:4])
+ branch := "agents/" + s.name + "-" + strings.TrimPrefix(s.id, "s-")[:4]
+ out, err := exec.Command("git", "-C", s.root, "worktree", "add", "-b", branch, wt).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("worktree add: %v: %s", err, strings.TrimSpace(string(out)))
+ }
+ s.worktree, s.branch, dir = wt, branch, wt
+ }
+
+ args := append([]string{
+ "-p",
+ "--input-format", "stream-json",
+ "--output-format", "stream-json",
+ "--verbose",
+ "--append-system-prompt", "You are driven from a shared group chat; each prompt is prefixed with the speaker's name, and several people may steer you.",
+ }, extraArgs...)
+ cmd := exec.Command(command, args...)
+ cmd.Dir = dir
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return err
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return err
+ }
+ cmd.Stderr = &logWriter{prefix: s.name, logf: logf}
+ if err := cmd.Start(); err != nil {
+ s.removeWorktree(logf)
+ return err
+ }
+ s.cmd, s.stdin = cmd, stdin
+ go s.readLoop(stdout, logf)
+ return nil
+}
+
+// steer writes one attributed user turn onto the session's stdin. The CLI
+// processes stdin messages in order, so mid-turn prompts queue naturally.
+func (s *session) steer(speaker, text string) error {
+ if speaker != "" {
+ text = speaker + ": " + text
+ }
+ msg := map[string]any{
+ "type": "user",
+ "message": map[string]any{
+ "role": "user",
+ "content": []map[string]any{{"type": "text", "text": text}},
+ },
+ }
+ data, err := json.Marshal(msg)
+ if err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.ended || s.stdin == nil {
+ return fmt.Errorf("session %s is not running", s.name)
+ }
+ _, err = s.stdin.Write(append(data, '\n'))
+ return err
+}
+
+// readLoop consumes the process's stream-json stdout until EOF, translating
+// each line. Lines can be large (a whole assistant message); the scanner
+// buffer allows well past the server's 64K blob cap.
+func (s *session) readLoop(stdout io.Reader, logf func(string, ...any)) {
+ sc := bufio.NewScanner(stdout)
+ sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
+ for sc.Scan() {
+ for _, act := range Translate(sc.Bytes()) {
+ if act.Kind == "init" {
+ s.mu.Lock()
+ s.claudeSessionID = act.ClaudeSessionID
+ s.mu.Unlock()
+ }
+ if s.sink != nil {
+ s.sink(s, act)
+ }
+ }
+ }
+ if err := sc.Err(); err != nil {
+ logf("agents: %s: read: %v", s.name, err)
+ }
+ // EOF: the process is going (or gone). A synthetic act lets the bridge
+ // distinguish a deliberate close from a session dying under it.
+ if s.sink != nil {
+ s.sink(s, Act{Kind: "eof"})
+ }
+}
+
+// close ends the process (stdin close lets an idle -p session exit cleanly;
+// SIGTERM then kill covers a stuck one) and removes the worktree — the
+// accepted design's close-session-closes-worktree: the checkout goes, the
+// branch (any committed work) survives, and the CLI's own session file stays
+// resumable from the ROOT checkout via --resume.
+func (s *session) close(logf func(string, ...any)) {
+ s.mu.Lock()
+ if s.ended {
+ s.mu.Unlock()
+ return
+ }
+ s.ended = true
+ stdin, cmd := s.stdin, s.cmd
+ s.mu.Unlock()
+ if stdin != nil {
+ stdin.Close()
+ }
+ if cmd != nil && cmd.Process != nil {
+ done := make(chan struct{})
+ go func() { cmd.Wait(); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ cmd.Process.Kill()
+ <-done
+ }
+ }
+ s.removeWorktree(logf)
+}
+
+func (s *session) removeWorktree(logf func(string, ...any)) {
+ if s.worktree == "" {
+ return
+ }
+ // --force: closing the session closes the worktree, uncommitted changes
+ // included — that's what the board's confirm says. Committed work lives on
+ // the branch.
+ out, err := exec.Command("git", "-C", s.root, "worktree", "remove", "--force", s.worktree).CombinedOutput()
+ if err != nil {
+ logf("agents: %s: worktree remove: %v: %s", s.name, err, strings.TrimSpace(string(out)))
+ }
+ s.worktree = ""
+}
+
+func isGitRepo(dir string) bool {
+ return exec.Command("git", "-C", dir, "rev-parse", "--git-dir").Run() == nil
+}
+
+// logWriter forwards a process's stderr to the bridge log, line-tagged.
+type logWriter struct {
+ prefix string
+ logf func(string, ...any)
+ buf []byte
+}
+
+func (w *logWriter) Write(p []byte) (int, error) {
+ w.buf = append(w.buf, p...)
+ for {
+ i := strings.IndexByte(string(w.buf), '\n')
+ if i < 0 {
+ break
+ }
+ if line := strings.TrimSpace(string(w.buf[:i])); line != "" {
+ w.logf("agents: %s: %s", w.prefix, line)
+ }
+ w.buf = w.buf[i+1:]
+ }
+ return len(p), nil
+}
diff --git a/internal/agents/translate.go b/internal/agents/translate.go
new file mode 100644
index 0000000..5b0038b
--- /dev/null
+++ b/internal/agents/translate.go
@@ -0,0 +1,108 @@
+package agents
+
+// The pure half of the bridge: one stream-json line from a headless Claude
+// Code session (`claude -p --input-format stream-json --output-format
+// stream-json --verbose`) in, zero or more Acts out. Kept free of I/O so the
+// translation is table-testable against fixture lines (translate_test.go) —
+// the same pure-logic-out-of-the-plumbing split as format.js/ui.js.
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+// Act is one thing the bridge should do in response to a stream line.
+type Act struct {
+ Kind string // "init" | "turn" | "tool" | "error"
+ // init: the CLI's own session id (what `claude --resume` takes later).
+ ClaudeSessionID string
+ // turn: the assistant's final text for the completed turn; error: detail.
+ Text string
+ // tool: a one-line activity description ("Ran `go test ./...`").
+ Tool string
+}
+
+// streamLine is the envelope every stream-json stdout line shares.
+type streamLine struct {
+ Type string `json:"type"`
+ Subtype string `json:"subtype"`
+ SessionID string `json:"session_id"`
+ Result string `json:"result"`
+ IsError bool `json:"is_error"`
+ Message struct {
+ Content []contentBlock `json:"content"`
+ } `json:"message"`
+}
+
+type contentBlock struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ Name string `json:"name"`
+ Input json.RawMessage `json:"input"`
+}
+
+// Translate parses one stdout line. Unknown or partial event types translate
+// to nothing — the stream carries plenty a fleet board doesn't need, and an
+// unrecognised future event must never wedge a session.
+func Translate(line []byte) []Act {
+ var sl streamLine
+ if json.Unmarshal(line, &sl) != nil {
+ return nil
+ }
+ switch sl.Type {
+ case "system":
+ if sl.Subtype == "init" && sl.SessionID != "" {
+ return []Act{{Kind: "init", ClaudeSessionID: sl.SessionID}}
+ }
+ case "assistant":
+ var acts []Act
+ for _, b := range sl.Message.Content {
+ if b.Type == "tool_use" && b.Name != "" {
+ acts = append(acts, Act{Kind: "tool", Tool: toolLine(b)})
+ }
+ }
+ return acts
+ case "result":
+ text := strings.TrimSpace(sl.Result)
+ if sl.IsError {
+ if text == "" {
+ text = "the turn failed"
+ }
+ return []Act{{Kind: "error", Text: text}}
+ }
+ if text != "" {
+ return []Act{{Kind: "turn", Text: text}}
+ }
+ }
+ return nil
+}
+
+// toolLine renders one tool_use block as the activity line members see.
+// Bash gets its command surfaced (that's the line a room actually wants to
+// read); everything else names the tool and, when short, its primary input.
+func toolLine(b contentBlock) string {
+ var input map[string]any
+ _ = json.Unmarshal(b.Input, &input)
+ if b.Name == "Bash" {
+ if cmd, _ := input["command"].(string); cmd != "" {
+ return "Ran " + clampLine(cmd, 160)
+ }
+ }
+ for _, k := range []string{"file_path", "path", "pattern", "url", "query", "description"} {
+ if v, _ := input[k].(string); v != "" {
+ return fmt.Sprintf("%s %s", b.Name, clampLine(v, 120))
+ }
+ }
+ return b.Name
+}
+
+// clampLine flattens newlines and truncates — activity lines are one line by
+// contract (the fold clamps to 200 chars; we stay under it).
+func clampLine(s string, max int) string {
+ s = strings.Join(strings.Fields(s), " ")
+ if len(s) > max {
+ s = s[:max] + "…"
+ }
+ return s
+}
diff --git a/internal/agents/translate_test.go b/internal/agents/translate_test.go
new file mode 100644
index 0000000..78bbba2
--- /dev/null
+++ b/internal/agents/translate_test.go
@@ -0,0 +1,61 @@
+package agents
+
+import "testing"
+
+func one(t *testing.T, line string) Act {
+ t.Helper()
+ acts := Translate([]byte(line))
+ if len(acts) != 1 {
+ t.Fatalf("Translate(%s) = %+v, want one act", line, acts)
+ }
+ return acts[0]
+}
+
+func TestTranslateInit(t *testing.T) {
+ a := one(t, `{"type":"system","subtype":"init","session_id":"cs-123","model":"x"}`)
+ if a.Kind != "init" || a.ClaudeSessionID != "cs-123" {
+ t.Errorf("init: %+v", a)
+ }
+}
+
+func TestTranslateToolUse(t *testing.T) {
+ a := one(t, `{"type":"assistant","message":{"content":[{"type":"text","text":"Let me check."},{"type":"tool_use","name":"Bash","input":{"command":"go test ./internal/bot"}}]}}`)
+ if a.Kind != "tool" || a.Tool != "Ran go test ./internal/bot" {
+ t.Errorf("tool: %+v", a)
+ }
+ a = one(t, `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"/x/y.go"}}]}}`)
+ if a.Kind != "tool" || a.Tool != "Read /x/y.go" {
+ t.Errorf("tool: %+v", a)
+ }
+}
+
+func TestTranslateResultTurn(t *testing.T) {
+ a := one(t, `{"type":"result","subtype":"success","result":"Fixed — the ticker fired early.","is_error":false,"session_id":"cs-123"}`)
+ if a.Kind != "turn" || a.Text != "Fixed — the ticker fired early." {
+ t.Errorf("turn: %+v", a)
+ }
+ a = one(t, `{"type":"result","subtype":"error_during_execution","result":"boom","is_error":true}`)
+ if a.Kind != "error" || a.Text != "boom" {
+ t.Errorf("error: %+v", a)
+ }
+}
+
+func TestTranslateIgnoresNoise(t *testing.T) {
+ for _, line := range []string{
+ "not json",
+ `{"type":"user","message":{"content":[{"type":"tool_result"}]}}`,
+ `{"type":"stream_event","event":{"type":"content_block_delta"}}`,
+ `{"type":"result","result":"","is_error":false}`,
+ `{"type":"system","subtype":"compact"}`,
+ } {
+ if acts := Translate([]byte(line)); len(acts) != 0 {
+ t.Errorf("Translate(%s) = %+v, want none", line, acts)
+ }
+ }
+}
+
+func TestClampLineFlattens(t *testing.T) {
+ if got := clampLine("a\n b\tc", 100); got != "a b c" {
+ t.Errorf("clampLine: %q", got)
+ }
+}
diff --git a/main.go b/main.go
index 66a9b12..74b3871 100644
--- a/main.go
+++ b/main.go
@@ -190,6 +190,9 @@ func main() {
case "bot":
runBotCmd(os.Args[2:])
return
+ case "agents":
+ runAgentsCmd(os.Args[2:])
+ return
case "add-hooks":
runAddHooks(os.Args[2:])
return
diff --git a/subcommands.go b/subcommands.go
index ddcf047..f417461 100644
--- a/subcommands.go
+++ b/subcommands.go
@@ -43,6 +43,7 @@ var subcommands = []subcommand{
{"fetch", "run the public-web fetch service: unfurl + signed image proxy (`fetch run`)"},
{"hooks", "run the webhook-ingest sidecar (`hooks run`)"},
{"bot", "run the bot sidecar (`bot run`)"},
+ {"agents", "run the workstation bridge for shared Claude Code sessions (`agents run`)"},
// Standing a box up, and putting things on it.
{"bootstrap", "install/refresh this box's systemd units, /data, and the router"},