diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e4319d..3b1afe5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. 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) {