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 @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **A session can find out its own name.** `lich whoami` prints the session the
command runs in, with its label and lich id, which `lich sessions` leaves out.
Every command and tool that takes a session (`send`, `close`, `rename`,
`control`, `ask`, `file`) now also accepts that id in place of the label.
- **Each notification under the bell has an X that dismisses it.** Clearing
one used to mean opening its session; a finished turn you dismiss also counts
as read.
Expand Down
23 changes: 22 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ guess at the one it resembles, and exit 1 — a typo does not open a window.
Arguments the app itself takes still do: bare `lich`, `lich --shell <path>`, and
`lich --` with the Chromium flags behind it.

`--json` on `sessions`, `send`, `wait`, `open`, `close`, `rename`, `control`, `ask`, `worktrees`, `folders`, `file`,
`--json` on `sessions`, `whoami`, `send`, `wait`, `open`, `close`, `rename`, `control`, `ask`, `worktrees`, `folders`, `file`,
`rename-folder`, `color-folder`, `cost` and `version`
replaces the prose with one JSON line: the peer array, the result object and the session
object exactly as this document describes them. An empty roster is `[]`, never
Expand Down Expand Up @@ -174,6 +174,22 @@ hook (`docs/hooks/session-state.md`), and it is the same thing its card shows:
Claude Code, 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.

### `lich whoami [--json]`

Prints the session the command runs in: the one entry `sessions` never lists.
Same columns as `sessions`, plus the lich id:

```
session project provider name state id
auth-fix lich claude lich-a1b2 busy 0b9c…
```

`--json` prints one object in the `sessions` shape,
`{"label","name","project","kind","state","id"}`. 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.

### `lich send [--project <name>] [--timeout <seconds>] [--private] [--json] <session> <prompt>`

