From 3fe97dc2efc6f01b707bfb26d7c4ca897f9057fb Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 9 Oct 2026 15:20:25 -0300 Subject: [PATCH] Add lich whoami and accept a session id wherever a session is named A session had no way to learn its own label: sessions and list_sessions leave the caller out, and a lich id given as a target matched nothing. whoami prints the caller's own entry in the sessions shape, and both resolvers (relay and spawn) now match the id before the two names, so naming yourself by id gets the same answer as naming yourself by label. --- CHANGELOG.md | 7 ++++ docs/cli.md | 23 ++++++++++++- internal/cli/ask.go | 2 +- internal/cli/cli.go | 25 ++++++++++++++ internal/cli/cli_test.go | 30 ++++++++++++++++ internal/cli/control.go | 2 +- internal/cli/folders.go | 2 +- internal/cli/help.go | 6 ++++ internal/cli/mcp.go | 6 ++-- internal/relay/relay_test.go | 55 +++++++++++++++++++++++++++++ internal/relay/roster.go | 63 ++++++++++++++++++++++++---------- internal/spawn/close.go | 11 ++++-- internal/spawn/control_test.go | 12 +++++++ 13 files changed, 216 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 593fc8f2..ff7b2188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [0.63.0] - 2026-10-09 ### Added diff --git a/docs/cli.md b/docs/cli.md index d0b0520b..ef1f6bb8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 `, 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 @@ -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 ] [--timeout ] [--private] [--json] ` Types `` at ``'s prompt, submits it, and waits. @@ -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`. +- `` 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 ``: `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 diff --git a/internal/cli/ask.go b/internal/cli/ask.go index 430b792e..f2b3393e 100644 --- a/internal/cli/ask.go +++ b/internal/cli/ask.go @@ -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 "+ diff --git a/internal/cli/cli.go b/internal/cli/cli.go index fd3edc79..a03f0eb0 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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": @@ -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 diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 3caf49a4..01088d00 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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, `[]`) diff --git a/internal/cli/control.go b/internal/cli/control.go index cc7db585..d5bf5530 100644 --- a/internal/cli/control.go +++ b/internal/cli/control.go @@ -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 "+ diff --git a/internal/cli/folders.go b/internal/cli/folders.go index 6b801f83..43c00d17 100644 --- a/internal/cli/folders.go +++ b/internal/cli/folders.go @@ -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 "+ diff --git a/internal/cli/help.go b/internal/cli/help.go index 7b4588bd..45dd35ce 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -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 ] [--timeout ] [--private] [--json] ", diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 31ac04cc..88d26c99 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -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 "+ @@ -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."), @@ -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 "+ diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index d5d3451d..17f43112 100644 --- a/internal/relay/relay_test.go +++ b/internal/relay/relay_test.go @@ -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) + } + } +} diff --git a/internal/relay/roster.go b/internal/relay/roster.go index 1cf12b80..68f776d4 100644 --- a/internal/relay/roster.go +++ b/internal/relay/roster.go @@ -1,6 +1,7 @@ package relay import ( + "errors" "fmt" "strconv" "strings" @@ -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. @@ -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 }) } diff --git a/internal/spawn/close.go b/internal/spawn/close.go index 0479d49a..f02ab3ac 100644 --- a/internal/spawn/close.go +++ b/internal/spawn/close.go @@ -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. @@ -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 diff --git a/internal/spawn/control_test.go b/internal/spawn/control_test.go index 58ea96c9..eb1d54cd 100644 --- a/internal/spawn/control_test.go +++ b/internal/spawn/control_test.go @@ -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"}, @@ -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) {