Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ the contradiction and the resolution under "Judgement calls" in
optional classifier.
6. **No intervention.** Nothing in this program may act on, prompt, stop, or
modify the agent session it measures. The one outbound write is
`watch --herdr-pane`, which pushes four display tokens with a TTL into
herdr's own sidebar. It reads no herdr state, changes no pane, and touches
no server lifecycle; anything beyond a display token is intervention.
`watch --herdr-pane` / `--herdr-workspace`, which pushes four display
tokens with a TTL into herdr's own sidebar. It reads no herdr state,
changes no pane, and touches no server lifecycle; anything beyond a
display token is intervention.

## Architecture

Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.0] - 2026-08-28

### Added

- `watch --herdr-workspace <id|auto>`: push the meter's four tokens onto a
workspace's space row through `herdr workspace report-metadata`. herdr draws
a pane's sidebar row only for panes it has promoted to agents, so a meter
watching an unpromoted pane pushed tokens no row would ever render; the
space row exists for every workspace.

- `auto` on both reporter flags. `--herdr-pane auto` reuses `--pane auto`
discovery — the agent pane sharing this pane's tab, widening to its
workspace, two candidates an error naming each — and `--herdr-workspace
auto` is the workspace this process runs in, from `HERDR_WORKSPACE_ID` with
`herdr pane current` as the fallback.

- `docs/HERDR.md`: the sidebar row config that renders the tokens. herdr
displays a custom token only where `[ui.sidebar]` rows name it, and its
default rows name none, so pushes landed in the daemon and drew nothing
until that block existed.

### Fixed

- Sidebar pushes after a meter restart were silently dropped. herdr keeps each
source's report `--seq` as a high-water mark that outlives the reporting
process, and the reporter's counter restarted at 1. Seq is now wall-clock
nanoseconds, the scheme herdr's own hook integrations use.

## [0.3.0] - 2026-08-17

### Added
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,19 +177,26 @@ failed: the meter prints what it is waiting for and locks on when the
report lands. Only a pane that cannot be followed at all — no such pane,
ambiguity, a missing command — exits.

The meter does not need a pane of its own. `--herdr-pane <id>` pushes four
The meter does not need a pane of its own. `--herdr-pane <id|auto>` pushes four
display tokens — phase, turn, elapsed, trend — into that pane's herdr sidebar
row, each carrying a 30-second TTL refreshed every 10 seconds:
row, each carrying a 30-second TTL refreshed every 10 seconds. herdr draws that
row only for a pane it has promoted to an agent; `--herdr-workspace <id|auto>`
pushes the same tokens onto a workspace's space row, which every workspace has.
`auto` is the agent pane sharing this tab for the pane form, and this process's
own workspace for the workspace form:

```bash
flow-indicator watch --pane auto --herdr-pane w1W:p1
flow-indicator watch --pane auto --herdr-pane auto
flow-indicator watch --current --herdr-workspace auto
```

The push is one bounded subprocess that never blocks the draw loop, and a failed
push is ignored: herdr may not be running, and a meter that cannot draw a
sidebar row is still a meter. If this process dies the row expires rather than
showing a phase that stopped being true. It reads no herdr state, changes no
pane, and touches no server lifecycle.
pane, and touches no server lifecycle. The tokens render only where herdr's
sidebar config names them; the one config block that is needed is in
[docs/HERDR.md](docs/HERDR.md).

Pane resolution, restart following, the token contract and the layouts are in
[docs/HERDR.md](docs/HERDR.md).
Expand Down
106 changes: 86 additions & 20 deletions cmd/flow-indicator/herdr.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,26 @@ package main

import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"time"
)

// Reporting the meter into a herdr sidebar row.
//
// herdr renders pane metadata tokens in its own sidebar, so the phase does not
// herdr renders metadata tokens in its own sidebar, so the phase does not
// need a pane of its own to be visible. This pushes four tokens and nothing
// else: it never reads herdr's state, never changes a pane, and never touches
// the running server's lifecycle.
//
// Two targets exist because they display under different conditions. Pane
// tokens render only on an agents-panel row, which herdr draws only for a
// pane it has promoted to an agent; workspace tokens render on the space row
// every workspace already has. Either way the tokens are invisible until the
// herdr sidebar config names them ($flow_phase and friends) in a row.
//
// Every push carries a TTL. If this process dies, the row expires instead of
// showing a phase that stopped being true.

