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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The agent can wait for its browser page to change instead of reading it again in a loop. `browser_info` takes `wait_for` (text that should appear) and `wait_gone` (text that should disappear), matched anywhere on the page, and answers as soon as the page shows it, or after 20 seconds with the page as it stands and a line saying the wait timed out.
- The agent hears when the page in its session's browser breaks. Each page it reads, clicks or presses a key on answers with the console errors since its last look, once each: `console.error` calls, uncaught exceptions and resources that failed to load, cut to one short line, the first five and how many more. Warnings and other messages stay out.
- An agent can close a project, with `close_project` or `lich close-project`: the tab leaves the window and its sessions stop, kept for a reopen as when you close the tab. The project the agent runs in and the home project are refused.
- The agent and you answer the page's JavaScript dialogs in a session's browser. An alert, a confirm, a prompt or a "Leave this page?" the page opens shows as a card over the top of the page in the session's browser tab, naming the site that opened it, with its buttons and, for a prompt, a field. You answer it there, or the agent does with `browser_dialog`, and its cursor presses the card's button. The agent's click that opened a dialog answers at once with the dialog in it, and the agent hears of one you answered. Before, a button that showed an alert froze the page: the agent's click and every read after it waited thirty seconds and failed.

### Changed

- The window holds less memory with many sessions open. A terminal off screen lets go of the pixels it drew, a few megabytes each, and redraws them in one frame when it comes back.
- The browser tools answer an agent in the session's prompt language: the legend of the element list, the note that more elements wait below, the filled and checked marks, a download still running, a wait that timed out, the count of further console errors, and what an open dialog asks of it. The line tags (`url:`, `text:`, `downloaded:`, `console error:`, `dialog open:` and the rest) stay as they are in every language.

### Fixed

