From e333b20de409765f820f96512756120c65a3f669 Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 28 Oct 2026 19:10:07 -0300 Subject: [PATCH 1/2] Expose the card's tool, detail and reason in the session roster lich sessions --json, whoami --json and list_sessions read them from the terminal's status book, gated on the roster's own state. The book keeps a busy session's tool through the PostToolUse gap, as the card does. --- CHANGELOG.md | 4 ++ api/relay.go | 7 +++ api/testdata/contract.golden | 3 ++ docs/cli.md | 11 ++++- internal/cli/wire_test.go | 2 + internal/relay/relay.go | 4 ++ internal/relay/relay_test.go | 74 +++++++++++++++++++++++++++- internal/relay/roster.go | 14 +++++- internal/terminal/turnstates.go | 12 +++++ internal/terminal/turnstates_test.go | 21 ++++++++ 10 files changed, 148 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc26154bc..d253d13eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `lich sessions --json`, `lich whoami --json` and the MCP tool `list_sessions` carry the line a session's card shows under its label: `tool` and `detail` while it is busy, the tool it is running and what it acts on, and `reason` while it waits on you. A script or a desktop widget can show what each agent is doing without opening the window. + ## [0.81.0] - 2026-10-28 > [!IMPORTANT] diff --git a/api/relay.go b/api/relay.go index cf1850bba..111adaeef 100644 --- a/api/relay.go +++ b/api/relay.go @@ -124,6 +124,13 @@ type Peer struct { // it; the name of a project says nothing about where it is. Path string `json:"path"` ProjectPath string `json:"projectPath"` + // Tool and Detail are the tool a busy session is running and what it acts + // on, Reason what a waiting one is blocked on: the line its card draws + // under the label, in the provider's own words (SessionStatus). Empty in + // every other state, and for a busy turn that has named no tool yet. + Tool string `json:"tool,omitempty"` + Detail string `json:"detail,omitempty"` + Reason string `json:"reason,omitempty"` } // ReplyOptions is one Reply call, an object for the reason SendOptions is one. diff --git a/api/testdata/contract.golden b/api/testdata/contract.golden index 5c4b2d4cd..4e31eab27 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -953,6 +953,9 @@ Peer { id string path string projectPath string + tool,omitempty string + detail,omitempty string + reason,omitempty string } ReplyOptions { diff --git a/docs/cli.md b/docs/cli.md index 312bb9a81..b95507afc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -153,7 +153,7 @@ api revu crush revu-9f8e - ``` `--json` prints an array of -`{"label","name","project","kind","state","id","path","projectPath"}`, +`{"label","name","project","kind","state","id","path","projectPath","tool","detail","reason"}`, `[]` when there are none. `path` is the directory the session runs in (its worktree's, for a worktree session) and `projectPath` the root of its project: what an editor matches its open folder against to find the session beside it. @@ -179,6 +179,13 @@ hook (`docs/hooks/session-state.md`), and it is the same thing its card shows: Claude Code and never reports waiting, and a session that has not had a turn yet has said nothing either), so an empty state says nothing about whether that session is free. +`tool`, `detail` and `reason` are the line the card draws under the label, in +the provider's own words and absent when empty: while `busy`, the tool the turn +is running and what it acts on (`"tool":"Bash","detail":"go test ./..."`), kept +through the gap after a tool until the next one; while `waiting`, what it is +blocked on. A busy turn that has named no tool yet carries neither, and neither +outlives the state it came with. The table does not print them. + ### `lich whoami [--json]` Prints the session the command runs in: the one entry `sessions` never lists. @@ -190,7 +197,7 @@ auth-fix lich claude lich-a1b2 busy 0b9c… ``` `--json` prints one object in the `sessions` shape, -`{"label","name","project","kind","state","id","path","projectPath"}`. It is how a caller learns its +`{"label","name","project","kind","state","id","path","projectPath","tool","detail","reason"}`. It is how a caller learns its own label and id without guessing: lich-plugin's Claude Code mod reads it to tell that a `control` aimed at a label is aimed at its own session. Run outside a lich session (`LICH_SESSION_ID` unset) it is an error, exit 1. diff --git a/internal/cli/wire_test.go b/internal/cli/wire_test.go index 0cb0765a3..53aa26a58 100644 --- a/internal/cli/wire_test.go +++ b/internal/cli/wire_test.go @@ -66,6 +66,8 @@ func (*wiredTerminal) QuietFor(string) time.Duration { return time.Hour } // derives — which is the one the wiring under test addresses. func (*wiredTerminal) AgentName(string) string { return "" } +func (*wiredTerminal) Status(string) api.SessionStatus { return api.SessionStatus{} } + // Nobody is typing at these sessions, so the hold has nothing to keep back. func (*wiredTerminal) HoldInput(string) func() { return func() {} } diff --git a/internal/relay/relay.go b/internal/relay/relay.go index 9865972b5..6c385695a 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -192,6 +192,10 @@ type Terminal interface { // peer roster, read out of the provider's own record. Empty when there is // none, which is what leaves the roster on the derived name. AgentName(id string) string + // Status is the last session-status report the window was sent for that + // session, zero when there is none: where the roster reads the tool and + // the reason the card shows. + Status(id string) api.SessionStatus // HoldInput keeps what the person at that session types out of the // submission this delivery is making, and hands it back at the prompt // afterwards. The returned release is called once the Enter is through diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index e561c77a4..fb3aa797b 100644 --- a/internal/relay/relay_test.go +++ b/internal/relay/relay_test.go @@ -65,6 +65,9 @@ type fakeTerminal struct { // transcript the terminal service reads. Empty for a session nobody renamed, // which is what puts the roster back on the derived name. names map[string]string + // statuses is the last session-status report of each session, standing in + // for the terminal's book the card is drawn from. + statuses map[string]api.SessionStatus // holding is whether a hold is open on that session right now, and inHold // collects the writes that landed inside one — which is how a test asks // whether the whole paste-to-Enter window was covered. @@ -84,7 +87,7 @@ func newFakeTerminal(live ...string) *fakeTerminal { live: map[string]bool{}, writes: map[string][]string{}, starting: map[string]bool{}, typing: map[string]bool{}, noisy: map[string]int{}, drained: map[string]int{}, - names: map[string]string{}, + names: map[string]string{}, statuses: map[string]api.SessionStatus{}, holding: map[string]bool{}, inHold: map[string][]string{}, mods: map[string]*fakeMod{}, answering: map[string]bool{}, } @@ -101,6 +104,21 @@ func (f *fakeTerminal) AgentName(id string) string { return f.names[id] } +// Status answers with whatever reported() recorded for that session. +func (f *fakeTerminal) Status(id string) api.SessionStatus { + f.mu.Lock() + defer f.mu.Unlock() + return f.statuses[id] +} + +// reported records st as the session's last status report, as the terminal's +// book does for the window. +func (f *fakeTerminal) reported(st api.SessionStatus) { + f.mu.Lock() + defer f.mu.Unlock() + f.statuses[st.ID] = st +} + // renamed makes a session answer to name the way a /rename inside it would. func (f *fakeTerminal) renamed(id, name string) { f.mu.Lock() @@ -469,6 +487,60 @@ func TestPeersCarryTheStateEachSessionReported(t *testing.T) { } } +// The roster carries the line the card draws under a session's label: the tool +// a busy session runs and what a waiting one is blocked on. It leaves with the +// state it was reported in. +func TestPeersCarryTheToolOfABusySession(t *testing.T) { + t.Parallel() + term := newFakeTerminal("s1", "s2") + svc := newRelay(workspace(), term, nil) + peerS2 := func() Peer { + t.Helper() + peers, err := svc.Peers(t.Context(), PeersOptions{From: "s1"}) + if err != nil || len(peers) != 1 { + t.Fatalf("Peers = %+v, %v; want s2 alone", peers, err) + } + return peers[0] + } + + svc.Observe("s2", stateBusy) + term.reported(api.SessionStatus{ID: "s2", State: stateBusy, Tool: "Bash", Detail: "go test ./..."}) + if p := peerS2(); p.Tool != "Bash" || p.Detail != "go test ./..." || p.Reason != "" { + t.Errorf("busy peer = %+v, want Bash: go test ./...", p) + } + + svc.Observe("s2", stateWaiting) + term.reported(api.SessionStatus{ID: "s2", State: stateWaiting, Reason: "permission"}) + if p := peerS2(); p.Tool != "" || p.Detail != "" || p.Reason != "permission" { + t.Errorf("waiting peer = %+v, want no tool, blocked on permission", p) + } + + svc.Observe("s2", stateDone) + term.reported(api.SessionStatus{ID: "s2", State: stateDone}) + if p := peerS2(); p.Tool != "" || p.Detail != "" || p.Reason != "" { + t.Errorf("done peer = %+v, want neither tool nor reason", p) + } +} + +// A tool is never shown beside a state it was not reported in: the two reads +// are taken apart, and a busy tool next to done would describe another turn. +func TestPeersDropAToolTheStateDisagreesWith(t *testing.T) { + t.Parallel() + term := newFakeTerminal("s1", "s2") + svc := newRelay(workspace(), term, nil) + svc.Observe("s2", stateBusy) + svc.Observe("s2", stateDone) + term.reported(api.SessionStatus{ID: "s2", State: stateBusy, Tool: "Bash"}) + + peers, err := svc.Peers(t.Context(), PeersOptions{From: "s1"}) + if err != nil || len(peers) != 1 { + t.Fatalf("Peers = %+v, %v; want s2 alone", peers, err) + } + if peers[0].State != stateDone || peers[0].Tool != "" { + t.Errorf("peer = %+v, want done with no tool", peers[0]) + } +} + // A compaction moves neither the turn nor the roster: mid-turn the session is // still busy and errands keep reading it so, and a /compact at the prompt // leaves a finished turn finished. The report that closes it says the rest. diff --git a/internal/relay/roster.go b/internal/relay/roster.go index a5e577647..bfdc426da 100644 --- a/internal/relay/roster.go +++ b/internal/relay/roster.go @@ -77,7 +77,7 @@ func (s *Service) peerOf(p store.Project, sess store.Session) Peer { if cwd == "" { cwd = p.Path } - return Peer{ + peer := Peer{ Label: sess.Label, Name: RosterNameOf(sess.Kind, s.term.AgentName(sess.ID), cwd, sess.ID), Project: p.Name, @@ -88,6 +88,18 @@ func (s *Service) peerOf(p store.Project, sess store.Session) Peer { Path: cwd, ProjectPath: p.Path, } + // Only while both reads agree on the state: they come from the same hook + // report but are taken apart, and a tool beside a state it was not + // reported in would describe a turn that is not the one listed. + st := s.term.Status(sess.ID) + switch { + case st.State != peer.State: + case st.State == stateBusy: + peer.Tool, peer.Detail = st.Tool, st.Detail + case st.State == stateWaiting: + peer.Reason = st.Reason + } + return peer } // reportedState is what a session last told lich it was doing, empty when it diff --git a/internal/terminal/turnstates.go b/internal/terminal/turnstates.go index b4d4407f0..513e6942e 100644 --- a/internal/terminal/turnstates.go +++ b/internal/terminal/turnstates.go @@ -43,6 +43,11 @@ func (b *statusBook) note(ev statusEvent) { since := now().UnixMilli() if prev, ok := b.byID[ev.ID]; ok && prev.event.State == ev.State { since = prev.since + // A busy naming no tool is the gap after one (PostToolUse), and the + // card keeps showing that tool through it (statusEntry.applyTool). + if ev.State == statusBusy && ev.Tool == "" { + ev.Tool, ev.Detail = prev.event.Tool, prev.event.Detail + } } b.byID[ev.ID] = bookedStatus{event: ev, since: since} } @@ -54,6 +59,13 @@ func (b *statusBook) of(id string) (bookedStatus, bool) { return st, ok } +// Status is the last session-status report the window was sent for a session, +// zero when there is none. +func (s *Service) Status(id string) api.SessionStatus { + st, _ := s.statuses.of(id) + return st.event +} + func (b *statusBook) forget(id string) { b.mu.Lock() delete(b.byID, id) diff --git a/internal/terminal/turnstates_test.go b/internal/terminal/turnstates_test.go index b26faf4c4..cab54fefb 100644 --- a/internal/terminal/turnstates_test.go +++ b/internal/terminal/turnstates_test.go @@ -54,6 +54,27 @@ func TestTurnStatesCarryTheLastReportOfEachLiveSession(t *testing.T) { } } +// The PostToolUse gap reports busy with no tool, and the card keeps drawing the +// last one through it; the snapshot keeps it too, so a window that attaches +// there, and the roster, read the same line. A new tool replaces it. +func TestTurnStatesKeepTheToolThroughTheGapAfterIt(t *testing.T) { + t.Parallel() + svc := serviceWithSessions(t, hookStore{}, "s1") + svc.onHookState(hookRequest{SessionID: "s1", State: statusBusy, Tool: "Bash", Detail: "go test ./..."}) + svc.onHookState(hookRequest{SessionID: "s1", State: statusBusy}) + if st := svc.Status("s1"); st.Tool != "Bash" || st.Detail != "go test ./..." { + t.Errorf("after the gap = %+v, want Bash: go test ./...", st) + } + svc.onHookState(hookRequest{SessionID: "s1", State: statusBusy, Tool: "Read", Detail: "go.mod"}) + if st := svc.Status("s1"); st.Tool != "Read" || st.Detail != "go.mod" { + t.Errorf("next tool = %+v, want Read: go.mod", st) + } + svc.onHookState(hookRequest{SessionID: "s1", State: statusDone}) + if st := svc.Status("s1"); st.Tool != "" || st.Detail != "" { + t.Errorf("done = %+v, want no tool", st) + } +} + // A report the window was never told (a `waiting` outside a turn) is not the // session's state either. func TestTurnStatesHoldBackWhatTheWindowWasNotTold(t *testing.T) { From f364e1ab8ba05d90d17896fea1c8ab3f4cfbf9c3 Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 28 Oct 2026 19:20:16 -0300 Subject: [PATCH 2/2] Name tool, detail and reason in list_sessions' description --- internal/cli/mcp.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index d0417b305..b5dc7506a 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -478,7 +478,8 @@ var mcpTools = append([]mcpTool{ "answered, so that session can take nothing on until someone does. Tell the user a " + "session is waiting instead of sending work into it. An empty state is not reported: " + "only some providers report at all, so it says nothing about whether that session is " + - "free.", + "free. A busy session may also carry `tool` and `detail`, the tool it is running and " + + "what it acts on, and a waiting one `reason`, what it is blocked on.", Schema: schema(map[string]any{}), ReadOnly: true, Run: func(ctx context.Context, c *client, _ mcpArgs) (string, error) {