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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Continue in keeps the session's browser. Moving a session to another profile closed its browser, and its page and cookies were gone when the session came back up.
- Notes from the session browser, and the Pulls screen's Fix CI errors and Resolve conflicts, reach a Claude Code session you stopped with Esc. Claude Code turned the message down until someone typed at the session, and the tray said "its mod refused it"; lich now types the message at the prompt instead.

## [0.81.0] - 2026-10-28
Expand Down
3 changes: 2 additions & 1 deletion docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,8 @@ work when nobody knows it and that the call site never shows. The mechanism and
has no DevTools, shows the arrow whatever cursor the page asks for, loads what the page opens in a new tab or window in the page itself, and leaves the page's own dialogs out; Open in
a window moves the page to a real Chromium window for those, reopening it on the same profile with every
cookie, since chromedp cannot show a headless process in place. A half-filled form or sessionStorage is
lost on that move. Closing the card, or the session's process ending, closes the browser; closing that
lost on that move. Closing the card, or the session's process ending, closes the browser; Continue in,
which ends the process and spawns it again in the same card, keeps it. Closing that
window does not close the session. A headless browser no pane shows and no one used for five minutes is
closed; one a pane shows, or a window, never is. It loads only http, https and about:blank. Page info never
includes a form's value. Screenshots are files. There is no tool that runs JavaScript in the page. It is
Expand Down
2 changes: 1 addition & 1 deletion internal/terminal/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ func (s *Service) ContinueIn(ctx context.Context, opts api.ContinueInOptions) (a
}
live := s.live(opts.ID)
if live {
if _, err := s.Close(ctx, api.SessionIDOptions{ID: opts.ID}); err != nil {
if err := s.endProcess(opts.ID, ""); err != nil {
return api.None{}, fmt.Errorf("end the session before moving it: %w", err)
}
}
Expand Down
35 changes: 35 additions & 0 deletions internal/terminal/profile_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,41 @@ func TestContinueInRespawnsTheCardInTheTargetProfile(t *testing.T) {
}
}

// TestContinueInKeepsTheCard: the process ends but the card stays, so what
// lives as long as the card (its browser) is not told it closed, while the
// relay still hears that the process it knew is gone.
func TestContinueInKeepsTheCard(t *testing.T) {
personal, _ := profileMachine(t)
conversationAt(t, personal, "c1")
log := filepath.Join(t.TempDir(), "spawns")
svc := New(stubBins{bin: spawnLogBin(t, log), providerSession: "c1", movedProfiles: map[string]string{}}, nil, events.New())
t.Cleanup(func() { _, _ = svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}) })
var cardsClosed, processesClosed []string
svc.SetCardClosed(func(id string) { cardsClosed = append(cardsClosed, id) })
svc.SetSessionClosed(func(id, _ string) { processesClosed = append(processesClosed, id) })

if _, err := svc.Start(t.Context(), api.StartOptions{ID: "s1", ProjectID: "p1", Cwd: t.TempDir(), Kind: providers.Claude, Resume: "c1", Cols: 80, Rows: 24}); err != nil {
t.Fatal(err)
}
waitFor(t, func() bool { return len(spawnLines(t, log)) == 1 }, "the first spawn")
if _, err := svc.ContinueIn(t.Context(), api.ContinueInOptions{ID: "s1", Profile: "work"}); err != nil {
t.Fatal(err)
}
if len(cardsClosed) != 0 {
t.Errorf("Continue in closed the card %v", cardsClosed)
}
if fmt.Sprint(processesClosed) != "[s1]" {
t.Errorf("the relay heard %v closed, want [s1]", processesClosed)
}

if _, err := svc.Close(t.Context(), api.SessionIDOptions{ID: "s1"}); err != nil {
t.Fatal(err)
}
if fmt.Sprint(cardsClosed) != "[s1]" {
t.Errorf("closing the card told %v, want [s1]", cardsClosed)
}
}

// A card that has not had a prompt yet has an id but no transcript on disk:
// there is nothing to move, so it starts afresh in the target profile.
func TestContinueInStartsAfreshACardWithNoTranscriptYet(t *testing.T) {
Expand Down
27 changes: 26 additions & 1 deletion internal/terminal/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ type Service struct {
// errand there, ahead of the SessionEnd the dying CLI reports. Guarded by mu
// like onState.
onClosed func(id, closerID string)
// onCardClosed, when set, is told about every card Close takes down.
onCardClosed func(id string)
// onExited, when set, is told about every session whose process ended on
// its own and was reaped: the relay drops the state that process reported.
// Guarded by mu like onState.
Expand Down Expand Up @@ -599,7 +601,7 @@ func (s *Service) SetSessionState(fn func(id, state string)) {
s.onState = fn
}

// SetSessionClosed wires fn to every session Close takes down, called before
// SetSessionClosed wires fn to every session Close or ContinueIn takes down, called before
// its process is killed with the session that closed it (CloseBy), empty for a
// close the window made. Only one watcher, the relay (relay.SessionClosed).
func (s *Service) SetSessionClosed(fn func(id, closerID string)) {
Expand All @@ -608,6 +610,15 @@ func (s *Service) SetSessionClosed(fn func(id, closerID string)) {
s.onClosed = fn
}

// SetCardClosed wires fn to every card Close takes down, after its process is
// gone. Unlike SetSessionClosed, a process ContinueIn ends and spawns again
// under the same card is not one.
func (s *Service) SetCardClosed(fn func(id string)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onCardClosed = fn
}

// SetSessionExited wires fn to every session whose process ended on its own,
// called once lich has reaped it. Only one watcher, the relay
// (relay.SessionExited).
Expand Down Expand Up @@ -730,6 +741,20 @@ func (s *Service) Close(_ context.Context, opts api.SessionIDOptions) (api.None,

// CloseBy is Close on behalf of closerID, the session that asked for it.
func (s *Service) CloseBy(id, closerID string) error {
err := s.endProcess(id, closerID)
s.mu.Lock()
cardClosed := s.onCardClosed
s.mu.Unlock()
if cardClosed != nil {
cardClosed(id)
}
return err
}

// endProcess ends a session's process and everything lich counted for it,
// leaving the card to whoever comes next under the same id: ContinueIn's
// respawn, or nothing when CloseBy closes the card.
func (s *Service) endProcess(id, closerID string) error {
s.mu.Lock()
sess, ok := s.sessions[id]
if ok {
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,8 @@ func registerServices(db *store.Service, term *terminal.Service, svcs *services.
term.SetWorkerUnanswered(rl.WorkerUnanswered)
term.SetSessionClosed(func(id, closerID string) {
rl.SessionClosed(id, closerID)
closeSessionBrowser(brows, id)
})
term.SetCardClosed(func(id string) { closeSessionBrowser(brows, id) })
term.SetSessionExited(func(id string) {
rl.SessionExited(id)
closeSessionBrowser(brows, id)
Expand Down
Loading