Expand Down
46 changes: 45 additions & 1 deletion api/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ var (
BrowserScroll = define[BrowserScrollOptions, None]("browser.Scroll")
BrowserList = define[SessionIDOptions, []BrowserHandle]("browser.List")
BrowserClose = define[SessionIDOptions, None]("browser.Close")
// BrowserAnswerDialog answers the JavaScript dialog the page holds open,
// and answers with the page as it stands after it.
BrowserAnswerDialog = define[BrowserDialogOptions, BrowserPageInfo]("browser.AnswerDialog")

// The window's side: a pane that shows a session's page and passes the
// person's input to it, beside the agent driving the same page.
Expand All @@ -31,6 +34,9 @@ var (
BrowserKey = define[BrowserKeyOptions, None]("browser.Key")
BrowserText = define[BrowserTextOptions, None]("browser.Text")
BrowserNav = define[BrowserNavOptions, None]("browser.Nav")
// BrowserAnswer is the person answering the page's open dialog in the
// pane, the window's side of BrowserAnswerDialog.
BrowserAnswer = define[BrowserDialogOptions, None]("browser.Answer")
// BrowserSelection is the text selected in the page, which the pane
// copies to the system clipboard: the headless browser's own clipboard
// is not the person's.
Expand Down Expand Up @@ -89,11 +95,42 @@ type BrowserState struct {
ByAgent bool `json:"byAgent"`
URL string `json:"url"`
Title string `json:"title"`
// Dialog is the JavaScript dialog the page holds open until the person
// or the agent answers it; nil when none is.
Dialog *BrowserDialog `json:"dialog,omitempty"`
}

// BrowserDialog is a JavaScript dialog a session's page opened. Type is
// Chrome's: alert, confirm, prompt or beforeunload. Default is a prompt's
// default text; URL is the page that opened it. Answer, on one the person
// answered, is DialogAccepted or DialogDismissed.
type BrowserDialog struct {
Type string `json:"type"`
Message string `json:"message"`
Default string `json:"default,omitempty"`
URL string `json:"url"`
Answer string `json:"answer,omitempty"`
}

// Dialog answers, as a page answer reports one the person gave.
const (
DialogAccepted = "accepted"
DialogDismissed = "dismissed"
)

// BrowserDialogOptions answers session ID's open dialog: Accept is OK (Leave
// on a beforeunload), false is Cancel. Text is what a prompt returns on OK;
// nil keeps its default text.
type BrowserDialogOptions struct {
ID string `json:"id"`
Accept bool `json:"accept"`
Text *string `json:"text,omitempty"`
}

// BrowserAgentAct is one agent action on its page. Action is click, type,
// press or scroll at a point; back, forward, reload or navigate, which the
// pane's toolbar does; or "" for one that changes nothing on screen (reading
// pane's toolbar does; accept or dismiss, the buttons of the pane's dialog
// card; or "" for one that changes nothing on screen (reading
// the page, a screenshot). X and Y are where it acted, in CSS pixels, when it
// acted at a point; DY is a scroll's distance, positive down; URL is where a
// navigate went.
Expand Down Expand Up @@ -274,6 +311,13 @@ type BrowserPageInfo struct {
// counts those past the cap.
ConsoleErrors []string `json:"consoleErrors,omitempty"`
ConsoleErrorsMore int `json:"consoleErrorsMore,omitempty"`
// Dialog is the JavaScript dialog the page holds open: until it is
// answered (BrowserAnswerDialog) the page runs no script, so the rest of the
// answer is the page as it stood when the dialog opened.
Dialog *BrowserDialog `json:"dialog,omitempty"`
// Dialogs are the ones the person answered in the pane since the agent's
// last page answer.
Dialogs []BrowserDialog `json:"dialogs,omitempty"`
}

// BrowserPointOptions is a point of session ID's page, in CSS pixels.
Expand Down
57 changes: 38 additions & 19 deletions api/testdata/contract.golden
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ agentplugin.Status(None) []PluginStatus
agentplugin.Update(PluginProviderOptions) None
appupdate.Apply(None) None
appupdate.Status(None) AppUpdateStatus
browser.Answer(BrowserDialogOptions) None
browser.AnswerDialog(BrowserDialogOptions) BrowserPageInfo
browser.Attach(BrowserViewOptions) BrowserHandle
browser.Back(SessionIDOptions) None
browser.Click(BrowserClickOptions) BrowserPageInfo
Expand Down Expand Up @@ -311,6 +313,26 @@ AppUpdateStatus {
installCommand string
}

BrowserDialogOptions {
id string
accept bool
text,omitempty *string
}

BrowserPageInfo {
url string
title string
text string
interactive []BrowserInteractive
truncated bool
downloads,omitempty []BrowserDownload
waitTimedOut,omitempty bool
consoleErrors,omitempty []string
consoleErrorsMore,omitempty int
dialog,omitempty *BrowserDialog
dialogs,omitempty []BrowserDialog
}

BrowserViewOptions {
id string
w int
Expand All @@ -336,18 +358,6 @@ BrowserClickOptions {
...BrowserTarget
}

BrowserPageInfo {
url string
title string
text string
interactive []BrowserInteractive
truncated bool
downloads,omitempty []BrowserDownload
waitTimedOut,omitempty bool
consoleErrors,omitempty []string
consoleErrorsMore,omitempty int
}

BrowserPointOptions {
id string
x float64
Expand Down Expand Up @@ -428,6 +438,7 @@ BrowserState {
byAgent bool
url string
title string
dialog,omitempty *BrowserDialog
}

BrowserScreenshotOptions {
Expand Down Expand Up @@ -1761,13 +1772,6 @@ SessionExit {
code,omitempty *int
}

BrowserTarget {
index,omitempty *int
selector,omitempty string
x,omitempty *float64
y,omitempty *float64
}

BrowserInteractive {
index int
tag string
Expand All @@ -1780,6 +1784,21 @@ BrowserInteractive {
checked,omitempty *bool
}

BrowserDialog {
type string
message string
default,omitempty string
url string
answer,omitempty string
}

BrowserTarget {
index,omitempty *int
selector,omitempty string
x,omitempty *float64
y,omitempty *float64
}

ProfileUpdate {
name,omitempty string
color,omitempty *string
Expand Down
38 changes: 34 additions & 4 deletions docs/browser-tab.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,38 @@ where it was thrown, or an error Chrome logs itself, such as a resource that
failed to load. Each is one line cut to 200 characters; past the first five, a
`(N more console errors)` line counts the rest. Warnings, info and log
messages are left out. The text is what the page logs, so a page that logs a
form's value hands it to the agent; lich reads nothing else. These lines are
English, outside the prompt-language catalogs, like the rest of the page answer.
form's value hands it to the agent; lich reads nothing else.

## Dialogs

An `alert`, `confirm`, `prompt` or `beforeunload` the page opens stays open
until someone answers it, and until then the page runs no script and takes no
input. lich answers no dialog on its own:

- The pane shows it as a card over the top of the page, naming the site that
opened it, with OK and Cancel (Leave and Stay for a `beforeunload`) and a
field for a `prompt`. The person answers there.
- The agent's call that opened it answers at once, rather than hanging, with
the page as it stood and a `dialog open:` line; any other call on that page
fails with a message naming the dialog. `browser_dialog` (`lich browser
dialog accept|dismiss`) answers it, and the pane plays the agent's cursor
pressing the card's button. `text` is what a `prompt` returns on OK; left
out, it returns its default text.
- Whichever answers first wins. A dialog the person answered reaches the
agent's next page answer as a `dialog answered:` line.

Accepting a `beforeunload` less than a second after it opened waits out the
rest of that second: Chrome aborts the navigation it held when it is accepted
sooner, and reloads the page, which asks again.

## Languages of the page answer

The line tags of a page answer (`url:`, `title:`, `wait:`, `text:`,
`interactive`, `downloaded:`, `downloading:`, `console error:`, `dialog
open:`, `dialog answered:`) stay English: the tool descriptions name them. The
prose around them, the element list's legend, the filled and checked marks,
the note that more elements wait below, and what a held dialog asks of the
agent, follows the session's prompt language.

## Downloads

Expand Down Expand Up @@ -104,5 +134,5 @@ window.

Claude Code, Codex and opencode receive the tools at spawn. Antigravity,
oh-my-pi, Crush, Cursor CLI and Kiro receive them from the lich MCP server the
plugin registers. Downloads reach all eight the same way: in the answer of the
same tools.
plugin registers. Downloads and dialogs reach all eight the same way: in the
answer of the same tools, and `browser_dialog` for all of them.
12 changes: 12 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,18 @@ work when nobody knows it and that the call site never shows. The mechanism and
pane is not held. A download still running after ten seconds is reported in progress and named again when it
ends. No tool lists past downloads or cancels one, nothing caps their size, and closing the browser cancels one
still running.
- **A dialog a session's page opens holds the page until someone answers it** (`internal/browser/dialogs.go`,
`chrome_dialog.go`, `docs/browser-tab.md`): nothing answers it on its own, so an agent that never calls
`browser_dialog` and a person who never looks at the pane leave the page frozen, every other agent call failing
with the dialog's name, until the browser is closed. The agent's click that opened it answers with the page as it
stood, not as the dialog's answer left it. A dialog the page opens on a timer interrupts whichever call is
running. Accepting a `beforeunload` less than a second after it opened is held to that second: accepted sooner,
Chrome aborts the navigation it held and reloads the page, which asks again (3 of 3 at 300 ms, 3 of 6 at 400 ms,
none of 30 from 500 ms; Chrome for Testing 155 and Helium 0.19.2.1). The agent hears of a dialog the person
answered, at most twenty between two page answers, but not of one the agent itself answered. A held page cannot lay
out, so a pane opened or resized meanwhile shows it at the size it had, and lays it out at the pane's once the
dialog closes; a screenshot fails until then.
A window opened with Open in a window was not tried with a dialog.

## Languages

Expand Down
16 changes: 6 additions & 10 deletions internal/browser/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,13 @@ func (s *Service) Info(id string) (PageInfo, error) {
return e
})
if err != nil {
s.drop(sess)
return PageInfo{}, err
return PageInfo{}, s.failed(sess, err)
}
s.remember(sess, info.URL, info.Title)
s.agentActed(sess, api.BrowserAgentAct{})
info.Downloads = s.untold(sess)
info.ConsoleErrors, info.ConsoleErrorsMore = s.consoleUntold(sess)
info.Dialogs = s.answeredUntold(sess)
return info, nil
}

Expand All @@ -96,8 +96,7 @@ func (s *Service) Click(id string, t Target) error {
return e
})
if err != nil {
s.drop(sess)
return err
return s.failed(sess, err)
}
s.touch(sess)
s.agentActed(sess, api.BrowserAgentAct{Action: "click", X: &x, Y: &y})
Expand Down Expand Up @@ -165,8 +164,7 @@ func (s *Service) Screenshot(id, dest string) (string, error) {
return sess.page.Screenshot(ctx, dest)
})
if err != nil {
s.drop(sess)
return "", err
return "", s.failed(sess, err)
}
s.touch(sess)
s.agentActed(sess, api.BrowserAgentAct{})
Expand Down Expand Up @@ -198,8 +196,7 @@ func (s *Service) goTo(sess *session, u string) error {
download, err = s.navigate(ctx, sess, u)
return err
}); err != nil {
s.drop(sess)
return err
return s.failed(sess, err)
}
if download {
s.touch(sess)
Expand Down Expand Up @@ -273,8 +270,7 @@ func (s *Service) drive(id string, fn func(Page, context.Context) error) (*sessi
}
err = runPage(func(ctx context.Context) error { return fn(sess.page, ctx) })
if err != nil {
s.drop(sess)
return nil, err
return nil, s.failed(sess, err)
}
s.touch(sess)
return sess, nil
Expand Down
18 changes: 11 additions & 7 deletions internal/browser/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/chromedp/cdproto/network"
"github.com/lichdotdev/lich/api"
)

// idleAfter is how long a headless browser no pane shows sits unused before
Expand Down Expand Up @@ -81,8 +82,13 @@ type session struct {
// first consoleErrorMax of them; consoleMore counts the rest.
consoleErrors []string
consoleMore int
page Page
lastUsed time.Time
// dialog is the JavaScript dialog the page holds open; answered are the
// ones the person answered since the agent's last page answer.
dialog *api.BrowserDialog
dialogAt time.Time
answered []api.BrowserDialog
page Page
lastUsed time.Time
}

// New starts a service that launches the system Chromium (chromium.FindBrowser)
Expand Down Expand Up @@ -152,8 +158,7 @@ func (s *Service) OpenVisible(owner string) (Handle, error) {
return s.promote(sess)
}
if err := runPage(sess.page.Focus); err != nil {
s.drop(sess)
return Handle{}, err
return Handle{}, s.failed(sess, err)
}
s.touch(sess)
return s.handleOf(sess), nil
Expand All @@ -170,8 +175,7 @@ func (s *Service) promote(sess *session) (Handle, error) {
cookies, err = sess.page.Cookies(ctx)
return err
}); err != nil {
s.drop(sess)
return Handle{}, err
return Handle{}, s.failed(sess, err)
}
s.mu.Lock()
s.detachLocked(sess)
Expand Down Expand Up @@ -273,7 +277,7 @@ func (s *Service) start(spec launchSpec) (Handle, error) {
id: newID(), owner: spec.owner, dir: spec.dir, url: spec.url, headed: spec.headed,
byAgent: spec.byAgent, page: page, lastUsed: s.now(),
}
page.Listen(s.frameSink(sess), s.infoSink(sess), s.downloadSink(sess), s.consoleSink(sess))
page.Listen(s.frameSink(sess), s.infoSink(sess), s.downloadSink(sess), s.consoleSink(sess), s.dialogSink(sess))
if err := runPage(func(ctx context.Context) error {
if len(spec.cookies) > 0 {
if err := page.SetCookies(ctx, spec.cookies); err != nil {
Expand Down
Loading
Loading