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