// Package herdr contains the small, protocol-oriented execution seam used by // the orchestration layer. It deliberately does not shell out to a harness. package herdr import ( "bufio" "context" "crypto/sha256" "encoding/json" "errors" "fmt" "net" "os" "os/exec" "path/filepath" "strings" "sync" "time" ) var ErrProtocol = errors.New("herdr protocol error") type Request struct { ID string `json:"id"` Method string `json:"method"` // Herdr's JSON-RPC decoder requires params to be present, including for // parameterless calls such as ping. Encode nil as an explicit JSON null. Params any `json:"params"` } type Response struct { ID string `json:"id"` Result json.RawMessage `json:"result"` Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error,omitempty"` } type Client struct { Path string Timeout time.Duration dial func() (net.Conn, error) mu sync.Mutex next uint64 panes map[string]string } type WorktreeInfo struct { Path string `json:"path"` } type worktreeResponse struct { Path string `json:"path"` Worktree WorktreeInfo `json:"worktree"` RootPane struct { PaneID string `json:"pane_id"` Agent string `json:"agent"` } `json:"root_pane"` Workspace struct { RootPane struct { PaneID string `json:"pane_id"` } `json:"root_pane"` } `json:"workspace"` } func New(path string) *Client { return &Client{Path: path, Timeout: 10 * time.Second, panes: map[string]string{}} } func (c *Client) conn() (net.Conn, error) { if c.dial != nil { return c.dial() } network := "unix" if strings.Contains(c.Path, "://") || (strings.Contains(c.Path, ":") && !strings.HasPrefix(c.Path, "/")) { network = "tcp" } return net.DialTimeout(network, c.Path, c.Timeout) } func (c *Client) Call(ctx context.Context, method string, params any, out any) error { c.mu.Lock() defer c.mu.Unlock() c.next++ id := fmt.Sprint(c.next) cn, err := c.conn() if err != nil { return err } defer cn.Close() if d, ok := ctx.Deadline(); ok { _ = cn.SetDeadline(d) } else if c.Timeout > 0 { _ = cn.SetDeadline(time.Now().Add(c.Timeout)) } if params == nil { params = map[string]any{} } if err = json.NewEncoder(cn).Encode(Request{ID: id, Method: method, Params: params}); err != nil { return err } var r Response if err = json.NewDecoder(bufio.NewReader(cn)).Decode(&r); err != nil { return err } if r.Error != nil { return fmt.Errorf("%w: %s", ErrProtocol, r.Error.Message) } if out != nil && len(r.Result) > 0 { return json.Unmarshal(r.Result, out) } return nil } type PingResult struct { Protocol json.RawMessage `json:"protocol"` Version json.RawMessage `json:"version"` } func (c *Client) Ping(ctx context.Context) (PingResult, error) { var p PingResult err := c.Call(ctx, "ping", nil, &p) return p, err } func (c *Client) CheckProtocol(ctx context.Context, want string) error { p, e := c.Ping(ctx) if e != nil { return e } if want != "" { var text string if err := json.Unmarshal(p.Protocol, &text); err != nil { text = string(p.Protocol) } if text != want { return fmt.Errorf("%w: want %s, got %s", ErrProtocol, want, text) } } return nil } type Session struct { PaneID string `json:"pane_id"` Worktree string `json:"worktree"` Harness string `json:"harness"` HerdrID string `json:"herdr_id,omitempty"` // SessionFile is the filesystem path to the harness's own session/ // transcript state (a Claude Code transcript, a Codex rollout, ...). // ClaudeUsage/CodexUsage/OpenCodeUsage read *this*, never the herdr pane // id — occupancy is a property of the harness's session state, not of // the pane multiplexing it. Left empty until resolved (see // CLIAdapter.Occupancy), since the file may not exist yet immediately // after lease. SessionFile string `json:"session_file,omitempty"` // TaskFileSHA is the sha256 of the worktree's TASK.md at the time this // session's lease was created — the immutable-spec hash continuity's // pickup validation compares against on the next rotation (§6.2). TaskFileSHA string `json:"task_file_sha,omitempty"` // HandoffRequested is set once rotate() has prompted the agent to write // its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt // every tick while Release keeps waiting for the file to appear. HandoffRequested bool `json:"handoff_requested,omitempty"` // ConventionsHash is continuity.ConventionsHash of the project's shared // *.md docs (AGENTS.md/CLAUDE.md/VOCAB.md) at the time this session was // last notified of (or started with) their content — §6.3's staleness // tracking lives at the orchestra layer, never trusted from the agent's // cached view. ConventionsHash string `json:"conventions_hash,omitempty"` } func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error { p := map[string]any{"target": pane, "text": text} if wait > 0 { p["wait"] = map[string]any{"until": []string{"idle"}, "timeout_ms": wait.Milliseconds()} } return c.Call(ctx, "agent.prompt", p, nil) } func (c *Client) Worktree(ctx context.Context, cwd, path, branch string) (string, error) { var r worktreeResponse // Protocol 17 requires exactly one of path or branch. Use the explicit // path so the worker owns the checkout location; herdr manages the branch // associated with that worktree. p := map[string]any{"cwd": cwd, "path": path} e := c.Call(ctx, "worktree.create", p, &r) if e != nil && strings.Contains(strings.ToLower(e.Error()), "already exists") { e = c.Call(ctx, "worktree.open", map[string]any{"cwd": cwd, "path": path}, &r) } if e != nil { return "", e } if r.RootPane.PaneID != "" { c.mu.Lock() c.panes[path] = r.RootPane.PaneID c.mu.Unlock() } if r.Path != "" { return r.Path, nil } return r.Worktree.Path, nil } func (c *Client) StartAgent(ctx context.Context, cwd, path, branch, harness, taskID string) (Session, error) { c.mu.Lock() paneID := c.panes[path] c.mu.Unlock() if paneID == "" { return Session{}, fmt.Errorf("herdr: no pane recorded for worktree %s", path) } var s Session if err := c.Call(ctx, "agent.start", map[string]any{ "pane_id": paneID, "kind": harness, "name": harness, "args": []string{}, }, &s); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already") { return Session{}, err } } s.PaneID = paneID s.Worktree = path s.Harness = harness return s, nil } // HeadSHA returns the current commit of a worktree. The rotation path uses // this to populate TaskReleased.anchor_sha without trusting the adapter's // opaque handoff-ref return value. func HeadSHA(root string) (string, error) { out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output() if err != nil { return "", err } sha := string(out) if len(sha) > 0 && sha[len(sha)-1] == '\n' { sha = sha[:len(sha)-1] } if len(sha) != 40 { return "", fmt.Errorf("herdr: unexpected HEAD output %q", sha) } return sha, nil } // AnchorValid checks the split-then-close safety condition without trusting a // predecessor's prose. dirty maps contain path -> expected SHA-256. func AnchorValid(root, sha string, dirty map[string]string) error { out, e := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output() if e != nil { return e } if string(out) != sha+"\n" { return fmt.Errorf("anchor: HEAD mismatch") } for p, want := range dirty { b, e := os.ReadFile(filepath.Join(root, p)) if e != nil { return e } h := sha256.Sum256(b) if fmt.Sprintf("%x", h) != want { return fmt.Errorf("anchor: %s changed", p) } } return nil }