// 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" "regexp" "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 agents map[string]string // pane ID -> machine-global agent name } 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{}, agents: 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"` AgentName string `json:"agent_name,omitempty"` 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"` // SessionID is the harness-native identity when its usage is stored in a // database rather than a transcript. OpenCode's SQLite session ID is kept // here so rotation never guesses "the newest session" after a restart. SessionID string `json:"session_id,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"` // HandoffReason is selected by the coordinator when it asks for the // semantic report. The checkout worker, rather than the harness, copies // it into the canonical handoff it seals at release time. HandoffReason string `json:"handoff_reason,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"` } // bootDeadline bounds the retry loops below. Freshly created panes/agents // have been observed (live, 2026-07-28) to reject the very next call for a // range of different transient reasons as herdr finishes bringing them up — // "not an available shell", "not an active named agent", "target ... not // found" — a new wording each time a prior one got fixed. There is no other // legitimate reason a call against state orchestra itself just created would // fail immediately, so these loops retry any error rather than pattern-match // an open-ended and apparently still-growing set of herdr wordings, bounded // by wall-clock time rather than attempt count so a slow-booting pane still // gets the same real budget as a fast-failing one. const ( bootRetryWindow = 15 * time.Second bootRetryDelay = 500 * time.Millisecond // agentAttachWindow/agentAttachPoll bound StartAgent's post-success // confirmation poll (B13, found live 2026-07-28): agent.start can return // no error while never actually starting an agent, observed on two of // three back-to-back leases — the pane's agent_status stayed "unknown" // after 2+ minutes of polling, with no error surfaced anywhere. A // legitimate attach has been observed taking "well over a minute", so // this window is deliberately longer than bootRetryWindow. claudeTrustObserveWindow = 15 * time.Second claudeTrustClearWindow = 15 * time.Second claudeTrustPoll = 500 * time.Millisecond ) var ( agentAttachWindow = 90 * time.Second agentAttachPoll = 2 * time.Second ) type paneStatus struct { Agent string `json:"agent"` AgentStatus string `json:"agent_status"` } // paneGetResponse mirrors herdr's pane.get result envelope. The pane fields // are not returned at the top level: the result is {"type":"pane_info", // "pane": {...}}. Keeping that wrapper explicit prevents a successful // agent.start from being falsely reported as unattached. type paneGetResponse struct { Pane paneStatus `json:"pane"` } type paneReadResponse struct { Read struct { Text string `json:"text"` } `json:"read"` } // paneAgentAttached reports whether herdr's own view of the pane shows a // real agent bound to it, rather than trusting agent.start's return value. func (c *Client) paneAgentAttached(ctx context.Context, paneID string) (bool, error) { var p paneGetResponse if err := c.Call(ctx, "pane.get", map[string]any{"pane_id": paneID}, &p); err != nil { return false, err } return p.Pane.Agent != "" && p.Pane.AgentStatus != "" && p.Pane.AgentStatus != "unknown", nil } func (c *Client) paneText(ctx context.Context, paneID string) (string, error) { var p paneReadResponse if err := c.Call(ctx, "pane.read", map[string]any{"pane_id": paneID, "source": "recent"}, &p); err != nil { return "", err } return p.Read.Text, nil } func claudeWorkspaceTrustPrompt(text string) bool { return strings.Contains(text, "Accessing workspace:") && strings.Contains(text, "Yes, I trust this folder") } // confirmClaudeWorkspaceTrust accepts only Claude Code's exact workspace // trust prompt. Orchestra creates its worktrees from registered project // repositories, so leaving this interactive would make every new worktree // permanently unattended-ineligible. It deliberately does not accept any // other Claude confirmation (in particular bypass-permissions mode). func (c *Client) confirmClaudeWorkspaceTrust(ctx context.Context, paneID string) error { deadline := time.Now().Add(claudeTrustObserveWindow) accepted := false for { text, err := c.paneText(ctx, paneID) if err == nil && claudeWorkspaceTrustPrompt(text) { if !accepted { if err := c.Call(ctx, "pane.send_text", map[string]any{"pane_id": paneID, "text": "1\n"}, nil); err != nil { return fmt.Errorf("herdr: accept Claude workspace trust for pane %s: %w", paneID, err) } accepted = true deadline = time.Now().Add(claudeTrustClearWindow) } } else if accepted { return nil } if time.Now().After(deadline) { if accepted { return fmt.Errorf("herdr: Claude workspace trust prompt did not clear for pane %s", paneID) } return nil } select { case <-ctx.Done(): return ctx.Err() case <-time.After(claudeTrustPoll): } } } func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error { var status paneGetResponse if err := c.Call(ctx, "pane.get", map[string]any{"pane_id": pane}, &status); err != nil { return fmt.Errorf("herdr: inspect pane before prompt: %w", err) } if strings.EqualFold(status.Pane.AgentStatus, "blocked") { return fmt.Errorf("herdr: refusing prompt to blocked pane %s", pane) } transcript, err := c.paneText(ctx, pane) if err != nil { return fmt.Errorf("herdr: inspect pane text before prompt: %w", err) } if permissionPrompt(transcript) { return fmt.Errorf("herdr: refusing prompt while pane %s shows a permission dialog", pane) } c.mu.Lock() target := c.agents[pane] c.mu.Unlock() if target == "" { // Session records created before unique agent names were introduced // used the pane as target; retain that compatibility path. target = pane } p := map[string]any{"target": target, "text": text} if wait > 0 { p["wait"] = map[string]any{"until": []string{"idle"}, "timeout_ms": wait.Milliseconds()} } callCtx := ctx var cancel context.CancelFunc if wait > 0 { if d, ok := ctx.Deadline(); !ok || time.Until(d) < wait+5*time.Second { callCtx, cancel = context.WithTimeout(ctx, wait+5*time.Second) defer cancel() } } // A transport failure after writing is ambiguous: herdr may already have // delivered the UI-changing request, so never resend it. A JSON-RPC // protocol error, however, is herdr's explicit rejection before it acted // (notably its short post-start readiness window); that is safe to retry // for the bounded boot window. deadline := time.Now().Add(bootRetryWindow) for { err := c.Call(callCtx, "agent.prompt", p, nil) if err == nil { return nil } if !errors.Is(err, ErrProtocol) || time.Now().After(deadline) { return fmt.Errorf("herdr: prompt delivery is uncertain; not retrying: %w", err) } select { case <-callCtx.Done(): return fmt.Errorf("herdr: prompt delivery is uncertain; not retrying: %w", callCtx.Err()) case <-time.After(bootRetryDelay): } } } // BindAgent restores the pane-to-agent routing from a persisted Session. func (c *Client) BindAgent(pane, agent string) { if pane == "" || agent == "" { return } c.mu.Lock() defer c.mu.Unlock() if c.agents == nil { c.agents = map[string]string{} } c.agents[pane] = agent } func permissionPrompt(text string) bool { text = strings.ToLower(text) return strings.Contains(text, "permission required") || strings.Contains(text, "approval required") || strings.Contains(text, "waiting for approval") } 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 deadline := time.Now().Add(bootRetryWindow) var err error for { s = Session{} err = c.Call(ctx, "agent.start", map[string]any{ "pane_id": paneID, "kind": harness, "name": agentName(harness, taskID), "args": harnessStartArgs(harness), }, &s) if err == nil { break } if strings.Contains(strings.ToLower(err.Error()), "already") { err = nil break } if time.Now().After(deadline) { return Session{}, err } select { case <-ctx.Done(): return Session{}, ctx.Err() case <-time.After(bootRetryDelay): } } if err != nil { return Session{}, err } // B13: agent.start's success does not mean an agent actually attached. // Confirm via pane.get before declaring the lease started, so a silent // no-op surfaces as an observable error (TaskBlocked) instead of leaving // the task leased against a pane that will never produce a session. attachDeadline := time.Now().Add(agentAttachWindow) for { ok, statusErr := c.paneAgentAttached(ctx, paneID) if statusErr == nil && ok { break } if time.Now().After(attachDeadline) { if statusErr != nil { return Session{}, fmt.Errorf("herdr: agent.start reported success for pane %s but confirming attach failed: %w", paneID, statusErr) } return Session{}, fmt.Errorf("herdr: agent.start reported success for pane %s but no agent attached within %s", paneID, agentAttachWindow) } select { case <-ctx.Done(): return Session{}, ctx.Err() case <-time.After(agentAttachPoll): } } if strings.EqualFold(harness, "claude") { if err := c.confirmClaudeWorkspaceTrust(ctx, paneID); err != nil { return Session{}, err } } s.PaneID = paneID s.Worktree = path s.Harness = harness s.AgentName = agentName(harness, taskID) c.BindAgent(paneID, s.AgentName) return s, nil } var invalidAgentName = regexp.MustCompile(`[^a-z0-9_-]+`) func agentName(harness, taskID string) string { prefix := map[string]string{"opencode": "oc", "claude": "cl", "codex": "cx"}[strings.ToLower(harness)] if prefix == "" { prefix = "agent" } id := strings.Trim(invalidAgentName.ReplaceAllString(strings.ToLower(taskID), "-"), "-_") if id == "" { id = "session" } name := prefix + "-" + id if len(name) <= 32 { return name } // Keeping only the leading task-id characters made distinct long task // IDs collide in herdr's machine-global name namespace. Reserve a stable // digest suffix so truncation remains bounded *and* task-specific. sum := sha256.Sum256([]byte(taskID)) const suffixLen = 8 keep := 32 - len(prefix) - 1 - 1 - suffixLen // prefix + "-" + stem + "-" + digest return prefix + "-" + strings.TrimRight(id[:keep], "-_") + "-" + fmt.Sprintf("%x", sum[:])[:suffixLen] } // harnessStartArgs stays empty for Claude: --dangerously-skip-permissions // introduces a separate first-run disclaimer. StartAgent instead acknowledges // only the registered-worktree trust prompt after Claude is running. func harnessStartArgs(harness string) []string { return []string{} } // 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 }