diff --git a/AGENTS.md b/AGENTS.md index d993c2d..4e5eb50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5136200..224fdef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `: 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 diff --git a/README.md b/README.md index 18baecf..9ffad67 100644 --- a/README.md +++ b/README.md @@ -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 ` pushes four +The meter does not need a pane of its own. `--herdr-pane ` 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 ` +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). diff --git a/cmd/flow-indicator/herdr.go b/cmd/flow-indicator/herdr.go index 9ef8c2e..e6645cc 100644 --- a/cmd/flow-indicator/herdr.go +++ b/cmd/flow-indicator/herdr.go @@ -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. @@ -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 @@ -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()), @@ -81,29 +142,35 @@ 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(): @@ -111,21 +178,20 @@ func (h *herdrReporter) run(ctx context.Context) { // 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. diff --git a/cmd/flow-indicator/herdr_test.go b/cmd/flow-indicator/herdr_test.go index 3dabcac..4e79b2d 100644 --- a/cmd/flow-indicator/herdr_test.go +++ b/cmd/flow-indicator/herdr_test.go @@ -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, " ") @@ -45,6 +45,43 @@ 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 @@ -52,9 +89,9 @@ func TestHerdrReporterNilIsInert(t *testing.T) { 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") } } diff --git a/cmd/flow-indicator/main.go b/cmd/flow-indicator/main.go index c13d0ab..4a6eb8e 100644 --- a/cmd/flow-indicator/main.go +++ b/cmd/flow-indicator/main.go @@ -131,8 +131,15 @@ Bootstrap reads the source and never writes to it: about 0.2 s on a --offset with a named file, start at a byte offset --stream-id override the stream identifier --force replace an existing session directory - --herdr-pane also push the phase into this herdr pane's - sidebar as metadata tokens, with a TTL + --herdr-pane + also push the phase into this herdr pane's + sidebar as metadata tokens, with a TTL; the + row exists only for a promoted agent pane. + auto finds the agent pane sharing this tab + --herdr-workspace + push the same tokens onto a workspace's + space row, which every workspace has. + auto is the workspace this process runs in --config configuration file --data-dir storage root --profile marker lexicon overlay @@ -669,7 +676,8 @@ func watch(args []string) error { adapterName := fs.String("adapter", "claude-code", "adapter or harness name: "+strings.Join(harness.Names(), ", ")) sessionRoot := fs.String("root", "", "working directory action targets are made relative to, for sources that record none") streamID := fs.String("stream-id", "", "override the stream identifier") - herdrPane := fs.String("herdr-pane", "", "report the phase into this herdr pane's sidebar row as metadata tokens") + herdrPane := fs.String("herdr-pane", "", "report the phase into this herdr pane's sidebar row as metadata tokens; auto finds the agent pane sharing this tab") + herdrWorkspace := fs.String("herdr-workspace", "", "report the phase into this herdr workspace's space row as metadata tokens; auto is this process's workspace") current := fs.Bool("current", false, "follow the session recorded for this working directory") last := fs.Bool("last", false, "follow the most recently written session on this machine") pane := fs.String("pane", "", "follow the agent session in this herdr pane; auto finds the agent pane sharing this pane's tab") @@ -706,7 +714,11 @@ func watch(args []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - view := display{micro: !*full, color: useColor(*noColor), width: *width, herdrPane: *herdrPane} + herdrScope, herdrID, err := resolveHerdrTarget(*herdrPane, *herdrWorkspace) + if err != nil { + return err + } + view := display{micro: !*full, color: useColor(*noColor), width: *width, herdrScope: herdrScope, herdrID: herdrID} // --adapter carries a default, so its value cannot say whether the operator // chose it. Only a flag actually passed narrows a search or overrides an @@ -833,9 +845,22 @@ type display struct { micro bool color bool width int - // herdrPane names a pane whose sidebar row should carry the phase. Empty - // means the meter draws only into its own terminal. - herdrPane string + // herdrScope and herdrID name the sidebar row that should carry the + // phase, already resolved from --herdr-pane / --herdr-workspace; herdr + // draws a pane row only for a promoted agent, a space row for every + // workspace. An empty id means the meter draws only into its own + // terminal. + herdrScope string + herdrID string +} + +// herdrPaneID is the pane the instance record names, when the reporter +// targets one. +func (d display) herdrPaneID() string { + if d.herdrScope == "pane" { + return d.herdrID + } + return "" } // useColor honours --no-color and the NO_COLOR convention, and stays off when @@ -879,7 +904,7 @@ func watchFile(ctx context.Context, cfg config.Config, root, harnessName string, return err } defer session.Close() - emit, err := newInstanceEmitter(root, id, harnessName, abs, sessionRoot, view.herdrPane) + emit, err := newInstanceEmitter(root, id, harnessName, abs, sessionRoot, view.herdrPaneID()) if err != nil { return err } @@ -916,7 +941,7 @@ func watchFile(ctx context.Context, cfg config.Config, root, harnessName string, live := liveness{last: time.Now()} moves := render.NewHighlights() - herdr := newHerdrReporter(ctx, view.herdrPane) + herdr := newHerdrReporter(ctx, view.herdrScope, view.herdrID) defer herdr.Close() var lastRow herdrRow var lastPush time.Time diff --git a/cmd/flow-indicator/pane.go b/cmd/flow-indicator/pane.go index d3a7c96..dbcd18e 100644 --- a/cmd/flow-indicator/pane.go +++ b/cmd/flow-indicator/pane.go @@ -86,8 +86,9 @@ func parseCurrentPane(raw []byte) (agentPane, error) { return *env.Result.Pane, nil } -// paneChoices formats the agent panes as --pane arguments for an error. -func paneChoices(agents []agentPane) string { +// paneChoices formats the agent panes as arguments for the named flag, for +// an error. +func paneChoices(flag string, agents []agentPane) string { if len(agents) == 0 { return "; no agent panes are running" } @@ -95,7 +96,7 @@ func paneChoices(agents []agentPane) string { sort.Slice(sorted, func(i, j int) bool { return sorted[i].PaneID < sorted[j].PaneID }) var b strings.Builder for _, a := range sorted { - fmt.Fprintf(&b, "\n --pane %s (%s)", a.PaneID, a.Agent) + fmt.Fprintf(&b, "\n %s %s (%s)", flag, a.PaneID, a.Agent) } return b.String() } @@ -107,14 +108,14 @@ func paneChoices(agents []agentPane) string { // split means the agent beside that split, not one three tabs away. A tab // with no agent widens to the workspace, for the layout where the meter runs // in a tab of its own. -func choosePane(target string, self agentPane, agents []agentPane) (agentPane, error) { +func choosePane(flag, target string, self agentPane, agents []agentPane) (agentPane, error) { if target != paneAuto { for _, a := range agents { if a.PaneID == target { return a, nil } } - return agentPane{}, fmt.Errorf("pane: no agent runs in pane %s%s", target, paneChoices(agents)) + return agentPane{}, fmt.Errorf("pane: no agent runs in pane %s%s", target, paneChoices(flag, agents)) } candidates := filterAgentPanes(agents, func(a agentPane) bool { return a.TabID == self.TabID }) if len(candidates) == 0 { @@ -123,12 +124,12 @@ func choosePane(target string, self agentPane, agents []agentPane) (agentPane, e switch len(candidates) { case 0: return agentPane{}, fmt.Errorf("pane: no agent pane in tab %s or workspace %s%s", - self.TabID, self.WorkspaceID, paneChoices(agents)) + self.TabID, self.WorkspaceID, paneChoices(flag, agents)) case 1: return candidates[0], nil } return agentPane{}, fmt.Errorf("pane: more than one agent pane is adjacent; pass the one to follow%s", - paneChoices(candidates)) + paneChoices(flag, candidates)) } func filterAgentPanes(agents []agentPane, keep func(agentPane) bool) []agentPane { @@ -232,7 +233,7 @@ func paneTarget(target string) (agentPane, string, error) { return agentPane{}, "", err } } - pane, err := choosePane(target, self, agents) + pane, err := choosePane("--pane", target, self, agents) if err != nil { return agentPane{}, "", err } diff --git a/cmd/flow-indicator/pane_test.go b/cmd/flow-indicator/pane_test.go index 6f9fa3d..f8dd655 100644 --- a/cmd/flow-indicator/pane_test.go +++ b/cmd/flow-indicator/pane_test.go @@ -55,7 +55,7 @@ func TestParseCurrentPane(t *testing.T) { func TestChoosePaneExplicit(t *testing.T) { agents := fixtureAgents(t) - got, err := choosePane("w1W:p1", agentPane{}, agents) + got, err := choosePane("--pane", "w1W:p1", agentPane{}, agents) if err != nil { t.Fatal(err) } @@ -65,7 +65,7 @@ func TestChoosePaneExplicit(t *testing.T) { } func TestChoosePaneExplicitMissListsTheChoices(t *testing.T) { - _, err := choosePane("w9Z:p1", agentPane{}, fixtureAgents(t)) + _, err := choosePane("--pane", "w9Z:p1", agentPane{}, fixtureAgents(t)) if err == nil { t.Fatal("want an error for a pane with no agent") } @@ -82,7 +82,7 @@ func TestChoosePaneExplicitMissListsTheChoices(t *testing.T) { func TestChoosePaneAutoPrefersTheTab(t *testing.T) { agents := fixtureAgents(t) self := agentPane{PaneID: "w1W:p8", TabID: "w1W:t2", WorkspaceID: "w1W"} - got, err := choosePane(paneAuto, self, agents) + got, err := choosePane("--pane", paneAuto, self, agents) if err != nil { t.Fatal(err) } @@ -94,7 +94,7 @@ func TestChoosePaneAutoPrefersTheTab(t *testing.T) { func TestChoosePaneAutoWidensToTheWorkspace(t *testing.T) { agents := []agentPane{{PaneID: "w2:p1", TabID: "w2:t1", WorkspaceID: "w2", Agent: "claude"}} self := agentPane{PaneID: "w2:p4", TabID: "w2:t3", WorkspaceID: "w2"} - got, err := choosePane(paneAuto, self, agents) + got, err := choosePane("--pane", paneAuto, self, agents) if err != nil { t.Fatal(err) } @@ -106,7 +106,7 @@ func TestChoosePaneAutoWidensToTheWorkspace(t *testing.T) { func TestChoosePaneAutoRefusesTwoCandidates(t *testing.T) { agents := fixtureAgents(t) self := agentPane{PaneID: "w1W:p9", TabID: "w1W:t9", WorkspaceID: "w1W"} - _, err := choosePane(paneAuto, self, agents) + _, err := choosePane("--pane", paneAuto, self, agents) if err == nil || !strings.Contains(err.Error(), "--pane w1W:p1") || !strings.Contains(err.Error(), "--pane w1W:p2") { t.Fatalf("want both candidates offered, got %v", err) } @@ -114,7 +114,7 @@ func TestChoosePaneAutoRefusesTwoCandidates(t *testing.T) { func TestChoosePaneAutoWithNoAgentsSaysSo(t *testing.T) { self := agentPane{TabID: "w1:t1", WorkspaceID: "w1"} - _, err := choosePane(paneAuto, self, nil) + _, err := choosePane("--pane", paneAuto, self, nil) if err == nil || !strings.Contains(err.Error(), "no agent pane") { t.Fatalf("want a no-agent error, got %v", err) } diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 7590a69..6166553 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -142,7 +142,8 @@ a restart. See [HERDR.md](HERDR.md). | `--current` | `false` | the session recorded for this working directory | | `--last` | `false` | the most recently written session on this machine | | `--pane ` | `""` | the session the agent in a herdr pane is running | -| `--herdr-pane ` | `""` | also push phase, turn, elapsed and trend into that pane's sidebar row | +| `--herdr-pane ` | `""` | also push phase, turn, elapsed and trend into that pane's sidebar row | +| `--herdr-workspace ` | `""` | push the same tokens onto that workspace's space row | | `--tail-only` | `false` | follow from the end, building no state first | | `--full` | `false` | the one-screen audit view instead of the side-pane meter | | `--no-color` | `false` | no ANSI attributes; `NO_COLOR` is honoured | diff --git a/docs/HERDR.md b/docs/HERDR.md index affa92b..6a40775 100644 --- a/docs/HERDR.md +++ b/docs/HERDR.md @@ -7,16 +7,20 @@ the meter: | flag | direction | herdr command | |---|---|---| | `watch --pane ` | read | `herdr agent list`, `herdr pane current` | -| `watch --herdr-pane ` | write | `herdr pane report-metadata` | +| `watch --herdr-pane ` | write | `herdr pane report-metadata` | +| `watch --herdr-workspace ` | write | `herdr workspace report-metadata` | The read side answers "which session is this pane running". The write side puts -four display tokens in that pane's sidebar row. They are independent: either can -be used alone, and `--pane auto --herdr-pane ` uses both. +four display tokens in a sidebar row — a pane's agents-panel row, or a +workspace's space row. Reads and writes are independent: either can be used +alone, and `--pane auto --herdr-pane auto` uses both. The two write flags name +one row each; passing both is an error. Without herdr, `watch --current`, `watch --last` and `watch ` work -unchanged. `--pane` requires the `herdr` command on `PATH`; `--herdr-pane` does not, and a -missing command means the sidebar row never appears. `--pane` -additionally requires the daemon to be answering. +unchanged. `--pane` requires the `herdr` command on `PATH`; an explicit +`--herdr-pane` / `--herdr-workspace` id does not, and a missing command means +the sidebar row never appears. `auto` on any flag resolves through the command, +so it needs `PATH` and the daemon answering, as `--pane` does. ## Why pane instead of directory @@ -99,15 +103,29 @@ agent exiting and its replacement starting looks exactly like a quiet pane from outside, so only a new id acts. `--tail-only` applies to the first session only; a session that appears mid-watch is new and is replayed whole. -## `--herdr-pane`: the sidebar row +## `--herdr-pane`, `--herdr-workspace`: the sidebar row The meter does not need a pane of its own. `--herdr-pane` pushes the phase into -another pane's herdr sidebar row: +another pane's herdr sidebar row, and `--herdr-workspace` into a workspace's +space row: ```bash flow-indicator watch --pane auto --herdr-pane w2E:p1 +flow-indicator watch --current --herdr-workspace auto ``` +Which row to target: herdr draws an agents-panel row only for a pane it has +promoted to an agent — one with an `agent` in `herdr agent list`. Tokens pushed +to an unpromoted pane land in the daemon and are never rendered. The space row +exists for every workspace, promoted agent or not, so `--herdr-workspace` is +the target when the watched agent has no herdr lifecycle integration. + +`auto` resolves against the herdr this process runs inside. The pane form +reuses `--pane auto` discovery — the agent pane sharing this pane's tab, +widening to its workspace; two candidates are an error naming each. The +workspace form is this process's own workspace, from `HERDR_WORKSPACE_ID`, or +`herdr pane current` when the variable is absent. + Four tokens, and nothing else: | token | value | @@ -118,8 +136,32 @@ Four tokens, and nothing else: | `flow_trend` | the most recent band crossing, e.g. `F → D` | Each push is one `herdr pane report-metadata --source flow-indicator ---seq --ttl-ms 30000` with a `--token name=value` per field. An empty field -is pushed as `--clear-token`, so a row never keeps a value that stopped holding. +--seq --ttl-ms 30000` with a `--token name=value` per field — `workspace +report-metadata ` for the workspace target. An empty field is pushed +as `--clear-token`, so a row never keeps a value that stopped holding. `--seq` +is wall-clock nanoseconds: herdr keeps each source's highest seq per target as +a freshness mark that outlives the reporting process, and a counter restarting +at 1 would leave every push after a meter restart silently dropped as stale. + +### The sidebar config that renders the tokens + +herdr renders a custom token only where its sidebar row config names it, and +the default rows name none. Without this, pushes land in the daemon (visible +through `herdr workspace get `) and draw nothing. For the space row: + +```toml +[ui.sidebar.spaces] +rows = [ + ["state_icon", "workspace"], + ["branch", "git_status"], + ["$flow_phase", "$flow_turn", "$flow_elapsed"], +] +``` + +The first two rows restate herdr's defaults, because setting `rows` replaces +them. A row renders only when one of its tokens resolves, so workspaces without +a meter are unchanged. For a pane target the same `$flow_*` names go under +`[ui.sidebar.agents]`. Applies on `herdr server reload-config`. Timing and failure: @@ -155,6 +197,13 @@ pane on both flags: flow-indicator watch --pane w2E:p1 --herdr-pane w2E:p1 ``` +**Agent herdr has not promoted.** No agents-panel row exists to draw on, so +target the workspace's space row instead: + +```bash +flow-indicator watch --adapter qwen --current --herdr-workspace auto +``` + **Several agents, one repository.** Name each pane; `--current` cannot separate them. @@ -168,8 +217,9 @@ flow-indicator watch --pane w2J:p1 A `watch` on a discovered session writes one operational record per run, readable through `flow-indicator instances --json`. Two of its fields come from herdr: -- `pane_id` is the `--herdr-pane` argument, not the `--pane` target: it names - the pane this meter is drawing into. +- `pane_id` is the resolved `--herdr-pane` target, not the `--pane` target: it + names the pane this meter is drawing into, and is empty when the reporter + targets a workspace or is off. - `workspace_id` is read from the `HERDR_WORKSPACE_ID` environment variable, which herdr sets in the panes it owns.