Expand All @@ -37,6 +45,58 @@ const (
herdrTimeout = 3 * time.Second
)

// resolveHerdrTarget turns --herdr-pane and --herdr-workspace into the
// reporter's scope and id. At most one may be set; both empty disables the
// reporter. auto resolves against the herdr this process runs inside: the
// pane form reuses pane discovery — the agent pane sharing this pane's tab,
// widening to its workspace — and the workspace form is this process's own
// workspace, from HERDR_WORKSPACE_ID or herdr pane current.
func resolveHerdrTarget(pane, workspace string) (scope, id string, err error) {
if pane != "" && workspace != "" {
return "", "", errors.New("watch: --herdr-pane and --herdr-workspace name one sidebar row each; pass one")
}
switch {
case workspace == paneAuto:
if id := os.Getenv("HERDR_WORKSPACE_ID"); id != "" {
return "workspace", id, nil
}
raw, err := herdrQuery("pane", "current")
if err != nil {
return "", "", err
}
self, err := parseCurrentPane(raw)
if err != nil {
return "", "", err
}
return "workspace", self.WorkspaceID, nil
case workspace != "":
return "workspace", workspace, nil
case pane == paneAuto:
raw, err := herdrQuery("agent", "list")
if err != nil {
return "", "", err
}
agents, err := parseAgentPanes(raw)
if err != nil {
return "", "", err
}
currentRaw, err := herdrQuery("pane", "current")
if err != nil {
return "", "", err
}
self, err := parseCurrentPane(currentRaw)
if err != nil {
return "", "", err
}
target, err := choosePane("--herdr-pane", paneAuto, self, agents)
if err != nil {
return "", "", err
}
return "pane", target.PaneID, nil
}
return "pane", pane, nil
}

