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

- The Pulls screen opens on an index of the project's pull requests across the whole screen, and a pull request opens in its place instead of beside the list. A back button over its title returns to the index as you left it; beside it, the number opens a menu of the other pull requests, and previous and next walk the index's filter. On the Files tab only the file tree sits beside the diff, which gains the width the list took. The Overview puts the description beside a column with the reviewers, checks, mergeability and the checkout the branch sits in; the tab strip ends with the pull request's size. Open in Session and Merge stay in the header, and the hand-off, Approve, Refresh and Open on GitHub move to the ⋯ menu. On the index, j and k or the arrows move, Enter opens and / goes to the filter; on a pull request, Escape goes back and Alt with an arrow steps to the previous or next one.
- A session's browser is no longer closed without asking. When no pane shows it and nobody has used it for five minutes, a toast asks whether to close it: Close closes it, and Keep open, or swiping the toast away, keeps it for another five minutes. Unanswered, the toast stays up and the browser stays open, so a page left open over lunch is still there when you come back. Using the browser, or the agent using it, takes the toast down. Before, the browser was closed after five minutes away from its session, and its page, tabs and login were lost.

### Fixed

Expand Down
8 changes: 8 additions & 0 deletions api/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ var (
BrowserAttach = define[BrowserViewOptions, BrowserHandle]("browser.Attach")
BrowserDetach = define[SessionIDOptions, None]("browser.Detach")
BrowserResize = define[BrowserViewOptions, None]("browser.Resize")
// BrowserKeep answers BrowserState.Idle with keep: the browser stays and
// is asked about again after another idle window. browser.Close answers
// it with close.
BrowserKeep = define[SessionIDOptions, None]("browser.Keep")
// BrowserDefaultView is the window's size, which a page no pane shows
// lays out at.
BrowserDefaultView = define[BrowserDefaultViewOptions, None]("browser.DefaultView")
Expand Down Expand Up @@ -110,6 +114,10 @@ type BrowserState struct {
Dialog *BrowserDialog `json:"dialog,omitempty"`
// Tabs are the browser's tabs in the order the pane shows them.
Tabs []BrowserTab `json:"tabs,omitempty"`
// Idle is the reaper asking whether to close it: headless, no pane shows
// it, and nobody used it for the idle window. It stays until browser.Close,
// browser.Keep or a use answers it; the browser stays open meanwhile.
Idle bool `json:"idle,omitempty"`
}

// BrowserTab is one tab of a session's browser. ID is Chrome's own for it;
Expand Down
2 changes: 2 additions & 0 deletions api/testdata/contract.golden
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ browser.Detach(SessionIDOptions) None
browser.ElementAt(BrowserPointOptions) BrowserElement
browser.Forward(SessionIDOptions) None
browser.Info(BrowserInfoOptions) BrowserPageInfo
browser.Keep(SessionIDOptions) None
browser.Key(BrowserKeyOptions) None
browser.List(SessionIDOptions) []BrowserHandle
browser.Locate(BrowserLocateOptions) []BrowserRect
Expand Down Expand Up @@ -452,6 +453,7 @@ BrowserState {
title string
dialog,omitempty *BrowserDialog
tabs,omitempty []BrowserTab
idle,omitempty bool
}

BrowserScreenshotOptions {
Expand Down
6 changes: 5 additions & 1 deletion docs/browser-tab.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ clicked.
`browser-state` tells the window when a browser starts, closes, moves to a
window or loads another page; `browser.Running` is what a window reads when
it attaches. A browser no pane shows and no one used for five minutes is
closed; one a pane shows, or a window, is not. The tab takes no other
never closed on its own: its state turns `idle`, the window asks with a toast,
and Close (`browser.Close`) or Keep open (`browser.Keep`, five more minutes)
answers. Unanswered, it stays open and is not asked about again; any use
withdraws the question. One a pane shows, or a window, is never asked about.
The tab takes no other
session, and closing its page closes the browser.

## What the agent may load
Expand Down
4 changes: 3 additions & 1 deletion docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -1009,7 +1009,9 @@ work when nobody knows it and that the call site never shows. The mechanism and
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
only asked about, in a toast, and closes only on Close: an unanswered one holds its Chromium until the
person answers or closes the card, and with the window closed nobody is asked; 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
not a sandbox: the page runs as the user, in an ephemeral profile. Chromium needs its own sandbox, and on
Ubuntu 23.10 and later AppArmor refuses it the user namespace unless the browser ships an AppArmor profile,
Expand Down
39 changes: 27 additions & 12 deletions internal/browser/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,36 @@ var errNoBrowser = fmt.Errorf("no browser for this session — open one first")
func (s *Service) live(id string) (*session, error) {
s.mu.Lock()
defer s.unlock()
s.reapLocked()
sess := s.lookupLocked(id)
if sess == nil {
return nil, errNoBrowser
}
return sess, nil
}

// touch marks sess used now, which withdraws the reaper's question about it.
func (s *Service) touch(sess *session) {
s.mu.Lock()
defer s.mu.Unlock()
if s.byID[sess.id] == sess {
sess.lastUsed = s.now()
registered := s.byID[sess.id] == sess
withdrawn := registered && sess.asked
if registered {
sess.lastUsed, sess.asked = s.now(), false
}
s.mu.Unlock()
if withdrawn {
s.emitState(sess, true)
}
}

// Keep answers the reaper's question about owner's browser with keep: it
// stays, and is asked about again once it sits idle another idle window.
func (s *Service) Keep(owner string) error {
sess, err := s.live(owner)
if err != nil {
return err
}
s.touch(sess)
return nil
}

// drop takes out a browser a call failed on, if its Chrome is gone. One that
Expand All @@ -46,15 +62,14 @@ func (s *Service) drop(sess *session) {

func (s *Service) remember(sess *session, url, title string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.byID[sess.id] != sess {
return
}
if url != "" {
sess.url = url
if s.byID[sess.id] == sess {
if url != "" {
sess.url = url
}
sess.title = title
}
sess.title = title
sess.lastUsed = s.now()
s.mu.Unlock()
s.touch(sess)
}

// Info returns the page the agent is looking at. Never includes form values.
Expand Down
41 changes: 22 additions & 19 deletions internal/browser/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ import (
)

// idleAfter is how long a headless browser no pane shows sits unused before
// the reaper closes it. Five minutes matches the "walked away from the card"
// window without holding a Chromium for a session that finished hours ago. A
// headed window is never reaped: lich cannot see a person using it, so only
// the card closing ends it.
// the reaper asks the window whether to close it. Five minutes matches the
// "walked away from the card" window. The reaper never closes one on its own:
// a person away for an hour comes back to the page they left. A headed window
// is never asked about: lich cannot see a person using it, so only the card
// closing ends it.
const idleAfter = 5 * time.Minute

// reapEvery is how often the reaper looks. Shorter than idleAfter so a
// browser that went idle is gone within a fraction of the idle window, not a
// full extra one.
// browser that went idle is asked about within a fraction of the idle window,
// not a full extra one.
const reapEvery = 30 * time.Second

// pageWait bounds one CDP action. Under the 90 seconds an MCP tool call may
Expand Down Expand Up @@ -80,8 +81,10 @@ type session struct {
url, title string
headed bool
// byAgent: the agent started it, not the window. watching: a pane shows
// it, so the reaper leaves it alone like a headed window.
byAgent, watching bool
// it, so the reaper leaves it alone like a headed window. asked: the
// reaper asked the window whether to close it, and nobody has answered or
// used it since.
byAgent, watching, asked bool
// agentAt is where the agent last acted, where its next scroll turns.
agentAt *point
// downloads are the page's, until the agent has heard how each ended;
Expand Down Expand Up @@ -338,11 +341,10 @@ func (s *Service) ownerLock(owner string) *sync.Mutex {
return lock
}

// current is owner's live browser after a reap, or nil.
// current is owner's live browser, or nil.
func (s *Service) current(owner string) *session {
s.mu.Lock()
defer s.unlock()
s.reapLocked()
return s.liveByOwner(owner)
}

Expand Down Expand Up @@ -431,21 +433,22 @@ func (s *Service) startReaper() {
}()
}

// Reap closes headless browsers no pane shows that have sat idle past the
// idle window.
// Reap asks the window, once each, whether to close the headless browsers no
// pane shows that have sat idle past the idle window. Close or Keep answers.
func (s *Service) Reap() {
s.mu.Lock()
defer s.unlock()
s.reapLocked()
}

func (s *Service) reapLocked() {
cutoff := s.now().Add(-s.idle)
var asked []*session
for _, sess := range s.byID {
if !sess.headed && !sess.watching && sess.lastUsed.Before(cutoff) {
s.buryLocked(sess)
if !sess.headed && !sess.watching && !sess.asked && sess.lastUsed.Before(cutoff) {
sess.asked = true
asked = append(asked, sess)
}
}
s.mu.Unlock()
for _, sess := range asked {
s.emitState(sess, true)
}
}

func runPage(fn func(context.Context) error) error {
Expand Down
101 changes: 95 additions & 6 deletions internal/browser/browser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,27 +238,116 @@ func TestNavigateRejectsBlockedURL(t *testing.T) {
}
}

func TestIdleReaperClosesUnused(t *testing.T) {
func TestIdleReaperAsksInsteadOfClosing(t *testing.T) {
launch := &fakeLauncher{}
now := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC)
s := newService(launch.launch, func(string, string) (string, error) {
return t.TempDir(), nil
}, func() time.Time { return now }, time.Minute)
out := &emitted{}
s.emit = out.emit
t.Cleanup(func() { _ = s.Cleanup() })

if _, err := s.Open("s", "https://example.com"); err != nil {
t.Fatal(err)
}
s.Reap()
if launch.pages[0].closed {
t.Fatal("reaper closed a browser that was just used")
if idle := idleStates(out); idle != 0 {
t.Fatalf("reaper asked about a browser that was just used (%d asks)", idle)
}
now = now.Add(time.Minute + time.Second)
s.now = func() time.Time { return now }
s.Reap()
if !launch.pages[0].closed {
t.Fatal("reaper left an idle browser running")
if launch.pages[0].closed {
t.Fatal("reaper closed an idle browser nobody agreed to close")
}
if idle := idleStates(out); idle != 1 {
t.Fatalf("idle asks = %d, want 1", idle)
}
if running := s.Running(); len(running) != 1 || !running[0].Idle {
t.Fatalf("Running = %+v, want the question for a window catching up", running)
}
now = now.Add(time.Hour)
s.Reap()
if idle := idleStates(out); idle != 1 {
t.Fatalf("idle asks = %d after an hour unanswered, want still 1", idle)
}
}

func TestKeepAnswersAndAsksAgainAfterAnotherIdleWindow(t *testing.T) {
launch := &fakeLauncher{}
now := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC)
s := newService(launch.launch, func(string, string) (string, error) {
return t.TempDir(), nil
}, func() time.Time { return now }, time.Minute)
out := &emitted{}
s.emit = out.emit
t.Cleanup(func() { _ = s.Cleanup() })

if _, err := s.Open("s", "https://example.com"); err != nil {
t.Fatal(err)
}
now = now.Add(2 * time.Minute)
s.Reap()
if err := s.Keep("s"); err != nil {
t.Fatal(err)
}
states := out.states()
if last := states[len(states)-1]; last.Idle || !last.Open {
t.Fatalf("after keep the window got %+v, want the question withdrawn", last)
}
s.Reap()
if idle := idleStates(out); idle != 1 {
t.Fatalf("idle asks = %d right after keep, want 1", idle)
}
now = now.Add(2 * time.Minute)
s.Reap()
if idle := idleStates(out); idle != 2 {
t.Fatalf("idle asks = %d after another idle window, want 2", idle)
}
if launch.pages[0].closed {
t.Fatal("keep closed the browser")
}
}

func TestUseWithdrawsTheIdleQuestion(t *testing.T) {
launch := &fakeLauncher{}
now := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC)
s := newService(launch.launch, func(string, string) (string, error) {
return t.TempDir(), nil
}, func() time.Time { return now }, time.Minute)
out := &emitted{}
s.emit = out.emit
t.Cleanup(func() { _ = s.Cleanup() })

if _, err := s.Open("s", "https://example.com"); err != nil {
t.Fatal(err)
}
now = now.Add(2 * time.Minute)
s.Reap()
if _, err := s.Info("s"); err != nil {
t.Fatalf("the agent lost an idle browser it went back to: %v", err)
}
if running := s.Running(); len(running) != 1 || running[0].Idle {
t.Fatalf("Running = %+v, want the question withdrawn by the agent's use", running)
}
}

func TestKeepWithoutBrowserFails(t *testing.T) {
s := testService(t, &fakeLauncher{})
if err := s.Keep("s"); err != errNoBrowser {
t.Fatalf("Keep = %v, want errNoBrowser", err)
}
}

// idleStates counts the times the window was asked about an idle browser.
func idleStates(out *emitted) int {
n := 0
for _, st := range out.states() {
if st.Idle {
n++
}
}
return n
}

func TestOpenRequiresOwner(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions internal/browser/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ func (s *Service) CloseOne(_ context.Context, opts api.SessionIDOptions) (api.No
return api.None{}, s.Close(opts.ID)
}

// KeepIdle keeps this session's idle browser for another idle window.
func (s *Service) KeepIdle(_ context.Context, opts api.SessionIDOptions) (api.None, error) {
return api.None{}, s.Keep(opts.ID)
}

func handleAPI(h Handle) api.BrowserHandle {
return api.BrowserHandle{ID: h.ID, URL: h.URL, Title: h.Title, Owner: h.Owner, Headed: h.Headed}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/browser/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,6 @@ func (s *Service) agentPoint(owner string) *point {
func (s *session) stateLocked(open bool) api.BrowserState {
return api.BrowserState{
Owner: s.owner, Open: open, Headed: s.headed, ByAgent: s.byAgent, URL: s.url, Title: s.title,
Dialog: s.dialog, Tabs: s.tabs,
Dialog: s.dialog, Tabs: s.tabs, Idle: s.asked,
}
}
6 changes: 3 additions & 3 deletions internal/browser/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func TestAttachRefusesABrowserInItsOwnWindow(t *testing.T) {
}
}

func TestReaperLeavesAWatchedBrowserUntilDetach(t *testing.T) {
func TestReaperLeavesAWatchedBrowserAloneUntilDetach(t *testing.T) {
s, launch, _ := viewService(t)
if _, err := s.Attach("s", Viewport{W: 800, H: 600, Scale: 1}); err != nil {
t.Fatal(err)
Expand All @@ -99,8 +99,8 @@ func TestReaperLeavesAWatchedBrowserUntilDetach(t *testing.T) {
}
s.now = func() time.Time { return later.Add(time.Hour) }
s.Reap()
if !launch.pages[0].closed {
t.Fatal("reaper kept an unwatched idle browser")
if running := s.Running(); len(running) != 1 || !running[0].Idle {
t.Fatalf("Running = %+v, want an unwatched idle browser asked about", running)
}
}

Expand Down
1 change: 1 addition & 0 deletions internal/routes/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func Browser(d *rpc.Handler, s *browser.Service) {
rpc.Handle(d, api.BrowserAttach, s.AttachView)
rpc.Handle(d, api.BrowserDetach, s.DetachView)
rpc.Handle(d, api.BrowserResize, s.ResizeView)
rpc.Handle(d, api.BrowserKeep, s.KeepIdle)
rpc.Handle(d, api.BrowserDefaultView, s.DefaultView)
rpc.Handle(d, api.BrowserMouse, s.MouseInput)
rpc.Handle(d, api.BrowserKey, s.KeyInput)
Expand Down
7 changes: 7 additions & 0 deletions native/backend/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ func (c *Client) CloseBrowser(ctx context.Context, id string) error {
return err
}

// KeepBrowser answers the question about session id's idle browser with
// keep.
func (c *Client) KeepBrowser(ctx context.Context, id string) error {
_, err := callMethod(ctx, c, api.BrowserKeep, api.SessionIDOptions{ID: id})
return err
}

// RunningBrowsers is every session browser the backend runs.
func (c *Client) RunningBrowsers(ctx context.Context) ([]api.BrowserState, error) {
return callMethod(ctx, c, api.BrowserRunning, api.None{})
Expand Down
Loading
Loading