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
### Added

- The crosshair on a session browser's toolbar lets you leave notes on the page for the agent. The element under the pointer is outlined and named, a click opens a note beside it, and each note pins its element with a number, following it as the page scrolls. Notes wait in a tray floating over the bottom of the page, which never resizes under you. Hovering a note there lights its element, a click edits it, and Send (or Ctrl+Enter) types them into the session as one message: which element, a selector the agent's `browser_click` takes, the React component that drew it in a development build, and your note. Ctrl+Shift+E (Cmd+Shift+E on macOS) turns the crosshair on and off, and Esc leaves it. A send the session refuses keeps the notes and says why in the tray.
- A session's browser downloads files. A download the page starts, from a link, a button, a new tab or a script, lands in your Downloads folder under the name the page gives it, with ` (1)` added rather than overwriting a file already there. The agent can also open a file's URL directly. The agent's click, key press or open answers with the file's path and size, and the window shows a toast naming the file, with a button that opens its folder. Before, a download button in the session's browser did nothing.

## [0.80.0] - 2026-10-27

Expand Down
40 changes: 34 additions & 6 deletions api/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,29 @@ var (
// BrowserAgentEvent is emitted each time the agent acts on its page, so the
// pane can show that the page moved because of it, and where it clicked.
BrowserAgentEvent = defineEvent[BrowserAgentAct]("browser-agent")

// BrowserDownloadEvent is emitted when a file a session's page downloaded
// is complete on disk, so the person learns where it went.
BrowserDownloadEvent = defineEvent[BrowserDownload]("browser-download")
)

// Download states, Chrome's own (Browser.downloadProgress).
const (
DownloadInProgress = "inProgress"
DownloadCompleted = "completed"
DownloadCanceled = "canceled"
)

// BrowserDownload is one file a session's page downloaded: Path is where it
// lands in the user's Downloads folder, under the name it keeps once
// complete; Bytes is what arrived so far.
type BrowserDownload struct {
Owner string `json:"owner,omitempty"`
Path string `json:"path"`
Bytes int64 `json:"bytes"`
State string `json:"state"`
}

// BrowserState is a session's browser as the window shows it. Open false means
// it is gone; ByAgent says the agent started it rather than the window.
type BrowserState struct {
Expand Down Expand Up @@ -136,13 +157,16 @@ type BrowserTextOptions struct {
}

// BrowserHandle is one session's browser: the id later calls take, and enough
// for a tool result to say which window it is.
// for a tool result to say which window it is. Downloads, on an open or a
// navigate, are what that load downloaded: a URL that answers with a file
// saves it and leaves the page where it was.
type BrowserHandle struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Owner string `json:"owner"`
Headed bool `json:"headed"`
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Owner string `json:"owner"`
Headed bool `json:"headed"`
Downloads []BrowserDownload `json:"downloads,omitempty"`
}

// BrowserOpenOptions loads a URL in the session's browser, creating a headless
Expand Down Expand Up @@ -227,6 +251,10 @@ type BrowserPageInfo struct {
Text string `json:"text"`
Interactive []BrowserInteractive `json:"interactive"`
Truncated bool `json:"truncated"`
// Downloads are the ones the agent has not heard of in their current
// state: what its last action saved, or one it saw start and that ended
// since.
Downloads []BrowserDownload `json:"downloads,omitempty"`
}

// BrowserPointOptions is a point of session ID's page, in CSS pixels.
Expand Down
10 changes: 10 additions & 0 deletions api/testdata/contract.golden
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ tray.SetLabels(TrayLabels) None
event appupdate-progress AppUpdateProgress
event branch-pull-request BranchPullRequest
event browser-agent BrowserAgentAct
event browser-download BrowserDownload
event browser-state BrowserState
event browser:frame:<id> string
event claude-profile-login ProfileLogin
Expand Down Expand Up @@ -322,6 +323,7 @@ BrowserHandle {
title string
owner string
headed bool
downloads,omitempty []BrowserDownload
}