// herdrRow is the state one sidebar row shows.
type herdrRow struct {
phase string
Expand Down Expand Up @@ -64,10 +124,11 @@ func (r herdrRow) tokens() []string {
return args
}

// herdrArgs is the full command line for one push.
func herdrArgs(pane string, seq int, r herdrRow) []string {
// herdrArgs is the full command line for one push. scope is "pane" or
// "workspace" — the two report-metadata surfaces herdr exposes.
func herdrArgs(scope, id string, seq int64, r herdrRow) []string {
args := []string{
"pane", "report-metadata", pane,
scope, "report-metadata", id,
"--source", herdrSource,
"--seq", fmt.Sprintf("%d", seq),
"--ttl-ms", fmt.Sprintf("%d", herdrTTL.Milliseconds()),
Expand All @@ -81,51 +142,56 @@ func herdrArgs(pane string, seq int, r herdrRow) []string {
// push is still running, the offer is dropped, because the next draw is a
// better row than the one that could not be sent.
type herdrReporter struct {
pane string
rows chan herdrRow
done chan struct{}
scope string
id string
rows chan herdrRow
done chan struct{}
}

// newHerdrReporter starts a reporter for a pane. An empty pane disables it,
// and every method on a nil reporter is a no-op.
func newHerdrReporter(ctx context.Context, pane string) *herdrReporter {
if pane == "" {
// newHerdrReporter starts a reporter for one pane or workspace. An empty id
// disables it, and every method on a nil reporter is a no-op.
func newHerdrReporter(ctx context.Context, scope, id string) *herdrReporter {
if id == "" {
return nil
}
h := &herdrReporter{
pane: pane,
rows: make(chan herdrRow, 1),
done: make(chan struct{}),
scope: scope,
id: id,
rows: make(chan herdrRow, 1),
done: make(chan struct{}),
}
go h.run(ctx)
return h
}

// seq is the pane's per-source freshness mark, and herdr keeps the highest
// value it has ever accepted for a source, outliving this process. A counter
// restarting at 1 would sit below the previous run's mark and every push
// would be dropped as stale, so seq is wall-clock nanoseconds — above any
// earlier run's mark, the same scheme herdr's own hook integrations use.
func (h *herdrReporter) run(ctx context.Context) {
defer close(h.done)
seq := 0
for {
select {
case <-ctx.Done():
// The row outlives this process only until its TTL runs out, but
// clearing it now means the sidebar never shows a stopped meter as
// a live one. The context is already cancelled, so the clear gets
// its own bounded one.
h.push(context.Background(), seq+1, herdrRow{})
h.push(context.Background(), time.Now().UnixNano(), herdrRow{})
return
case row := <-h.rows:
seq++
h.push(ctx, seq, row)
h.push(ctx, time.Now().UnixNano(), row)
}
}
}

// push runs one report. Failures are ignored: herdr may not be running, and a
// meter that cannot draw a sidebar row is still a working meter.
func (h *herdrReporter) push(ctx context.Context, seq int, row herdrRow) {
func (h *herdrReporter) push(ctx context.Context, seq int64, row herdrRow) {
ctx, cancel := context.WithTimeout(ctx, herdrTimeout)
defer cancel()
_ = exec.CommandContext(ctx, "herdr", herdrArgs(h.pane, seq, row)...).Run()
_ = exec.CommandContext(ctx, "herdr", herdrArgs(h.scope, h.id, seq, row)...).Run()
}

// Report offers a row. It never blocks and never fails.
Expand Down
43 changes: 40 additions & 3 deletions cmd/flow-indicator/herdr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
// The row is pushed as tokens herdr can render in a sidebar row, with a TTL so
// a dead meter expires instead of leaving a phase that stopped being true.
func TestHerdrArgs(t *testing.T) {
args := herdrArgs("w1V:p1", 7, herdrRow{
args := herdrArgs("pane", "w1V:p1", 7, herdrRow{
phase: "THRASH", turn: "turn 12", elapsed: "53m40s", trend: "CPB mid → bad",
})
joined := strings.Join(args, " ")
Expand Down Expand Up @@ -45,16 +45,53 @@ func TestHerdrClearsEmptyTokens(t *testing.T) {
}
}

// A workspace target reports through the workspace surface, which herdr
// renders on the space row every workspace has, promoted agent or not.
func TestHerdrArgsWorkspaceScope(t *testing.T) {
args := herdrArgs("workspace", "w3G", 7, herdrRow{phase: "FLOW"})
joined := strings.Join(args, " ")
if !strings.Contains(joined, "workspace report-metadata w3G") {
t.Errorf("workspace scope not used:\n%s", joined)
}
}

// Target resolution: explicit ids pass through, both flags at once is an
// error, and workspace auto is this process's own workspace from the
// environment. The pane auto branch reuses choosePane, which pane_test.go
// covers, and needs a live herdr, so it is not exercised here.
func TestResolveHerdrTarget(t *testing.T) {
if _, _, err := resolveHerdrTarget("w1:p1", "w1"); err == nil {
t.Error("both flags set did not error")
}
scope, id, err := resolveHerdrTarget("", "")
if err != nil || scope != "pane" || id != "" {
t.Errorf("empty flags = (%s, %q, %v); the reporter must stay disabled", scope, id, err)
}
scope, id, err = resolveHerdrTarget("w1:p1", "")
if err != nil || scope != "pane" || id != "w1:p1" {
t.Errorf("explicit pane = (%s, %q, %v)", scope, id, err)
}
scope, id, err = resolveHerdrTarget("", "w3G")
if err != nil || scope != "workspace" || id != "w3G" {
t.Errorf("explicit workspace = (%s, %q, %v)", scope, id, err)
}
t.Setenv("HERDR_WORKSPACE_ID", "w9")
scope, id, err = resolveHerdrTarget("", "auto")
if err != nil || scope != "workspace" || id != "w9" {
t.Errorf("workspace auto = (%s, %q, %v); want the workspace HERDR_WORKSPACE_ID names", scope, id, err)
}
}

// A nil reporter is the disabled case and must be inert.
func TestHerdrReporterNilIsInert(t *testing.T) {
var h *herdrReporter
h.Report(herdrRow{phase: "FLOW"})
h.Close()
}

// Disabling is by empty pane, and must not start a worker.
// Disabling is by empty target id, and must not start a worker.
func TestHerdrDisabledWithoutPane(t *testing.T) {
if h := newHerdrReporter(t.Context(), ""); h != nil {
if h := newHerdrReporter(t.Context(), "pane", ""); h != nil {
t.Error("a reporter was started with no pane to report to")
}
}
Loading