Types `<prompt>` at `<session>`'s prompt, submits it, and waits.
Expand All @@ -193,6 +209,11 @@ collected is typed as usual.
and lich reads it back off the session's own record, so both sides go on
naming it the same thing; the name lich derived at spawn stops addressing it,
exactly as it stops addressing it in `/list-agents`.
- `<session>` can also be the session's lich id (`id` in `sessions --json`,
`LICH_SESSION_ID` in its process). It is matched before either name. The same
holds for every command that takes a `<session>`: `close`, `rename`,
`control`, `ask` and `file` resolve it the same way, and a caller naming
itself by id gets exactly what naming itself by label gets.
- Labels are unique within a project, not across them: a label two live sessions
answer to is an error naming both, and `--project` is what narrows it.
Guessing which session a prompt lands in is the one mistake this must not
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ var askTools = []mcpTool{
"answer. Claude Code sessions only, and never your own.",
Schema: schema(map[string]any{
"session": property("string",
"The session to ask, by the label on its card or the name it answers to."),
"The session to ask, by the label on its card, the name it answers to or its lich id."),
"question": property("string", "The question."),
"project": property("string",
"Project to narrow to, by name or by directory path, when the same label "+
Expand Down
25 changes: 25 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ func dispatch(args []string, c *client) int {
switch args[0] {
case "sessions":
return c.run(c.sessions, args[1:])
case "whoami":
return c.run(c.whoami, args[1:])
case "send":
return c.run(c.send, args[1:])
case "wait":
Expand Down Expand Up @@ -297,6 +299,29 @@ func (c *client) sessions(args []string) error {
return nil
}

func (c *client) whoami(args []string) error {
flags := newFlagSet("whoami")
asJSON := flags.Bool("json", false, "print the result as JSON")
if err := c.parse(flags, args); err != nil {
return err
}
if flags.NArg() != 0 {
return usageError("whoami")
}

var self relay.Peer
if err := c.call(context.Background(), "relay.Self", []any{c.sessionID()}, shortCall, &self); err != nil {
return err
}
if *asJSON {
return c.emit(self)
}
fmt.Fprintln(c.stdout, "session\tproject\tprovider\tname\tstate\tid")
fmt.Fprintf(c.stdout, "%s\t%s\t%s\t%s\t%s\t%s\n",
self.Label, self.Project, self.Kind, self.Name, sessionState(self.State), self.ID)
return nil
}

// sessionState words a peer's state for the table. A session that has reported
// nothing gets a dash rather than a blank cell: the column is never empty by
// accident, and "not reported" is a different thing from a session sitting
Expand Down
30 changes: 30 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,36 @@ func TestSessionsJSONCarriesTheLichID(t *testing.T) {
}
}

// whoami is the one entry sessions leaves out, asked for by the caller's own id.
func TestWhoamiPrintsTheCallersOwnSession(t *testing.T) {
f := newFakeLich(t, `{"label":"Session 3","name":"lich-s1","project":"lich","kind":"claude","state":"busy","id":"s1"}`)

code, stdout, stderr := run(t, f, "whoami")
if code != 0 {
t.Fatalf("exit = %d, stderr = %q", code, stderr)
}
call := f.only(t)
if call.method != "relay.Self" || len(call.args) != 1 || call.args[0] != "s1" {
t.Errorf("called %s %v, want relay.Self with the caller's own id", call.method, call.args)
}
if !strings.Contains(stdout, "Session 3\tlich\tclaude\tlich-s1\tbusy\ts1") {
t.Errorf("output = %q", stdout)
}
}

func TestWhoamiJSONIsThePeerObject(t *testing.T) {
body := `{"label":"Session 3","name":"lich-s1","project":"lich","kind":"claude","state":"","id":"s1"}`
f := newFakeLich(t, body)

code, stdout, stderr := run(t, f, "whoami", "--json")
if code != 0 {
t.Fatalf("exit = %d, stderr = %q", code, stderr)
}
if strings.TrimSpace(stdout) != body {
t.Errorf("output = %s, want %s", stdout, body)
}
}

func TestSessionsSaysWhenThereAreNone(t *testing.T) {
f := newFakeLich(t, `[]`)

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ var controlTools = []mcpTool{
"every new session.",
Schema: schema(map[string]any{
"session": property("string",
"The session to drive, by the label on its card or the name it answers to."),
"The session to drive, by the label on its card, the name it answers to or its lich id."),
"action": property("string", "One of prompt, abort, model, effort, command."),
"value": property("string",
"prompt: the text (required). model: the model name, or omit to go back to the "+
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/folders.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ var folderTools = []mcpTool{
"Folder to file the session under, exactly as list_folders names it, or a new "+
"name. An empty string takes the session out of its folder."),
"session": property("string",
"Session to file, by the label on its card or the name it answers to. Omit to "+
"Session to file, by the label on its card, the name it answers to or its lich id. Omit to "+
"file the session you are running in."),
"project": property("string",
"Project to narrow to, by name or by directory path, when the same label "+
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ var commands = []command{
args: "[--json]",
about: "List the live sessions that can be reached.",
},
{
name: "whoami",
args: "[--json]",
about: "Print the session this command runs in, which `sessions` never lists:\n" +
"its label, project, provider, roster name, state and lich id.",
},
{
name: "send",
args: "[--project <name>] [--timeout <seconds>] [--private] [--json] <session> <prompt>",
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ var mcpTools = append([]mcpTool{
"own messaging tool instead of this one.",
Schema: schema(map[string]any{
"session": property("string",
"Label of the target session, exactly as list_sessions returns it. Not a peer-roster name."),
"Label of the target session, exactly as list_sessions returns it, or its id from there."),
"prompt": property("string",
"What to ask that session's agent to do. Capped at 8 KB: it is typed at a "+
"terminal prompt, so name the paths, branches and commits to read rather "+
Expand Down Expand Up @@ -573,7 +573,7 @@ var mcpTools = append([]mcpTool{
"you are running in.",
Schema: schema(map[string]any{
"session": property("string",
"The session to close, by the label on its card or the name it answers to."),
"The session to close, by the label on its card, the name it answers to or its lich id."),
"project": property("string",
"Project to narrow to, by name or by directory path, when the same label "+
"exists in more than one."),
Expand Down Expand Up @@ -606,7 +606,7 @@ var mcpTools = append([]mcpTool{
Schema: schema(map[string]any{
"label": property("string", "The new name for the card."),
"session": property("string",
"Session to rename, by the label on its card or the name it answers to. "+
"Session to rename, by the label on its card, the name it answers to or its lich id. "+
"Omit to rename the session you are running in."),
"project": property("string",
"Project to narrow to, by name or by directory path, when the same label "+
Expand Down
55 changes: 55 additions & 0 deletions internal/relay/relay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2966,3 +2966,58 @@ func TestAnInterruptedTargetIsNotBusy(t *testing.T) {
t.Fatal("a session whose turn was interrupted still read as working")
}
}

// The lich id is a third address for a session, beside its two names: it is the
// one a plugin already holds (LICH_SESSION_ID, lich whoami), and the one no
// rename moves.
func TestSendReachesASessionByItsID(t *testing.T) {
term := newFakeTerminal("s1", "s5")
svc := newRelay(workspace(), term, nil)

go func() { _ = svc.Reply("", waitForTicket(svc), "ok") }()
got, err := svc.Send(context.Background(), "s1", "s5", "", "hello", 30)
if err != nil {
t.Fatalf("Send by id: %v", err)
}
if got.Target != "api" || term.written("s5") == "" {
t.Fatalf("Send = %+v, want it typed at s5 under its label", got)
}
}

// A caller naming itself by id is refused exactly as by label: the roster never
// holds the caller, whichever name it is looked up by.
func TestSendRefusesTheCallersOwnIDLikeItsLabel(t *testing.T) {
svc := newRelay(workspace(), newFakeTerminal("s1", "s2"), nil)

_, byLabel := svc.Send(context.Background(), "s1", "sender", "", "hello", 30)
_, byID := svc.Send(context.Background(), "s1", "s1", "", "hello", 30)
if byLabel == nil || byID == nil {
t.Fatalf("byLabel = %v, byID = %v, want both refused", byLabel, byID)
}
if !strings.HasPrefix(byID.Error(), "no live session named") {
t.Errorf("by id: %v, want the refusal the label gets (%v)", byID, byLabel)
}
}

func TestSelfIsTheCallersOwnEntry(t *testing.T) {
svc := newRelay(workspace(), newFakeTerminal("s1", "s2"), nil)

got, err := svc.Self("s1")
if err != nil {
t.Fatalf("Self: %v", err)
}
want := Peer{Label: "sender", Name: "lich-s1", Project: "lich", Kind: "claude", ID: "s1"}
if got != want {
t.Fatalf("Self = %+v, want %+v", got, want)
}
}

func TestSelfRefusesACallerWithoutASession(t *testing.T) {
svc := newRelay(workspace(), newFakeTerminal("s1"), nil)

for id, want := range map[string]string{"": "LICH_SESSION_ID is unset", "gone": `no session with id "gone"`} {
if _, err := svc.Self(id); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("Self(%q) err = %v, want one saying %q", id, err, want)
}
}
}
63 changes: 45 additions & 18 deletions internal/relay/roster.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package relay

import (
"errors"
"fmt"
"strconv"
"strings"
Expand Down Expand Up @@ -32,26 +33,48 @@ func (s *Service) roster(fromID string) ([]candidate, error) {
if sess.ID == fromID || !s.term.Live(sess.ID) {
continue
}
cwd := sess.Path
if cwd == "" {
cwd = p.Path
}
found = append(found, candidate{
ID: sess.ID,
Peer: Peer{
Label: sess.Label,
Name: RosterNameOf(s.term.AgentName(sess.ID), cwd, sess.ID),
Project: p.Name,
Kind: sess.Kind,
State: s.reportedState(sess.ID),
ID: sess.ID,
},
})
found = append(found, candidate{ID: sess.ID, Peer: s.peerOf(p, sess)})
}
}
return found, nil
}

// Self is the caller's own entry, in the shape Peers lists everybody else's: the
// one session the roster never shows it. It does not ask whether a process is
// running there, since the caller asking is that process.
func (s *Service) Self(id string) (Peer, error) {
if id == "" {
return Peer{}, errors.New("not running in a lich session (LICH_SESSION_ID is unset)")
}
projects, err := s.sessions.LoadState()
if err != nil {
return Peer{}, fmt.Errorf("read sessions: %w", err)
}
for _, p := range projects {
for _, sess := range p.Sessions {
if sess.ID == id {
return s.peerOf(p, sess), nil
}
}
}
return Peer{}, fmt.Errorf("lich has no session with id %q (LICH_SESSION_ID)", id)
}

func (s *Service) peerOf(p store.Project, sess store.Session) Peer {
cwd := sess.Path
if cwd == "" {
cwd = p.Path
}
return Peer{
Label: sess.Label,
Name: RosterNameOf(s.term.AgentName(sess.ID), cwd, sess.ID),
Project: p.Name,
Kind: sess.Kind,
State: s.reportedState(sess.ID),
ID: sess.ID,
}
}

// reportedState is what a session last told lich it was doing, empty when it
// has told lich nothing. Taken under the lock because reports arrive on the
// hook handler's own goroutines while a roster is being built.
Expand All @@ -75,11 +98,15 @@ func (s *Service) resolve(fromID, target, project string) (candidate, error) {
return candidate{}, err
}

// The label first, then the roster name. Both address this session, and an
// agent may have been handed either — a mention writes the roster name at
// The id first: it names one session and no name can be spelled like it.
// Then the label, then the roster name. Both names address this session, and
// an agent may have been handed either — a mention writes the roster name at
// the prompt, list_sessions prints both. The label wins a tie because it is
// the name the user chose and the one every message and error quotes.
matches := matching(found, target, project, func(c candidate) string { return c.Peer.Label })
matches := matching(found, target, project, func(c candidate) string { return c.ID })
if len(matches) == 0 {
matches = matching(found, target, project, func(c candidate) string { return c.Peer.Label })
}
if len(matches) == 0 {
matches = matching(found, target, project, func(c candidate) string { return c.Peer.Name })
}
Expand Down
11 changes: 8 additions & 3 deletions internal/spawn/close.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,11 @@ type located struct {
session store.Session
}

// findSession resolves a session by the label on its card or the name it answers
// to in the peer roster, exactly as the relay does — an agent holding either
// should not have to know which one it has. The label is tried first and wins a
// findSession resolves a session by its lich id, the label on its card or the
// name it answers to in the peer roster, exactly as the relay does — an agent
// holding any of them should not have to know which one it has. An id names one
// session and nothing else can be spelled like one, so it is matched before the
// names. The label is tried next and wins a
// tie against another session's roster name, because that is how `lich send`
// reads the same name (relay.resolve); one product cannot disagree with itself
// about which session a name addresses.
Expand Down Expand Up @@ -275,6 +277,9 @@ func findSession(
continue
}
for _, sess := range p.Sessions {
if sess.ID == target {
return located{project: p, session: sess}, nil
}
cwd := sess.Path
if cwd == "" {
cwd = p.Path
Expand Down
12 changes: 12 additions & 0 deletions internal/spawn/control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func TestControlRefusesBeforeReachingTheSession(t *testing.T) {
{"another CLI", "s1", "codex-run", "abort", "", "", "only a Claude Code session"},
{"another CLI, by the name the user knows it by", "s1", "pi-run", "abort", "", "", `"pi-run" runs oh-my-pi,`},
{"the caller itself", "s1", "Session 3", "abort", "", "", "cannot control itself"},
{"the caller itself, by its id", "s1", "s1", "abort", "", "", `"Session 3" is this session, and a session cannot control itself`},
{"a value on an abort", "s1", "auth-fix", "abort", "now", "", "takes nothing after it"},
{"args on a prompt", "s1", "auth-fix", "prompt", "go", "x", "only a command takes arguments"},
{"an empty prompt", "s1", "auth-fix", "prompt", " ", "", "needs its text"},
Expand All @@ -87,6 +88,17 @@ func TestControlRefusesBeforeReachingTheSession(t *testing.T) {
}
}

func TestControlReachesASessionByItsID(t *testing.T) {
svc, term := newControlService(t)
got, err := svc.Control(context.Background(), "s1", "s2", "", "abort", "", "")
if err != nil {
t.Fatalf("Control: %v", err)
}
if got.Label != "auth-fix" || len(term.ranOn) != 1 || term.ranOn[0] != "s2" {
t.Fatalf("Control = %+v on %v, want auth-fix on s2", got, term.ranOn)
}
}

func TestControlReportsHowFarTheCommandGot(t *testing.T) {
for _, state := range []string{ControlDelivered, ControlEnded} {
t.Run(state, func(t *testing.T) {
Expand Down
Loading