// 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 }