// 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" "sync" "time" ) var ErrProtocol = errors.New("herdr protocol error") type Request struct { ID string `json:"id"` Method string `json:"method"` Params any `json:"params,omitempty"` } type Response struct { ID string `json:"id"` Result json.RawMessage `json:"result"` Error *struct { Code int `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 } func New(path string) *Client { return &Client{Path: path, Timeout: 10 * time.Second} } func (c *Client) conn() (net.Conn, error) { if c.dial != nil { return c.dial() } return net.DialTimeout("unix", 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 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 string `json:"protocol"` Version string `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 != "" && p.Protocol != want { return fmt.Errorf("%w: want %s, got %s", ErrProtocol, want, p.Protocol) } return nil } type Session struct { PaneID string `json:"pane_id"` Worktree string `json:"worktree"` Harness string `json:"harness"` } func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error { p := map[string]any{"pane_id": pane, "prompt": text, "wait": map[string]any{"until": "turn_end", "timeout_ms": wait.Milliseconds()}} return c.Call(ctx, "agent.prompt", p, nil) } func (c *Client) Worktree(ctx context.Context, path, branch string) (string, error) { var r struct { Path string `json:"path"` } e := c.Call(ctx, "worktree.create", map[string]string{"path": path, "branch": branch}, &r) return r.Path, e } // 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 }