// Package herdr reports Codey's state to herdr, the terminal workspace
// manager, when Codey is running inside one of its panes.
//
// Herdr owns the terminal a coding agent runs in and shows, across every
// project, which agent is working, which is blocked waiting on a human, and
// which is idle. For agents it ships support for it reads that off the screen
// with pattern rules; for anything else it offers a contract, which is what
// this implements: an agent inside a pane inherits HERDR_ENV=1, HERDR_PANE_ID
// and HERDR_BIN_PATH, and reports semantic state by running
//
// "$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
// --source custom:codey --agent codey --state working
//
// releasing the claim on exit. Reporting beats screen-scraping for us in both
// directions: herdr never has to guess from what the TUI drew, and a state it
// could not see at all — that the agent is sitting on an approval prompt
// behind a full-screen view — is stated outright.
//
// Everything here is a no-op outside herdr. Detect returns nil when the
// environment does not say we are in a pane, which is the whole of the
// activation logic: no probing, no socket connection, no cost to anyone who
// has never heard of herdr.
package herdr
import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"codey/internal/hooks"
)
// The identity Codey claims in a pane. Source must be stable across runs and
// unique to this integration: herdr keys lifecycle authority on it, so a
// source that changed between reports would leave the old one owning the pane.
const (
Source = "custom:codey"
Agent = "codey"
)
// The states herdr understands. Anything else is refused by its CLI, so they
// are spelled once here rather than at each call.
const (
StateWorking = "working"
StateBlocked = "blocked"
StateIdle = "idle"
)
// reportTimeout bounds one call to the herdr CLI. Reports are fired from the
// agent's goroutine at turn boundaries, so this is latency the user would
// feel; herdr is a local binary talking to a local socket, and one that has
// not answered in two seconds is one worth giving up on until the next event.
const reportTimeout = 2 * time.Second
// maxMessage is how much of a block reason is worth sending. The message is a
// line in a sidebar, not a report.
const maxMessage = 160
// Reporter maps Codey's lifecycle events to herdr's agent states.
//
// A pane holds one Codey process, and one Codey process can hold several
// sessions at once — tabs. Herdr has one state per pane, so the states are
// tracked per session and reduced: blocked beats working beats idle, because
// what a person scanning a sidebar needs to find is the agent that stopped and
// wants them, and after that the one still going.
type Reporter struct {
bin string
pane string
// run executes the herdr CLI. A field so the tests do not need herdr
// installed; nil means the real one.
run func(ctx context.Context, bin string, args []string) error
mu sync.Mutex
// states is what each live session is doing, keyed by session id.
states map[string]*sessionState
// session is the native session reference handed to herdr for restore:
// the first session opened, which is the one `codey -resume` would want.
session string
// last is what was last reported, so an unchanged state costs nothing.
last string
lastMsg string
lastSess string
// seq strictly increases across reports. Herdr ignores a stale sequence
// number from the same source, which is what keeps a slow report for an
// old state from landing on top of a new one.
seq int64
reported bool
closed bool
}
// Detect returns a reporter when this process is running inside a herdr pane,
// and nil when it is not.
func Detect() *Reporter { return detect(os.Getenv) }
// detect is Detect against an arbitrary environment, for tests.
func detect(env func(string) string) *Reporter {
// HERDR_ENV=1 is the flag herdr documents for "you are inside a pane".
// Requiring the other two as well is not paranoia: a stale HERDR_ENV
// exported into a shell that outlived its pane is exactly the case where
// reporting would go nowhere, slowly, forever.
if env("HERDR_ENV") != "1" {
return nil
}
pane, bin := env("HERDR_PANE_ID"), env("HERDR_BIN_PATH")
if pane == "" || bin == "" {
return nil
}
return &Reporter{bin: bin, pane: pane, states: map[string]*sessionState{}}
}
// sessionState is one conversation as herdr should see it.
//
// The two halves are tracked separately because they move independently: the
// loop says whether it is running, and a prompt says whether somebody is being
// waited on, and a subagent can be holding a prompt open long after the
// session's own agent went quiet. Folding them into one field would mean an
// answered prompt had to guess what the agent had been doing before it.
type sessionState struct {
// base is what the loop last said: working or idle.
base string
// blocks are the messages of the prompts currently open, innermost last.
// A count rather than a flag because a session's own agent and a subagent
// of it can both be waiting, and the first answer given must not clear the
// second question.
blocks []string
}
// state is what this session is, once both halves are taken into account.
func (s *sessionState) state() string {
if len(s.blocks) > 0 {
return StateBlocked
}
if s.base == "" {
return StateIdle
}
return s.base
}
// message is what it is waiting for, if anything. The innermost prompt wins:
// it is the one on screen.
func (s *sessionState) message() string {
if len(s.blocks) == 0 {
return ""
}
return s.blocks[len(s.blocks)-1]
}
// Pane reports which pane this is reporting to, for a startup notice.
func (r *Reporter) Pane() string {
if r == nil {
return ""
}
return r.pane
}
// IsNil lets hooks.Add drop a typed-nil reporter, so a caller can write
// hooks.Add(herdr.Detect()) without a nil check of its own.
func (r *Reporter) IsNil() bool { return r == nil }
// Handle implements hooks.Handler: it folds one lifecycle event into this
// pane's state and reports the result if it changed.
func (r *Reporter) Handle(ctx context.Context, p hooks.Payload) error {
if r == nil {
return nil
}
// A subagent's own comings and goings are not the pane's state: its parent
// is running while it works, and already says so. Its prompts are another
// matter — an approval a subagent needs is a person being waited on just
// as much as one its parent needs, and the parent may well have gone quiet
// long before it was asked, since spawning does not block.
if p.Agent != "" && p.Event != hooks.Blocked && p.Event != hooks.Unblocked {
return nil
}
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return nil
}
r.applyLocked(p)
args, send := r.nextLocked()
r.mu.Unlock()
if !send {
return nil
}
return r.exec(ctx, args)
}
// applyLocked folds one event into the session it came from. The caller holds
// the lock.
func (r *Reporter) applyLocked(p hooks.Payload) {
if p.Event == hooks.SessionEnd && p.Agent == "" {
delete(r.states, p.Session)
return
}
s := r.states[p.Session]
if s == nil {
s = &sessionState{}
r.states[p.Session] = s
}
switch p.Event {
case hooks.SessionStart:
// The first session opened is the one `codey -resume` would want, and
// so the one worth telling herdr about.
if r.session == "" {
r.session = p.Session
}
s.base = StateIdle
case hooks.RunStart, hooks.TurnStart, hooks.ToolStart, hooks.ToolResult, hooks.Resumed:
s.base = StateWorking
case hooks.RunEnd:
// The model stopped asking for tools: the answer is on screen and the
// next move is the user's.
s.base = StateIdle
case hooks.Paused:
// Held at a turn boundary by the user. Not working, and not waiting on
// an answer either — idle is the honest reading, and it keeps a paused
// agent out of the list of ones that need attention.
s.base = StateIdle
case hooks.Blocked:
s.blocks = append(s.blocks, blockMessage(p))
case hooks.Unblocked:
if len(s.blocks) > 0 {
s.blocks = s.blocks[:len(s.blocks)-1]
}
}
}
// blockMessage is the one-line explanation shown beside a blocked pane.
func blockMessage(p hooks.Payload) string {
msg := p.Message
if msg == "" {
switch p.Reason {
case hooks.ReasonApproval:
msg = "waiting for approval"
case hooks.ReasonQuestion:
msg = "waiting for an answer"
default:
msg = "waiting for input"
}
}
return sanitize(msg)
}
// sanitize makes a message safe to pass as a command-line argument and
// sensible to show in a sidebar: one line, no control characters, bounded.
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r == '\r' {
return ' '
}
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
s = strings.Join(strings.Fields(s), " ")
if len(s) > maxMessage {
s = strings.TrimSpace(s[:maxMessage]) + "…"
}
return s
}
// nextLocked reduces the per-session states to the one the pane is in and
// builds the command for it, or reports that nothing changed. The caller holds
// the lock.
func (r *Reporter) nextLocked() (args []string, send bool) {
state, message := reduce(r.states)
if state == "" {
// Every session has ended. The claim is released by Close, not here:
// the process is still alive, and a released source that reported
// again would be claiming the pane back.
return nil, false
}
if r.reported && state == r.last && message == r.lastMsg && r.session == r.lastSess {
return nil, false
}
r.seq++
r.last, r.lastMsg, r.lastSess, r.reported = state, message, r.session, true
args = []string{
"pane", "report-agent", r.pane,
"--source", Source,
"--agent", Agent,
"--state", state,
"--seq", strconv.FormatInt(r.seq, 10),
}
if message != "" {
args = append(args, "--message", message)
}
// The session reference travels with the state report rather than through
// a second call: herdr accepts it there, and one process is one report.
if r.session != "" {
args = append(args, "--agent-session-id", r.session)
}
return args, true
}
// reduce picks the state of a pane holding several sessions. Blocked wins
// because it is the one that needs a person; working beats idle because a pane
// with anything running in it is not idle.
func reduce(states map[string]*sessionState) (state, message string) {
var working, idle bool
for _, s := range states {
switch s.state() {
case StateBlocked:
// Which blocked session's message is shown is arbitrary when
// there are two, since map order is: the state is what a sidebar
// sorts on, and either message names a real thing being waited on.
if state != StateBlocked {
state, message = StateBlocked, s.message()
}
case StateWorking:
working = true
default:
idle = true
}
}
if state == StateBlocked {
return state, message
}
switch {
case working:
return StateWorking, ""
case idle:
return StateIdle, ""
default:
return "", ""
}
}
// Close releases this source's claim on the pane, so herdr stops attributing a
// state to a Codey that is no longer there. It is safe to call twice.
func (r *Reporter) Close() error {
if r == nil {
return nil
}
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return nil
}
r.closed = true
reported := r.reported
r.mu.Unlock()
if !reported {
// Never claimed the pane, so there is nothing to release.
return nil
}
return r.exec(context.Background(), []string{
"pane", "release-agent", r.pane,
"--source", Source,
"--agent", Agent,
})
}
// exec runs the herdr CLI with its own deadline, detached from the turn's
// context: an interrupted turn still has a state worth reporting, and the
// report that says so must not be cancelled by the interrupt that caused it.
func (r *Reporter) exec(ctx context.Context, args []string) error {
run := r.run
if run == nil {
run = runCLI
}
cmdCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), reportTimeout)
defer cancel()
if err := run(cmdCtx, r.bin, args); err != nil {
return fmt.Errorf("herdr %s: %w", strings.Join(args[:2], " "), err)
}
return nil
}
// runCLI is the real herdr invocation. Output is discarded unless the command
// failed: herdr's CLI prints nothing useful on success, and anything it did
// print would land in the middle of the TUI.
func runCLI(ctx context.Context, bin string, args []string) error {
cmd := exec.CommandContext(ctx, bin, args...)
out, err := cmd.CombinedOutput()
if err != nil {
if trimmed := strings.TrimSpace(string(out)); trimmed != "" {
return fmt.Errorf("%w: %s", err, trimmed)
}
return err
}
return nil
}