on main help sign in
feat(herdr): add herdr socket integration
b39a85eKieran Klukas
internal/app/app.go+29
@@ -27,6 +27,7 @@ import (
27 "github.com/charmbracelet/crush/internal/event"
27 "github.com/charmbracelet/crush/internal/event"
28 "github.com/charmbracelet/crush/internal/filetracker"
28 "github.com/charmbracelet/crush/internal/filetracker"
29 "github.com/charmbracelet/crush/internal/format"
29 "github.com/charmbracelet/crush/internal/format"
30+ "github.com/charmbracelet/crush/internal/herdr"
30 "github.com/charmbracelet/crush/internal/history"
31 "github.com/charmbracelet/crush/internal/history"
31 "github.com/charmbracelet/crush/internal/log"
32 "github.com/charmbracelet/crush/internal/log"
32 "github.com/charmbracelet/crush/internal/lsp"
33 "github.com/charmbracelet/crush/internal/lsp"
@@ -83,6 +84,10 @@ type App struct {
83 // drive their exit on a deterministic, payload-bearing event
84 // drive their exit on a deterministic, payload-bearing event
84 // instead of guessing from message finish parts.
85 // instead of guessing from message finish parts.
85 runCompletions *pubsub.Broker[notify.RunComplete]
86 runCompletions *pubsub.Broker[notify.RunComplete]
87+
88+ // herdrClient reports agent state to herdr when running inside
89+ // a herdr-managed pane. Nil when not in a herdr environment.
90+ herdrClient *herdr.Client
86}
91}
87
92
88// New initializes a new application instance. skillsMgr carries the
93// New initializes a new application instance. skillsMgr carries the
@@ -134,6 +139,15 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore, skillsMgr
134
139
135 go mcp.Initialize(ctx, app.Permissions, store)
140 go mcp.Initialize(ctx, app.Permissions, store)
136
141
142+ // Start herdr integration when running inside a herdr pane.
143+ app.herdrClient = herdr.Init()
144+ herdr.BridgeLocal(ctx, app.herdrClient, herdr.BridgeSources{
145+ PermRequests: app.Permissions,
146+ PermNotifications: app.Permissions,
147+ RunCompletions: app.runCompletions,
148+ Messages: app.Messages,
149+ })
150+
137 // Release the shared database connection on shutdown. The pool
151 // Release the shared database connection on shutdown. The pool
138 // closes the underlying *sql.DB when the last reference is released.
152 // closes the underlying *sql.DB when the last reference is released.
139 dataDir := cfg.Options.DataDirectory
153 dataDir := cfg.Options.DataDirectory
@@ -200,6 +214,15 @@ func (app *App) RunCompletions() *pubsub.Broker[notify.RunComplete] {
200 return app.runCompletions
214 return app.runCompletions
201}
215}
202
216
217+// ReportCurrentSession tells herdr which session the user is now
218+// viewing so it can persist a resumable reference for the pane. Safe
219+// to call when not running inside a herdr pane; the underlying client
220+// is nil-safe. Call this whenever the active session changes (load,
221+// new, or select).
222+func (app *App) ReportCurrentSession(sessionID string) {
223+ app.herdrClient.SetSessionID(sessionID)
224+}
225+
203// resolveSession resolves which session to use for a non-interactive run
226// resolveSession resolves which session to use for a non-interactive run
204// If continueSessionID is set, it looks up that session by ID
227// If continueSessionID is set, it looks up that session by ID
205// If useLast is set, it returns the most recently updated top-level session
228// If useLast is set, it returns the most recently updated top-level session
@@ -316,6 +339,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
316 // session.
339 // session.
317 app.Permissions.AutoApproveSession(sess.ID)
340 app.Permissions.AutoApproveSession(sess.ID)
318
341
342+ // Report session identity to herdr.
343+ app.ReportCurrentSession(sess.ID)
344+
319 type response struct {
345 type response struct {
320 result *fantasy.AgentResult
346 result *fantasy.AgentResult
321 err error
347 err error
@@ -677,6 +703,9 @@ func (app *App) Shutdown() {
677 shell.GetBackgroundShellManager().KillAll(shutdownCtx)
703 shell.GetBackgroundShellManager().KillAll(shutdownCtx)
678 })
704 })
679
705
706+ // Close herdr client to stop its background writer.
707+ app.herdrClient.Close()
708+
680 // Shutdown all LSP clients.
709 // Shutdown all LSP clients.
681 wg.Go(func() {
710 wg.Go(func() {
682 app.LSPManager.KillAll(shutdownCtx)
711 app.LSPManager.KillAll(shutdownCtx)
internal/cmd/run.go+11
@@ -16,6 +16,7 @@ import (
16 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/event"
17 "github.com/charmbracelet/crush/internal/event"
18 "github.com/charmbracelet/crush/internal/format"
18 "github.com/charmbracelet/crush/internal/format"
19+ "github.com/charmbracelet/crush/internal/herdr"
19 "github.com/charmbracelet/crush/internal/proto"
20 "github.com/charmbracelet/crush/internal/proto"
20 "github.com/charmbracelet/crush/internal/pubsub"
21 "github.com/charmbracelet/crush/internal/pubsub"
21 "github.com/charmbracelet/crush/internal/session"
22 "github.com/charmbracelet/crush/internal/session"
@@ -262,6 +263,11 @@ func runNonInteractive(
262 read: make(map[string]int),
263 read: make(map[string]int),
263 }
264 }
264
265
266+ // Start herdr integration when running inside a herdr pane.
267+ hc := herdr.Init()
268+ hc.SetSessionID(sess.ID)
269+ defer hc.Close()
270+
265 defer func() {
271 defer func() {
266 if progress && stderrTTY {
272 if progress && stderrTTY {
267 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
273 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
@@ -281,6 +287,11 @@ func runNonInteractive(
281 return nil
287 return nil
282 }
288 }
283
289
290+ // Forward events to herdr if running inside a herdr pane.
291+ if hev := herdr.Translate(ev); hev != nil {
292+ hc.HandleEvent(hev)
293+ }
294+
284 done, err := stream.handle(ev, stopSpinner)
295 done, err := stream.handle(ev, stopSpinner)
285 if err != nil {
296 if err != nil {
286 return err
297 return err
internal/herdr/client.gonew file+401
@@ -0,0 +1,401 @@
1+// Package herdr provides native integration with the herdr terminal
2+// multiplexer. When Crush runs inside a herdr-managed pane it reports
3+// agent state (idle, working, blocked) and session identity over
4+// herdr's Unix socket API so herdr can display accurate status without
5+// screen scraping.
6+//
7+// The client consumes a small, herdr-specific event vocabulary rather
8+// than accepting raw proto or domain types. Callers translate their
9+// events into herdr.Event before forwarding. This keeps the client
10+// decoupled from both the proto and internal domain layers.
11+package herdr
12+
13+import (
14+ "context"
15+ "encoding/json"
16+ "flag"
17+ "fmt"
18+ "io"
19+ "log/slog"
20+ "net"
21+ "os"
22+ "sync"
23+ "time"
24+)
25+
26+// State values matching herdr's PaneAgentState enum.
27+const (
28+ stateIdle = "idle"
29+ stateWorking = "working"
30+ stateBlocked = "blocked"
31+)
32+
33+// Event is the herdr-specific event vocabulary. Each type maps to a
34+// distinct state transition in the agent lifecycle. Callers translate
35+// from proto or domain types into these before calling HandleEvent.
36+type Event interface {
37+ herdrEvent()
38+}
39+
40+// AssistantMessage indicates the agent produced output. Transitions
41+// to working if not already active.
42+type AssistantMessage struct {
43+ SessionID string
44+}
45+
46+func (AssistantMessage) herdrEvent() {}
47+
48+// RunComplete indicates the agent finished a turn. Transitions to idle.
49+type RunComplete struct {
50+ SessionID string
51+}
52+
53+func (RunComplete) herdrEvent() {}
54+
55+// PermissionRequested indicates the agent is waiting for user approval.
56+// Transitions to blocked.
57+type PermissionRequested struct{}
58+
59+func (PermissionRequested) herdrEvent() {}
60+
61+// PermissionResolved indicates a permission decision was made.
62+// Transitions back to working if a run is active, idle otherwise.
63+type PermissionResolved struct{}
64+
65+func (PermissionResolved) herdrEvent() {}
66+
67+// Summarizing indicates the agent is compacting context. Transitions
68+// to working if not already active.
69+type Summarizing struct{}
70+
71+func (Summarizing) herdrEvent() {}
72+
73+// sender abstracts the transport layer for reporting state to herdr.
74+// Production uses a Unix socket; tests use a recorder.
75+type sender interface {
76+ send(req reportRequest) error
77+ close()
78+}
79+
80+// Client reports Crush agent state to a running herdr instance.
81+type Client struct {
82+ socketPath string
83+ paneID string
84+
85+ mu sync.Mutex
86+ sessionID string
87+ state string
88+ runActive bool
89+ seq uint64
90+
91+ snd sender
92+}
93+
94+// defaultClient is the process-wide herdr client. Initialized once
95+// via Init(). All integration sites share this single instance so
96+// only one Unix socket connection exists per process.
97+var (
98+ defaultClient *Client
99+ initOnce sync.Once
100+)
101+
102+// Init returns the process-wide herdr Client, creating it on first
103+// call from environment variables. Returns nil when Crush is not
104+// running inside a herdr pane. Safe to call from any goroutine.
105+func Init() *Client {
106+ initOnce.Do(func() {
107+ defaultClient = newFromEnv()
108+ })
109+ return defaultClient
110+}
111+
112+func newFromEnv() *Client {
113+ if os.Getenv("HERDR_ENV") != "1" {
114+ return nil
115+ }
116+ // A test binary inherits the launching shell's HERDR_* env, so
117+ // without this it would attach to the developer's live pane and
118+ // release its agent on teardown. Skip herdr entirely under test.
119+ if flag.Lookup("test.v") != nil {
120+ slog.Debug("Herdr integration disabled: running under go test")
121+ return nil
122+ }
123+ socketPath := os.Getenv("HERDR_SOCKET_PATH")
124+ paneID := os.Getenv("HERDR_PANE_ID")
125+ if socketPath == "" || paneID == "" {
126+ slog.Debug("Herdr integration disabled: incomplete environment",
127+ "has_socket", socketPath != "",
128+ "has_pane_id", paneID != "",
129+ )
130+ return nil
131+ }
132+ c := &Client{
133+ socketPath: socketPath,
134+ paneID: paneID,
135+ state: stateIdle,
136+ seq: uint64(time.Now().UnixNano()),
137+ snd: newUnixSender(socketPath),
138+ }
139+ c.registerInitial()
140+ return c
141+}
142+
143+// registerInitial sends an initial idle-state report to herdr so the
144+// pane knows about the agent immediately, not just after the first
145+// event. Called once during client creation. Bypasses the dedup
146+// check since the initial state must always be reported regardless
147+// of redundancy.
148+//
149+// herdr remembers the highest seq it has seen per source for the
150+// lifetime of a pane and silently drops any report with a seq that
151+// is not strictly greater. Because crush seeds seq from the wall
152+// clock at startup (see newFromEnv), a restarted crush in the same
153+// pane always reports above the previous run's high-water mark, so
154+// the first report is accepted instead of being rejected as stale.
155+func (c *Client) registerInitial() {
156+ if c == nil {
157+ return
158+ }
159+ c.mu.Lock()
160+ defer c.mu.Unlock()
161+ c.snd.send(c.newRequestLocked("pane.report_agent", "init", stateIdle))
162+}
163+
164+// Close releases the agent's authority on the pane and shuts down
165+// the background writer. Safe to call on a nil client.
166+func (c *Client) Close() {
167+ if c == nil {
168+ return
169+ }
170+ c.releaseAgent()
171+ c.snd.close()
172+}
173+
174+// releaseAgent sends a pane.release_agent request to herdr so the
175+// pane is freed for a new agent to claim authority. This is the
176+// clean-shutdown protocol per herdr's socket API. Sends directly
177+// on the socket to ensure delivery even if the write loop is busy.
178+func (c *Client) releaseAgent() {
179+ c.mu.Lock()
180+ defer c.mu.Unlock()
181+ req := c.newRequestLocked("pane.release_agent", "release", "")
182+ if err := dialSend(c.socketPath, req); err != nil {
183+ slog.Debug("Herdr release_agent failed", "error", err)
184+ }
185+}
186+
187+// HandleEvent processes a single herdr event and reports state changes.
188+// Safe to call from any goroutine.
189+func (c *Client) HandleEvent(ev Event) {
190+ if c == nil {
191+ return
192+ }
193+ switch e := ev.(type) {
194+ case AssistantMessage:
195+ c.onAssistantMessage(e.SessionID)
196+ case RunComplete:
197+ c.onRunComplete(e.SessionID)
198+ case PermissionRequested:
199+ c.onPermissionRequest()
200+ case PermissionResolved:
201+ c.onPermissionResolved()
202+ case Summarizing:
203+ c.onSummarizing()
204+ }
205+}
206+
207+// SetSessionID sets the session ID for reporting. Call this when the
208+// session is created or resolved, before events start flowing.
209+func (c *Client) SetSessionID(id string) {
210+ if c == nil {
211+ return
212+ }
213+ c.mu.Lock()
214+ defer c.mu.Unlock()
215+ c.sessionID = id
216+}
217+
218+func (c *Client) onAssistantMessage(sessionID string) {
219+ c.mu.Lock()
220+ defer c.mu.Unlock()
221+ if sessionID != "" {
222+ c.sessionID = sessionID
223+ }
224+ if !c.runActive {
225+ c.runActive = true
226+ c.reportLocked(stateWorking)
227+ }
228+}
229+
230+func (c *Client) onRunComplete(sessionID string) {
231+ c.mu.Lock()
232+ defer c.mu.Unlock()
233+ c.runActive = false
234+ if sessionID != "" {
235+ c.sessionID = sessionID
236+ }
237+ c.reportLocked(stateIdle)
238+}
239+
240+func (c *Client) onPermissionRequest() {
241+ c.mu.Lock()
242+ defer c.mu.Unlock()
243+ // A permission request implies a run is active, even if no
244+ // assistant message has arrived yet (e.g. tool calls that fire
245+ // before any text output).
246+ if !c.runActive {
247+ c.runActive = true
248+ }
249+ c.reportLocked(stateBlocked)
250+}
251+
252+func (c *Client) onPermissionResolved() {
253+ c.mu.Lock()
254+ defer c.mu.Unlock()
255+ if c.runActive {
256+ c.reportLocked(stateWorking)
257+ } else {
258+ c.reportLocked(stateIdle)
259+ }
260+}
261+
262+func (c *Client) onSummarizing() {
263+ c.mu.Lock()
264+ defer c.mu.Unlock()
265+ if !c.runActive {
266+ c.runActive = true
267+ }
268+ c.reportLocked(stateWorking)
269+}
270+
271+// newRequestLocked builds a seq-stamped JSON-RPC request to herdr.
272+// Must be called with c.mu held. Every request increments c.seq so
273+// herdr accepts it as strictly newer than the last (see
274+// registerInitial for why monotonic seq matters). State is empty for
275+// requests that carry no agent state, such as pane.release_agent.
276+func (c *Client) newRequestLocked(method, idPrefix, state string) reportRequest {
277+ c.seq++
278+ return reportRequest{
279+ ID: fmt.Sprintf("crush:%s:%d", idPrefix, time.Now().UnixNano()),
280+ Method: method,
281+ Params: reportParams{
282+ PaneID: c.paneID,
283+ Source: "crush",
284+ Agent: "crush",
285+ State: state,
286+ Seq: c.seq,
287+ AgentSessionID: c.sessionID,
288+ },
289+ }
290+}
291+
292+// reportLocked sends a pane.report_agent request to herdr. Must be
293+// called with c.mu held. Skips redundant reports when the state has
294+// not changed.
295+func (c *Client) reportLocked(state string) {
296+ if state == c.state {
297+ return
298+ }
299+ c.state = state
300+ c.snd.send(c.newRequestLocked("pane.report_agent", "report", state))
301+}
302+
303+// reportRequest is the JSON-RPC envelope sent to herdr.
304+type reportRequest struct {
305+ ID string `json:"id"`
306+ Method string `json:"method"`
307+ Params reportParams `json:"params"`
308+}
309+
310+// reportParams carries the agent state payload.
311+type reportParams struct {
312+ PaneID string `json:"pane_id"`
313+ Source string `json:"source"`
314+ Agent string `json:"agent"`
315+ State string `json:"state"`
316+ Seq uint64 `json:"seq"`
317+ AgentSessionID string `json:"agent_session_id"`
318+}
319+
320+// unixSender sends JSON-RPC requests over a Unix domain socket using
321+// a single background writer goroutine and a buffered channel. This
322+// serializes writes and avoids spawning unbounded goroutines under
323+// high event throughput. Each report opens a short-lived connection.
324+type unixSender struct {
325+ socketPath string
326+ ch chan reportRequest
327+ cancel context.CancelFunc
328+}
329+
330+func newUnixSender(socketPath string) *unixSender {
331+ ctx, cancel := context.WithCancel(context.Background())
332+ s := &unixSender{
333+ socketPath: socketPath,
334+ ch: make(chan reportRequest, 16),
335+ cancel: cancel,
336+ }
337+ go s.writeLoop(ctx)
338+ return s
339+}
340+
341+func (s *unixSender) send(req reportRequest) error {
342+ select {
343+ case s.ch <- req:
344+ default:
345+ // Drop if the buffer is full. State reports are
346+ // best-effort; blocking the agent is worse than
347+ // missing a transition.
348+ }
349+ return nil
350+}
351+
352+func (s *unixSender) close() {
353+ s.cancel()
354+}
355+
356+func (s *unixSender) writeLoop(ctx context.Context) {
357+ for {
358+ select {
359+ case req, ok := <-s.ch:
360+ if !ok {
361+ return
362+ }
363+ if err := dialSend(s.socketPath, req); err != nil {
364+ slog.Debug("Herdr report failed", "error", err)
365+ }
366+ case <-ctx.Done():
367+ return
368+ }
369+ }
370+}
371+
372+// dialSend opens a short-lived Unix socket connection to herdr,
373+// sends a single JSON-RPC request, and drains the response.
374+func dialSend(socketPath string, req reportRequest) error {
375+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
376+ defer cancel()
377+
378+ dialer := net.Dialer{}
379+ conn, err := dialer.DialContext(ctx, "unix", socketPath)
380+ if err != nil {
381+ return err
382+ }
383+ defer conn.Close()
384+
385+ _ = conn.SetDeadline(time.Now().Add(500 * time.Millisecond))
386+
387+ data, err := json.Marshal(req)
388+ if err != nil {
389+ return err
390+ }
391+ data = append(data, '\n')
392+
393+ _, err = conn.Write(data)
394+ if err != nil {
395+ return err
396+ }
397+
398+ // Drain the response to complete the request cycle.
399+ _, _ = io.Copy(io.Discard, conn)
400+ return nil
401+}
internal/herdr/client_test.gonew file+158
@@ -0,0 +1,158 @@
1+package herdr
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+// recordingSender captures state transitions without connecting to a
10+// real Unix socket.
11+type recordingSender struct {
12+ states []string
13+}
14+
15+func (r *recordingSender) send(req reportRequest) error {
16+ r.states = append(r.states, req.Params.State)
17+ return nil
18+}
19+
20+func (r *recordingSender) close() {}
21+
22+// newTestClient creates a Client that records state transitions
23+// without connecting to a real Unix socket.
24+func newTestClient() *Client {
25+ rec := &recordingSender{states: make([]string, 0, 16)}
26+ return &Client{
27+ state: stateIdle,
28+ snd: rec,
29+ }
30+}
31+
32+// reportedStates returns the states recorded by the test sender.
33+func reportedStates(c *Client) []string {
34+ return c.snd.(*recordingSender).states
35+}
36+
37+func TestBasicLifecycle(t *testing.T) {
38+ t.Parallel()
39+ c := newTestClient()
40+
41+ // Assistant message starts working.
42+ c.HandleEvent(AssistantMessage{SessionID: "sess-1"})
43+ assert.Equal(t, []string{stateWorking}, reportedStates(c))
44+
45+ // Run complete returns to idle.
46+ c.HandleEvent(RunComplete{SessionID: "sess-1"})
47+ assert.Equal(t, []string{stateWorking, stateIdle}, reportedStates(c))
48+}
49+
50+func TestPermissionBlockAndUnblock(t *testing.T) {
51+ t.Parallel()
52+ c := newTestClient()
53+
54+ // Start working.
55+ c.HandleEvent(AssistantMessage{SessionID: "sess-1"})
56+
57+ // Permission request blocks.
58+ c.HandleEvent(PermissionRequested{})
59+ assert.Equal(t, []string{stateWorking, stateBlocked}, reportedStates(c))
60+
61+ // Permission granted returns to working (run still active).
62+ c.HandleEvent(PermissionResolved{})
63+ assert.Equal(t, []string{stateWorking, stateBlocked, stateWorking}, reportedStates(c))
64+
65+ // Run complete returns to idle.
66+ c.HandleEvent(RunComplete{SessionID: "sess-1"})
67+ assert.Equal(t, []string{stateWorking, stateBlocked, stateWorking, stateIdle}, reportedStates(c))
68+}
69+
70+func TestPermissionBeforeAssistantMessage(t *testing.T) {
71+ t.Parallel()
72+ c := newTestClient()
73+
74+ // Permission request arrives before any assistant message.
75+ // This can happen when tool calls fire before text output.
76+ c.HandleEvent(PermissionRequested{})
77+ assert.Equal(t, []string{stateBlocked}, reportedStates(c))
78+
79+ // Permission resolved should return to working, not idle,
80+ // because the permission request implied a run was active.
81+ c.HandleEvent(PermissionResolved{})
82+ assert.Equal(t, []string{stateBlocked, stateWorking}, reportedStates(c))
83+}
84+
85+func TestSessionIDPropagation(t *testing.T) {
86+ t.Parallel()
87+ c := newTestClient()
88+
89+ // SetSessionID before events.
90+ c.SetSessionID("early-session")
91+ assert.Equal(t, "early-session", c.sessionID)
92+
93+ // RunComplete also updates session ID.
94+ c.HandleEvent(RunComplete{SessionID: "final-session"})
95+ assert.Equal(t, "final-session", c.sessionID)
96+}
97+
98+func TestDedupSkipsRedundantState(t *testing.T) {
99+ t.Parallel()
100+ c := newTestClient()
101+
102+ // Two assistant messages in a row should only report working once.
103+ c.HandleEvent(AssistantMessage{SessionID: "s1"})
104+ c.HandleEvent(AssistantMessage{SessionID: "s1"})
105+ assert.Equal(t, []string{stateWorking}, reportedStates(c))
106+}
107+
108+func TestSummarizingTriggersWorking(t *testing.T) {
109+ t.Parallel()
110+ c := newTestClient()
111+
112+ // Summarizing event should trigger working.
113+ c.HandleEvent(Summarizing{})
114+ assert.Equal(t, []string{stateWorking}, reportedStates(c))
115+
116+ // Second summarizing should not trigger another state change.
117+ c.HandleEvent(Summarizing{})
118+ assert.Equal(t, []string{stateWorking}, reportedStates(c))
119+}
120+
121+func TestNilClientSafe(t *testing.T) {
122+ t.Parallel()
123+ var c *Client
124+ // These should not panic on a nil receiver.
125+ c.SetSessionID("s1")
126+ c.HandleEvent(AssistantMessage{SessionID: "s1"})
127+ c.HandleEvent(RunComplete{SessionID: "s1"})
128+ c.HandleEvent(PermissionRequested{})
129+ c.HandleEvent(PermissionResolved{})
130+ c.HandleEvent(Summarizing{})
131+}
132+
133+func TestRegisterInitial(t *testing.T) {
134+ t.Parallel()
135+ rec := &recordingSender{states: make([]string, 0, 16)}
136+ c := &Client{
137+ state: stateIdle,
138+ seq: 100,
139+ snd: rec,
140+ }
141+ c.registerInitial()
142+ assert.Equal(t, []string{stateIdle}, rec.states)
143+ // seq must strictly increase so herdr accepts the report.
144+ assert.Equal(t, uint64(101), c.seq)
145+}
146+
147+// TestInitDisabledUnderTest guards the critical safety property that
148+// herdr never attaches to a real pane from a test binary. Test
149+// processes inherit the developer's HERDR_* environment, so a missing
150+// guard would release the live pane's agent on teardown. Because this
151+// test itself runs under `go test`, Init must return nil even with a
152+// complete, valid-looking environment.
153+func TestInitDisabledUnderTest(t *testing.T) {
154+ t.Setenv("HERDR_ENV", "1")
155+ t.Setenv("HERDR_SOCKET_PATH", "/tmp/does-not-matter.sock")
156+ t.Setenv("HERDR_PANE_ID", "test:pane")
157+ assert.Nil(t, newFromEnv())
158+}
internal/herdr/translate.gonew file+150
@@ -0,0 +1,150 @@
1+package herdr
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/charmbracelet/crush/internal/agent/notify"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/permission"
10+ "github.com/charmbracelet/crush/internal/proto"
11+ "github.com/charmbracelet/crush/internal/pubsub"
12+)
13+
14+// Translate converts a pub/sub event (domain or proto) into a herdr
15+// Event. Returns nil for event types herdr doesn't care about. This
16+// is the single translation point for all integration modes.
17+func Translate(ev any) Event {
18+ switch e := ev.(type) {
19+ // Domain types (TUI / local headless).
20+ case pubsub.Event[message.Message]:
21+ return translateMessage(
22+ e.Payload.Role == message.Assistant,
23+ e.Payload.SessionID,
24+ e.Payload.IsSummaryMessage,
25+ )
26+ case pubsub.Event[notify.RunComplete]:
27+ return RunComplete{SessionID: e.Payload.SessionID}
28+ case pubsub.Event[permission.PermissionRequest]:
29+ return PermissionRequested{}
30+ case pubsub.Event[permission.PermissionNotification]:
31+ return PermissionResolved{}
32+
33+ // Proto types (client/server mode).
34+ case pubsub.Event[proto.Message]:
35+ return translateMessage(
36+ e.Payload.Role == proto.Assistant,
37+ e.Payload.SessionID,
38+ false,
39+ )
40+ case pubsub.Event[proto.RunComplete]:
41+ return RunComplete{SessionID: e.Payload.SessionID}
42+ case pubsub.Event[proto.PermissionRequest]:
43+ return PermissionRequested{}
44+ case pubsub.Event[proto.PermissionNotification]:
45+ return PermissionResolved{}
46+ case pubsub.Event[proto.AgentEvent]:
47+ if e.Payload.Type == proto.AgentEventTypeSummarize && !e.Payload.Done {
48+ return Summarizing{}
49+ }
50+ return nil
51+
52+ default:
53+ return nil
54+ }
55+}
56+
57+// translateMessage is the shared message-mapping logic for both domain
58+// and proto message types.
59+func translateMessage(isAssistant bool, sessionID string, isSummary bool) Event {
60+ if !isAssistant {
61+ return nil
62+ }
63+ if isSummary {
64+ return Summarizing{}
65+ }
66+ return AssistantMessage{SessionID: sessionID}
67+}
68+
69+// permNotificationSubscriber is the subset of the permission service
70+// needed by BridgeLocal to subscribe to permission notifications.
71+type permNotificationSubscriber interface {
72+ SubscribeNotifications(context.Context) <-chan pubsub.Event[permission.PermissionNotification]
73+}
74+
75+// BridgeSources groups the pub/sub sources that BridgeLocal subscribes
76+// to. Adding a new event type means adding a field here rather than
77+// growing the function signature.
78+type BridgeSources struct {
79+ PermRequests pubsub.Subscriber[permission.PermissionRequest]
80+ PermNotifications permNotificationSubscriber
81+ RunCompletions pubsub.Subscriber[notify.RunComplete]
82+ Messages pubsub.Subscriber[message.Message]
83+}
84+
85+// BridgeLocal subscribes to local pub/sub brokers and forwards
86+// translated events to the client. Used in TUI and local headless
87+// modes where the agent runs in-process. Cancelling ctx stops the
88+// bridge goroutines.
89+//
90+// The spawned goroutines are best-effort and may briefly outlive
91+// Client.Close(). This is safe: HandleEvent is nil-safe, and the
92+// unixSender drops messages on a full buffer rather than blocking.
93+//
94+// Each goroutine uses a resilient subscription loop that re-subscribes
95+// if the channel closes unexpectedly, ensuring the bridge survives
96+// transient pub/sub broker resets.
97+func BridgeLocal(ctx context.Context, c *Client, src BridgeSources) {
98+ if c == nil {
99+ return
100+ }
101+ go forward(ctx, c, func(subCtx context.Context) <-chan pubsub.Event[permission.PermissionRequest] {
102+ return src.PermRequests.Subscribe(subCtx)
103+ })
104+ go forward(ctx, c, func(subCtx context.Context) <-chan pubsub.Event[permission.PermissionNotification] {
105+ return src.PermNotifications.SubscribeNotifications(subCtx)
106+ })
107+ go forward(ctx, c, func(subCtx context.Context) <-chan pubsub.Event[notify.RunComplete] {
108+ return src.RunCompletions.Subscribe(subCtx)
109+ })
110+ go forward(ctx, c, func(subCtx context.Context) <-chan pubsub.Event[message.Message] {
111+ return src.Messages.Subscribe(subCtx)
112+ })
113+}
114+
115+// forward reads from a pub/sub channel and forwards translated
116+// events to the herdr client. If the channel closes (e.g., due to
117+// broker reset), it re-subscribes after a brief delay. Runs until ctx
118+// is cancelled.
119+func forward[T any](ctx context.Context, c *Client, subscribe func(context.Context) <-chan pubsub.Event[T]) {
120+ for {
121+ select {
122+ case <-ctx.Done():
123+ return
124+ default:
125+ }
126+
127+ subCtx, cancel := context.WithCancel(ctx)
128+ ch := subscribe(subCtx)
129+
130+ inner:
131+ for {
132+ select {
133+ case <-ctx.Done():
134+ cancel()
135+ return
136+ case ev, ok := <-ch:
137+ if !ok {
138+ // Channel closed — broker may have reset.
139+ // Cancel the sub-context and re-subscribe.
140+ cancel()
141+ time.Sleep(100 * time.Millisecond)
142+ break inner
143+ }
144+ if hev := Translate(ev); hev != nil {
145+ c.HandleEvent(hev)
146+ }
147+ }
148+ }
149+ }
150+}
internal/herdr/translate_test.gonew file+145
@@ -0,0 +1,145 @@
1+package herdr
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/agent/notify"
7+ "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/permission"
9+ "github.com/charmbracelet/crush/internal/proto"
10+ "github.com/charmbracelet/crush/internal/pubsub"
11+ "github.com/stretchr/testify/assert"
12+)
13+
14+// Domain type translation.
15+
16+func TestTranslateDomainAssistantMessage(t *testing.T) {
17+ t.Parallel()
18+ ev := pubsub.Event[message.Message]{
19+ Payload: message.Message{Role: message.Assistant, SessionID: "s1"},
20+ }
21+ assert.Equal(t, AssistantMessage{SessionID: "s1"}, Translate(ev))
22+}
23+
24+func TestTranslateDomainSummaryMessage(t *testing.T) {
25+ t.Parallel()
26+ ev := pubsub.Event[message.Message]{
27+ Payload: message.Message{
28+ Role: message.Assistant,
29+ SessionID: "s1",
30+ IsSummaryMessage: true,
31+ },
32+ }
33+ assert.Equal(t, Summarizing{}, Translate(ev))
34+}
35+
36+func TestTranslateDomainNonAssistantIgnored(t *testing.T) {
37+ t.Parallel()
38+ ev := pubsub.Event[message.Message]{
39+ Payload: message.Message{Role: message.System},
40+ }
41+ assert.Nil(t, Translate(ev))
42+}
43+
44+func TestTranslateDomainRunComplete(t *testing.T) {
45+ t.Parallel()
46+ ev := pubsub.Event[notify.RunComplete]{
47+ Payload: notify.RunComplete{SessionID: "s1"},
48+ }
49+ assert.Equal(t, RunComplete{SessionID: "s1"}, Translate(ev))
50+}
51+
52+func TestTranslateDomainPermissionRequest(t *testing.T) {
53+ t.Parallel()
54+ ev := pubsub.Event[permission.PermissionRequest]{
55+ Payload: permission.PermissionRequest{ToolName: "bash"},
56+ }
57+ assert.Equal(t, PermissionRequested{}, Translate(ev))
58+}
59+
60+func TestTranslateDomainPermissionNotification(t *testing.T) {
61+ t.Parallel()
62+ ev := pubsub.Event[permission.PermissionNotification]{
63+ Payload: permission.PermissionNotification{Granted: true},
64+ }
65+ assert.Equal(t, PermissionResolved{}, Translate(ev))
66+}
67+
68+// Proto type translation.
69+
70+func TestTranslateProtoAssistantMessage(t *testing.T) {
71+ t.Parallel()
72+ ev := pubsub.Event[proto.Message]{
73+ Payload: proto.Message{Role: proto.Assistant, SessionID: "s1"},
74+ }
75+ assert.Equal(t, AssistantMessage{SessionID: "s1"}, Translate(ev))
76+}
77+
78+func TestTranslateProtoNonAssistantIgnored(t *testing.T) {
79+ t.Parallel()
80+ ev := pubsub.Event[proto.Message]{
81+ Payload: proto.Message{Role: proto.User, SessionID: "s1"},
82+ }
83+ assert.Nil(t, Translate(ev))
84+}
85+
86+func TestTranslateProtoRunComplete(t *testing.T) {
87+ t.Parallel()
88+ ev := pubsub.Event[proto.RunComplete]{
89+ Payload: proto.RunComplete{SessionID: "s1"},
90+ }
91+ assert.Equal(t, RunComplete{SessionID: "s1"}, Translate(ev))
92+}
93+
94+func TestTranslateProtoPermissionRequest(t *testing.T) {
95+ t.Parallel()
96+ ev := pubsub.Event[proto.PermissionRequest]{
97+ Payload: proto.PermissionRequest{ToolName: "bash"},
98+ }
99+ assert.Equal(t, PermissionRequested{}, Translate(ev))
100+}
101+
102+func TestTranslateProtoPermissionNotification(t *testing.T) {
103+ t.Parallel()
104+ ev := pubsub.Event[proto.PermissionNotification]{
105+ Payload: proto.PermissionNotification{Granted: true},
106+ }
107+ assert.Equal(t, PermissionResolved{}, Translate(ev))
108+}
109+
110+func TestTranslateProtoSummarizing(t *testing.T) {
111+ t.Parallel()
112+ ev := pubsub.Event[proto.AgentEvent]{
113+ Payload: proto.AgentEvent{
114+ Type: proto.AgentEventTypeSummarize,
115+ Done: false,
116+ },
117+ }
118+ assert.Equal(t, Summarizing{}, Translate(ev))
119+}
120+
121+func TestTranslateProtoSummarizeDoneIgnored(t *testing.T) {
122+ t.Parallel()
123+ ev := pubsub.Event[proto.AgentEvent]{
124+ Payload: proto.AgentEvent{
125+ Type: proto.AgentEventTypeSummarize,
126+ Done: true,
127+ },
128+ }
129+ assert.Nil(t, Translate(ev))
130+}
131+
132+func TestTranslateProtoSessionIgnored(t *testing.T) {
133+ t.Parallel()
134+ ev := pubsub.Event[proto.Session]{
135+ Payload: proto.Session{ID: "s1"},
136+ }
137+ assert.Nil(t, Translate(ev))
138+}
139+
140+// Unknown types.
141+
142+func TestTranslateUnknownReturnsNil(t *testing.T) {
143+ t.Parallel()
144+ assert.Nil(t, Translate("not an event"))
145+}
internal/shell/run.go+30
@@ -255,6 +255,36 @@ func withNonInteractiveEnv(env []string) []string {
255 return append(result, nonInteractiveEnvVars...)
255 return append(result, nonInteractiveEnvVars...)
256}
256}
257
257
258+// herdrEnvVars are the environment variables herdr injects into panes
259+// so agents can report state over its Unix socket API. Subprocesses
260+// must not inherit these: a child process that calls herdr.Init()
261+// would attach to the parent's pane and, on exit, release its agent
262+// authority — making the status vanish. Stripping them here closes
263+// that gap for every command the bash tool runs.
264+var herdrEnvVars = []string{
265+ "HERDR_ENV",
266+ "HERDR_SOCKET_PATH",
267+ "HERDR_PANE_ID",
268+}
269+
270+// withoutHerdrEnv returns env with all HERDR_* variables removed.
271+// The returned slice is a new allocation safe to use concurrently
272+// with the input.
273+func withoutHerdrEnv(env []string) []string {
274+ strip := make(map[string]bool, len(herdrEnvVars))
275+ for _, k := range herdrEnvVars {
276+ strip[k] = true
277+ }
278+ result := make([]string, 0, len(env))
279+ for _, e := range env {
280+ if key, _, ok := strings.Cut(e, "="); ok && strip[key] {
281+ continue
282+ }
283+ result = append(result, e)
284+ }
285+ return result
286+}
287+
258// standardHandlers returns the exec-handler middleware chain used by both
288// standardHandlers returns the exec-handler middleware chain used by both
259// [Run] and [Shell]. Order matters:
289// [Run] and [Shell]. Order matters:
260// 1. builtins first (so Crush's in-process jq wins over any PATH binary);
290// 1. builtins first (so Crush's in-process jq wins over any PATH binary);
internal/shell/run_test.go+43
@@ -309,6 +309,49 @@ func TestWithNonInteractiveEnv_SliceIndependence(t *testing.T) {
309 }
309 }
310}
310}
311
311
312+func TestWithoutHerdrEnv_StripsAllVars(t *testing.T) {
313+ t.Parallel()
314+ env := []string{
315+ "HERDR_ENV=1",
316+ "HERDR_SOCKET_PATH=/tmp/herdr.sock",
317+ "HERDR_PANE_ID=wA:p1",
318+ "PATH=/usr/bin",
319+ "HOME=/home/user",
320+ }
321+ result := withoutHerdrEnv(env)
322+ for _, e := range result {
323+ if strings.HasPrefix(e, "HERDR_") {
324+ t.Errorf("herdr var not stripped: %s", e)
325+ }
326+ }
327+ if !slices.Contains(result, "PATH=/usr/bin") {
328+ t.Error("non-herdr var PATH was incorrectly removed")
329+ }
330+ if !slices.Contains(result, "HOME=/home/user") {
331+ t.Error("non-herdr var HOME was incorrectly removed")
332+ }
333+}
334+
335+func TestWithoutHerdrEnv_EmptyInput(t *testing.T) {
336+ t.Parallel()
337+ result := withoutHerdrEnv(nil)
338+ if len(result) != 0 {
339+ t.Errorf("expected empty result for nil input, got %v", result)
340+ }
341+}
342+
343+func TestWithoutHerdrEnv_SliceIndependence(t *testing.T) {
344+ t.Parallel()
345+ env := []string{"HERDR_ENV=1", "FOO=bar"}
346+ result := withoutHerdrEnv(env)
347+ env[1] = "FOO=baz"
348+ for _, e := range result {
349+ if e == "FOO=baz" {
350+ t.Error("result shares backing array with input")
351+ }
352+ }
353+}
354+
312func TestRun_DiscardsNilWriters(t *testing.T) {
355func TestRun_DiscardsNilWriters(t *testing.T) {
313 // No panic when Stdout/Stderr are nil.
356 // No panic when Stdout/Stderr are nil.
314 err := Run(t.Context(), RunOptions{
357 err := Run(t.Context(), RunOptions{
internal/shell/shell.go+5
@@ -94,6 +94,11 @@ func NewShell(opts *Options) *Shell {
94 env = os.Environ()
94 env = os.Environ()
95 }
95 }
96
96
97+ // Strip herdr pane-ownership vars so subprocesses (including test
98+ // binaries and nested crush instances) can't attach to or release
99+ // the parent pane's agent authority.
100+ env = withoutHerdrEnv(env)
101+
97 // Allow tools to detect execution by Crush.
102 // Allow tools to detect execution by Crush.
98 env = append(env, CrushEnvMarkers()...)
103 env = append(env, CrushEnvMarkers()...)
99
104
internal/workspace/app_workspace.go+5−3
@@ -71,10 +71,12 @@ func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string
71 return w.app.Sessions.ParseAgentToolSessionID(sessionID)
71 return w.app.Sessions.ParseAgentToolSessionID(sessionID)
72}
72}
73
73
74+// SetCurrentSession reports the active session to herdr so the pane
75+// can persist a resumable reference. Multi-client presence tracking
74−// SetCurrentSession is a no-op in single-client local mode. The
76+// is irrelevant in single-client local mode, but herdr still needs
75−// presence concept only matters when multiple clients can share a
77+// to know which session is live to support agent resume.
76−// workspace via the HTTP server.
77func (w *AppWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error {
78func (w *AppWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error {
79+ w.app.ReportCurrentSession(sessionID)
78 return nil
80 return nil
79}
81}
80
82
internal/workspace/client_workspace.go+16−3
@@ -14,6 +14,7 @@ import (
14 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
14 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
15 "github.com/charmbracelet/crush/internal/client"
15 "github.com/charmbracelet/crush/internal/client"
16 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/config"
17+ "github.com/charmbracelet/crush/internal/herdr"
17 "github.com/charmbracelet/crush/internal/history"
18 "github.com/charmbracelet/crush/internal/history"
18 "github.com/charmbracelet/crush/internal/log"
19 "github.com/charmbracelet/crush/internal/log"
19 "github.com/charmbracelet/crush/internal/lsp"
20 "github.com/charmbracelet/crush/internal/lsp"
@@ -37,6 +38,10 @@ type ClientWorkspace struct {
37 mu sync.RWMutex
38 mu sync.RWMutex
38 ws proto.Workspace
39 ws proto.Workspace
39 skills *skills.Manager
40 skills *skills.Manager
41+
42+ // herdrClient reports agent state to herdr when running inside
43+ // a herdr-managed pane. Nil when not in a herdr environment.
44+ herdrClient *herdr.Client
40}
45}
41
46
42// NewClientWorkspace creates a new ClientWorkspace that proxies all
47// NewClientWorkspace creates a new ClientWorkspace that proxies all
@@ -54,9 +59,10 @@ func NewClientWorkspace(c *client.Client, ws proto.Workspace) *ClientWorkspace {
54 states := protoToSkillStates(ws.Skills)
59 states := protoToSkillStates(ws.Skills)
55 mgr := skills.NewManager(nil, nil, states, skills.WithGlobalMirror())
60 mgr := skills.NewManager(nil, nil, states, skills.WithGlobalMirror())
56 return &ClientWorkspace{
61 return &ClientWorkspace{
57− client: c,
62+ client: c,
58− ws: ws,
63+ ws: ws,
59− skills: mgr,
64+ skills: mgr,
65+ herdrClient: herdr.Init(),
60 }
66 }
61}
67}
62
68
@@ -147,6 +153,7 @@ func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, str
147// are propagated to the caller; the TUI logs and ignores them since
153// are propagated to the caller; the TUI logs and ignores them since
148// the presence record is a hint, not correctness-critical state.
154// the presence record is a hint, not correctness-critical state.
149func (w *ClientWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error {
155func (w *ClientWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error {
156+ w.herdrClient.SetSessionID(sessionID)
150 return w.client.SetCurrentSession(ctx, w.workspaceID(), sessionID)
157 return w.client.SetCurrentSession(ctx, w.workspaceID(), sessionID)
151}
158}
152
159
@@ -622,6 +629,11 @@ func (w *ClientWorkspace) Subscribe(program *tea.Program) {
622// are translated into domain types and forwarded to send.
629// are translated into domain types and forwarded to send.
623func (w *ClientWorkspace) consumeEvents(evc <-chan any, send func(tea.Msg)) {
630func (w *ClientWorkspace) consumeEvents(evc <-chan any, send func(tea.Msg)) {
624 for ev := range evc {
631 for ev := range evc {
632+ // Forward events to herdr if running inside a herdr pane.
633+ if hev := herdr.Translate(ev); hev != nil {
634+ w.herdrClient.HandleEvent(hev)
635+ }
636+
625 if _, ok := ev.(pubsub.Event[proto.ConfigChanged]); ok {
637 if _, ok := ev.(pubsub.Event[proto.ConfigChanged]); ok {
626 w.refreshWorkspace()
638 w.refreshWorkspace()
627 continue
639 continue
@@ -634,6 +646,7 @@ func (w *ClientWorkspace) consumeEvents(evc <-chan any, send func(tea.Msg)) {
634}
646}
635
647
636func (w *ClientWorkspace) Shutdown() {
648func (w *ClientWorkspace) Shutdown() {
649+ w.herdrClient.Close()
637 _ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
650 _ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
638}
651}
639
652