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 @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The Pulls screen shows a changed image in a pull request's Files tab, before and after side by side, as the Review tab does, where it used to say only "Binary file". An edited Markdown file there has the preview button too, where only an added one had it. lich reads the file from your clone when it holds the commit and from GitHub otherwise.
- `lich mcp` answers MCP clients on protocol revision 2026-07-28, which name the version on each request instead of opening with `initialize`, and serves their `server/discover`. Clients that open with `initialize` are answered exactly as before.

### Changed

- A merge on the Pulls screen tells the session that opened the merged branch's session too, wherever that session runs, as it already told the sessions in the merged checkout. A session that hands work out learns its pull request merged without you saying so.

### Fixed

- A long Markdown file, such as lich's own CHANGELOG.md, previews in the Code and Review tabs without freezing the window: the preview draws only the part on screen, where it used to lay out the whole file on every frame.
Expand Down
3 changes: 2 additions & 1 deletion docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,8 @@ work when nobody knows it and that the call site never shows. The mechanism and
- **Only a merge lich makes is announced, and only to a card running at that moment** (`internal/relay`,
`AnnounceMerge`; called from the Pulls screen's merge): a pull request merged on github.com, with `gh` in a
terminal or by an agent leaves every session in its checkout believing it is still open, and so does one merged
while the card was parked or never opened. The notice also lands as a delivery like any other, so a cleanup
while the card was parked or never opened. The session that opened one of the checkout's sessions hears it
too, wherever it runs, but only that one: the session that opened the opener does not. The notice also lands as a delivery like any other, so a cleanup
clicked in the second before it starts a turn finds no session mid-turn, asks nothing, and removes the checkout
with the notice still on its way.
- **Only a Claude Code subagent becomes a lich card** (lich-plugin's `hooks/agent-cards.js`, its
Expand Down
57 changes: 44 additions & 13 deletions internal/relay/merged.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import (

"github.com/lichdotdev/lich/internal/prompt"
"github.com/lichdotdev/lich/internal/providers"
"github.com/lichdotdev/lich/internal/store"
)

// AnnounceMerge tells every agent running in checkout that the pull request on
// its branch was merged from lich's own screen. An agent keeps notes on the work
// it did there, and without this the last thing they say about it is that it is
// waiting on a merge, which a later session reads back as true long after the
// checkout is gone.
// checkout is gone. The session that opened one of those agents hears it too,
// wherever it runs: it handed the work out and keeps the same kind of notes on
// it.
//
// Only provider sessions hear it: a terminal card in the same checkout is a
// shell, where a typed notice would run as a command. A card with no process
Expand All @@ -31,25 +34,53 @@ func (s *Service) AnnounceMerge(checkout string, number int, title, branch, base
message := mergeNotice(lang, number, title, branch, base)
summary := fmt.Sprintf(prompt.For(lang).MergeSummary, number, base)
note := &Notification{Status: NotifyCompleted, Summary: summary}
for _, sess := range s.mergeAudience(projects, checkout) {
go func(id, label string) {
if err := s.deliver(id, message, note); err != nil {
slog.Warn("relay: merge notice not delivered", "session", label, "err", err)
}
}(sess.ID, sess.Label)
}
return nil
}

// mergeAudience is the live provider sessions running in checkout, and the live
// provider sessions that opened any session there, each once. An opener hears
// it even when the session it opened is parked: the work it handed out is
// done either way.
func (s *Service) mergeAudience(projects []store.Project, checkout string) []store.Session {
byID := map[string]store.Session{}
var inCheckout []store.Session
for _, p := range projects {
for _, sess := range p.Sessions {
if !s.runsAgentIn(sess.ID, sess.Kind, sessionCwd(sess.Path, p.Path), checkout) {
continue
byID[sess.ID] = sess
if sessionCwd(sess.Path, p.Path) == checkout {
inCheckout = append(inCheckout, sess)
}
go func(id, label string) {
if err := s.deliver(id, message, note); err != nil {
slog.Warn("relay: merge notice not delivered", "session", label, "err", err)
}
}(sess.ID, sess.Label)
}
}
return nil
told := map[string]bool{}
var audience []store.Session
add := func(sess store.Session) {
if !told[sess.ID] && s.runsAgent(sess.ID, sess.Kind) {
told[sess.ID] = true
audience = append(audience, sess)
}
}
for _, sess := range inCheckout {
add(sess)
}
for _, sess := range inCheckout {
if origin, ok := byID[sess.OriginSessionID]; ok {
add(origin)
}
}
return audience
}

// runsAgentIn is whether a session is a live provider session whose checkout is
// the one given.
func (s *Service) runsAgentIn(id, kind, cwd, checkout string) bool {
return cwd == checkout && providers.Known(kind) && s.term.Live(id)
// runsAgent is whether a session is a live provider session.
func (s *Service) runsAgent(id, kind string) bool {
return providers.Known(kind) && s.term.Live(id)
}

// sessionCwd is the directory a session runs in: its worktree, or the project's
Expand Down
36 changes: 36 additions & 0 deletions internal/relay/merged_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,42 @@ func TestAnnounceMergeReachesSessionsInTheProjectsOwnDirectory(t *testing.T) {
}
}

func TestAnnounceMergeTellsTheSessionThatOpenedOneThere(t *testing.T) {
t.Parallel()
sessions := fakeSessions{projects: []store.Project{
{ID: "p1", Name: "lich", Path: "/src/lich", Sessions: []store.Session{
{ID: "lead", Label: "main", Kind: "claude"},
{ID: "bystander", Label: "other", Kind: "codex", Path: "/wt/other"},
}},
{ID: "p2", Name: "site", Path: "/src/site", Sessions: []store.Session{
{ID: "worker", Label: "feat", Kind: "codex", Path: "/wt/feat", OriginSessionID: "lead"},
{ID: "parked", Label: "feat-2", Kind: "codex", Path: "/wt/feat", OriginSessionID: "lead"},
{ID: "nested", Label: "feat-3", Kind: "codex", Path: "/wt/feat", OriginSessionID: "worker"},
}},
}}
term := newFakeTerminal("lead", "bystander", "worker", "nested")
svc := newRelay(sessions, term, nil)

if err := svc.AnnounceMerge("/wt/feat", 42, "Add merge notice", "feat/merge", "main"); err != nil {
t.Fatalf("AnnounceMerge: %v", err)
}

for _, id := range []string{"lead", "worker", "nested"} {
if !awaitWritten(term, id, "#42") {
t.Fatalf("typed at %s = %q, want the merge notice", id, term.written(id))
}
}
if got := strings.Count(term.written("lead"), "#42"); got != 1 {
t.Fatalf("lead heard the notice %d times, want once for its two sessions there", got)
}
if got := strings.Count(term.written("worker"), "#42"); got != 1 {
t.Fatalf("worker heard the notice %d times, want once: it runs there and opened nested", got)
}
if got := term.written("bystander"); got != "" {
t.Fatalf("typed at bystander = %q, want nothing", got)
}
}

func TestAnnounceMergeReportsAStoreFailure(t *testing.T) {
t.Parallel()
svc := newRelay(fakeSessions{err: errors.New("disk gone")}, newFakeTerminal(), nil)
Expand Down
Loading