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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 7 additions & 0 deletions api/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions api/testdata/contract.golden
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,9 @@ Peer {
id string
path string
projectPath string
tool,omitempty string
detail,omitempty string
reason,omitempty string
}

ReplyOptions {
Expand Down
11 changes: 9 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {} }

Expand Down
4 changes: 4 additions & 0 deletions internal/relay/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 73 additions & 1 deletion internal/relay/relay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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{},
}
Expand All @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion internal/relay/roster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions internal/terminal/turnstates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Expand All @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions internal/terminal/turnstates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading