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>
10 files changed,
+1136
−0
agents_bridge_test.go+233 −0agentscmd.go+74 −0internal/agents/bridge.go+253 −0internal/agents/config.go+97 −0internal/agents/config_test.go+62 −0internal/agents/session.go+244 −0internal/agents/translate.go+108 −0internal/agents/translate_test.go+61 −0main.go+3 −0subcommands.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"}, |