SessionIDOptions {
Expand All @@ -339,6 +341,7 @@ BrowserPageInfo {
text string
interactive []BrowserInteractive
truncated bool
downloads,omitempty []BrowserDownload
}

BrowserPointOptions {
Expand Down Expand Up @@ -1546,6 +1549,13 @@ BrowserAgentAct {
url,omitempty string
}

BrowserDownload {
owner,omitempty string
path string
bytes int64
state string
}

ProfileLogin {
profile string
output,omitempty string
Expand Down
22 changes: 21 additions & 1 deletion docs/browser-tab.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,28 @@ page only answers what is where (`browser.ElementAt`, `browser.Locate`); the
outline, the pins and the note are the window's, so nothing is injected into
the page.

## Downloads

A file the page downloads lands in the user's Downloads folder
(`XDG_DOWNLOAD_DIR`, from the environment or `user-dirs.dirs`, on Linux;
`~/Downloads` elsewhere) under the name the page suggests, made unique the way
browsers do (`report (1).csv`): nothing already there is overwritten. Chrome
writes it under its download id and lich renames it once it is complete. A
click or key press that starts one answers with a `downloaded:` line, its path
and size, after waiting up to ten seconds for it; one still running answers
`downloading:`, and the next page read says when it ends. `browser_open` and
`browser_navigate` on a URL that answers with a file download it the same way:
Chrome aborts that navigation and the page stays where it was, so the answer
is the handle with `downloads` rather than an error. That answer also
waits until 1.5 seconds after the download began, because Chrome aborts a
navigation started sooner and reloads the page. `browser-download` tells the
window when a file is complete, and the window shows a toast naming it with a
button that opens its folder. The same holds in a window opened with Open in a
window.

## Providers

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.
plugin registers. Downloads reach all eight the same way: in the answer of the
same tools.
12 changes: 11 additions & 1 deletion docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,7 @@ work when nobody knows it and that the call site never shows. The mechanism and
`docs/browser-tab.md`): a headless Chrome, Edge, Chromium, Brave, Vivaldi or Helium already installed,
whose page reaches the session's browser tab as a JPEG screencast over `/events`, at the pace the window
takes frames. The native window cannot hold a webview: on Linux it speaks Wayland without GTK. So the pane
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 downloads and the page's own dialogs out; Open in
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
Expand All @@ -991,6 +991,16 @@ work when nobody knows it and that the call site never shows. The mechanism and
moves on its own is outlined late. Notes not sent yet
live in the window: closing it, or the browser, loses them.

- **A session browser's downloads go straight to the user's Downloads folder** (`internal/browser/downloads.go`,
`session_downloads.go`, `docs/browser-tab.md`): `XDG_DOWNLOAD_DIR` on Linux, `~/Downloads` on macOS and
Windows, even for a Windows user who moved that known folder. While one runs, a file named by Chrome's
download id sits there; it takes its real name once complete. Chrome aborts a navigation the page starts
within about a second of a download (`net::ERR_ABORTED`, then it reloads the page; Chromium 155, with
downloads on or off), so an agent's click, key press, open or navigate that downloaded holds its answer
1.5 seconds; a navigation the person starts in the 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.

## Languages

- **The prompt language reaches a running session only through lich itself** (`internal/prompt`): text lich
Expand Down
30 changes: 23 additions & 7 deletions internal/browser/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func (s *Service) Info(id string) (PageInfo, error) {
}
s.remember(sess, info.URL, info.Title)
s.agentActed(sess, api.BrowserAgentAct{})
info.Downloads = s.untold(sess)
return info, nil
}

Expand Down Expand Up @@ -181,18 +182,33 @@ func (s *Service) Navigate(id, rawURL string) (Handle, error) {
if err != nil {
return Handle{}, err
}
err = runPage(func(ctx context.Context) error {
return sess.page.Navigate(ctx, u)
})
if err != nil {
s.drop(sess)
if err := s.goTo(sess, u); err != nil {
return Handle{}, err
}
s.remember(sess, u, "")
s.agentActed(sess, api.BrowserAgentAct{Action: "navigate", URL: u})
return s.handleOf(sess), nil
}

// goTo is the agent loading u in sess's page. A download leaves the page
// where it was, so its URL is not remembered as the page's.
func (s *Service) goTo(sess *session, u string) error {
var download bool
if err := runPage(func(ctx context.Context) error {
var err error
download, err = s.navigate(ctx, sess, u)
return err
}); err != nil {
s.drop(sess)
return err
}
if download {
s.touch(sess)
} else {
s.remember(sess, u, "")
}
s.agentActed(sess, api.BrowserAgentAct{Action: "navigate", URL: u})
return nil
}

// Reload reloads the current page.
func (s *Service) Reload(id string) error {
return s.act(id, api.BrowserAgentAct{Action: "reload"}, func(p Page, ctx context.Context) error { return p.Reload(ctx) })
Expand Down
41 changes: 24 additions & 17 deletions internal/browser/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ 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 @@ -41,11 +40,13 @@ var (
// opens and the page the agent drives are the same one. Headless until
// someone asks for a window; headed after OpenVisible.
type Service struct {
mu sync.Mutex
launch Launcher
mkTmp func(dir, pattern string) (string, error)
now func() time.Time
idle time.Duration
mu sync.Mutex
launch Launcher
mkTmp func(dir, pattern string) (string, error)
now func() time.Time
idle time.Duration
// quiet is downloadQuiet, which the suite's fake pages do without.
quiet time.Duration
byOwner map[string]*session
byID map[string]*session
// owners serializes one session's launch, promotion and reuse, so a slow
Expand All @@ -68,9 +69,13 @@ type session struct {
// it, so the reaper leaves it alone like a headed window.
byAgent, watching bool
// agentAt is where the agent last acted, where its next scroll turns.
agentAt *point
page Page
lastUsed time.Time
agentAt *point
// downloads are the page's, until the agent has heard how each ended;
// downloadBegun is when the last one began.
downloads []*download
downloadBegun time.Time
page Page
lastUsed time.Time
}

// New starts a service that launches the system Chromium (chromium.FindBrowser)
Expand All @@ -89,6 +94,7 @@ func newService(launch Launcher, mkTmp func(string, string) (string, error), now
mkTmp: mkTmp,
now: now,
idle: idle,
quiet: downloadQuiet,
byOwner: make(map[string]*session),
byID: make(map[string]*session),
owners: make(map[string]*sync.Mutex),
Expand All @@ -114,14 +120,9 @@ func (s *Service) Open(owner, rawURL string) (Handle, error) {
if sess == nil {
return s.launchFresh(owner, u, false, true)
}
if err := runPage(func(ctx context.Context) error {
return sess.page.Navigate(ctx, u)
}); err != nil {
s.drop(sess)
if err := s.goTo(sess, u); err != nil {
return Handle{}, err
}
s.remember(sess, u, "")
s.agentActed(sess, api.BrowserAgentAct{Action: "navigate", URL: u})
return s.handleOf(sess), nil
}

Expand Down Expand Up @@ -264,14 +265,20 @@ 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))
page.Listen(s.frameSink(sess), s.infoSink(sess), s.downloadSink(sess))
if err := runPage(func(ctx context.Context) error {
if len(spec.cookies) > 0 {
if err := page.SetCookies(ctx, spec.cookies); err != nil {
return err
}
}
return page.Navigate(ctx, spec.url)
download, err := s.navigate(ctx, sess, spec.url)
if download {
s.mu.Lock()
sess.url = "about:blank"
s.mu.Unlock()
}
return err
}); err != nil {
return Handle{}, errors.Join(err, page.Close(), os.RemoveAll(spec.dir))
}
Expand Down
1 change: 1 addition & 0 deletions internal/browser/browser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func testService(t *testing.T, launch *fakeLauncher) *Service {
return t.TempDir(), nil
}, func() time.Time { return now }, 5*time.Minute)
s.now = func() time.Time { return now }
s.quiet = 0
t.Cleanup(func() { _ = s.Cleanup() })
return s
}
Expand Down
Loading
Loading