Skip to content
Closed
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
23 changes: 18 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **A session can open a real browser the agent drives.** Right-click a session
card and pick **Browser tab** to open a Chromium window of that session's
own — the same page `lich browser` and the `browser_*` MCP tools inspect,
click, type, press named keys (Enter, Tab, Escape, …) and screenshot. It is
a second Chromium on the machine (never lich-shell / the UI window), and
closing it does not quit lich. `file:` and `javascript:` URLs are refused,
and form values never enter the transcript.

- **The Commits tab says which GitHub account each commit actually landed
under.** A project's GitHub account governs what lich asks `gh`; it has never
governed the push, which still signs with your global `user.email`. So a pull
Expand Down Expand Up @@ -48,6 +56,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Browser tab no longer flashes shut with "invalid context".** CDP actions
now run on the Chromium they just launched, so the window stays open.

- **What you type while a message is being relayed into your session no longer
goes out with it.** A relayed message is pasted at the prompt and an Enter is
sent behind it, and anything typed between the two was submitted as part of
Expand Down Expand Up @@ -275,20 +286,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
measured against that login instead of the one lich is signed in to. A session
whose environment cannot be read at all shows no gauge on any platform now,
which is what a card whose terminal is not yet up looks like until it is.

- **Closing a session on Windows now asks the agent to leave before
terminating it.** The close sends the terminal's Ctrl+C and gives the agent a
moment to run its own exit path — hooks, transcripts, whatever it writes on
the way out — where it used to be killed outright. An agent whose TUI wants a
second Ctrl+C to quit is still terminated when that moment runs out.

- **A session whose shell runs inside tmux, over ssh or in a container now says
its directory is unknown instead of naming a local one.** The card, its
tooltip and the footer read "cwd unknown · inside tmux": the directory those
hosts sit in is real and is not where the user is typing, which is the one
wrong answer that looks right. Linux and macOS; Windows has no foreground
process group to read it from.

- **The Review panel's recap band no longer goes blank behind a large tool
result, and says when its words are the previous turn's.** The band read a
bounded tail of the conversation, so a turn whose closing words ended up
Expand All @@ -297,13 +305,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
where the last read stopped, so a turn is read whole however large it is;
while a turn is running the band is labelled "from the previous turn", which
is whose words they are beside a diff that has no window to draw yet.

- **Searching a large history no longer reads whole conversations to show one
sentence.** Every row a search matched on its conversation had its indexed
body read out whole: megabytes each, a page of a hundred rows, once per
settled keystroke. The stretch the snippet is cut from is now cut by the query
itself, so the page reads kilobytes whatever the conversations behind it hold.

- **On Windows, lich's own window no longer opens a stray console window beside
it, or gives up and reopens in Chrome.** The window was built for the console
subsystem, so Windows allocated a console for it and for each of Chromium's
Expand Down Expand Up @@ -375,35 +381,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
way — it is what each sender would have been told alone — and the worker is
asked at its prompt to name the ticket. One open request behaves as before on
both paths.

- **The shortcut recorder now says what a chord costs the terminal.** A bound
chord is caught before the session sees it, so rebinding one to `Ctrl+R` took
the shell's history search with nothing on screen connecting the two. The row
now names what the chord already does down there — the shell's own control
codes, the terminal's search, the image paste the agent reads — and still
records it.

- **The sandbox's "Ask each time" rung now reaches every session.** It only ever
asked in the New worktree dialog, so a session started from the New session
menu, by another session, or through the MCP tool quietly ran on the machine.
The New session menu now puts the question before the card opens, and a
session nobody can be asked about — one opened by an agent through
`open_session` or `lich open` — is confined, which that call now says.

- **A session card now says when the sandbox setting moved past it.** A session
is confined by the answer it opened with, so changing the rung in Settings
never reaches a card already open, and the card's shield said confined or not
without ever saying why. A session the rung would confine today now carries a
crossed shield, and the tooltip on either side of the disagreement says to
reopen the session to apply the setting.

- **`lich cost` now says whose arithmetic each dollar is.** A new `source`
column, carried in `--json` and `--csv` too, marks a row `priced` where lich
derived the money from Claude Code's and Codex's token counts and `reported`
where oh-my-pi, opencode or Crush handed over the figure they computed
themselves. A project that ran both reads `mixed`, and a total that mixes them
gains a line splitting the sessions between the two.

- **A session card now says why it cannot be forked.** "Fork to worktree…" used
to be missing altogether on Antigravity, oh-my-pi, Crush, Cursor CLI and Kiro
CLI cards, so the offer looked lost rather than withheld. The item is there
now, disabled, under one line naming the provider — their CLIs keep no fork,
only resume.

- **A session card's right-click menu now reads the same on every card.** The
shortcuts printed beside Rename, Pin, Delegate, Terminal and Close session
were only ever true of the card in view, since that is the card a chord acts
Expand Down Expand Up @@ -2075,6 +2087,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
raised surface and stops at the ends of the list it is being reordered in —
the same for checkout groups and project tabs.


## [0.35.0] - 2026-08-16

### Added
Expand Down
5 changes: 5 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ telemetry, so the interesting boundaries are local ones:
[lich-plugin](https://github.com/omartelo/lich-plugin) and post to the same
authenticated listener. A payload that escapes its contract belongs here too;
a bug in the scripts themselves belongs in that repository.
- **The agent browser.** Each session may launch a second Chromium, driven over
CDP, with its own profile — never the lich `--app` window, never
`lich/chromium-profile`. URLs are allow-listed (`http`/`https`/`about:blank`);
page-info never serializes form values. Attaching CDP to the UI process, or
loading `file:` / `javascript:` / `data:` for the agent, is a vulnerability.

## What is not a vulnerability

Expand Down
50 changes: 50 additions & 0 deletions docs/browser-tab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Decision: the session browser is a sidecar window, not an in-app tab

**Status: shipped.** Right-click a session card → **Browser tab** opens a
Chromium window owned by that session. The agent drives the same page through
`lich browser` and the `browser_*` MCP tools.

## Why not a tab inside lich

lich's window is a system Chromium in `--app` mode (`docs/chromium-shell.md`).
lich does not embed Chromium: no `WebContentsView`, no `<webview>`, no CEF.
An iframe of arbitrary `https://` is unsafe (X-Frame-Options, and the RPC
token in the page URL). Attaching CDP to the `--app` process would expose the
UI and the session token.

So the product is a **second Chromium process** on `chromium.FindBrowser`
(system Chrome/Edge/Chromium/… — never `lich-shell`), with a **different**
`user-data-dir` than `lich/chromium-profile`. Closing that window does not
quit lich. Closing the session card kills the sidecar.

## One browser per card

The human entry point is the session's context menu (after Pin, before Open
Terminal), not the file tree and not a dock tab. Clicking **Browser tab**:

- none yet → headed window at `about:blank`, chromedp attached
- already headed → focus that window
- headless only (the agent opened first) → replace with a headed window at
the same URL (chromedp cannot promote a headless process in place)

The agent without the menu uses `browser_open`: headless if nobody asked for a
window, reuse the headed one if the human already did. MCP and `lich browser`
target `LICH_SESSION_ID`, not a pool of ids.

## What the agent may load

`http`, `https`, `about:blank`. Not `file:`, `javascript:`, `data:`, `chrome:`.
Page-info never serializes form control values (passwords, recovery phrases);
agents get `filled` / `checked`. Screenshots are files, not base64 in the
transcript. Click, type and press are real input events, not `element.click()`
or `el.value = …`. `press` is named keys only (Enter, Tab, Escape, arrows, …);
text still goes through `type`.

## Providers

Claude Code and Codex see the tools at spawn (`lich mcp`). Crush and oh-my-pi
see them once the plugin has registered that server. **opencode does not**:
its plugin cannot register MCP and still defines the original seven tools —
see `docs/ceilings.md`. `lich browser` still works in that PTY.

`evaluate` (arbitrary JS in the page) is out of v1.
40 changes: 11 additions & 29 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,35 +300,17 @@ work when nobody knows it and that the call site never shows. The mechanism and
holds the delivery back while the user has unsent input there. What it counts is printable input since the last
Enter, escape sequences skipped — it cannot see the line, so an edit that leaves it empty by another route
(Ctrl+W, a click into the middle of it) reads as a draft that is still there, and a delivery waits out
`draftIdle` for nothing. The stale-draft release is what keeps that a delay instead of a wedged relay, and the
wait says so where it is happening: the pull request and issue handoffs
(`frontend/src/lib/terminal/write-at-prompt.ts`) wait on the same answer with no Enter of their own to justify
it, so while one is held its target's card carries a rung saying something is waiting for that prompt — hand a
conflict to a session you left half a sentence in and the card says where it went, rather than the click reading
as having done nothing. The mark lives in the page that is waiting, so it is only ever on the window that started
the handoff, and the relay's own hold is not drawn at all: a delivery held there answers to its sender, through
the ticket. What stays open is the provider that takes keystrokes through anything other than this PTY, which is
invisible to every part of this.
- **A relayed Enter is timed against silence, not against the target** (`internal/relay`, `awaitSettled`): lich
presses Enter once the target's PTY has been quiet for `defaultSubmitDelay`, because nothing here can read a TUI's
screen to know it has taken the paste in. The window that opens on the target's own keyboard is closed rather
than lived with: from the paste to the Enter its keystrokes are held and written at the prompt the Enter leaves
behind (`terminal.HoldInput`), so what a person types there is late by the length of the window instead of
submitted inside somebody else's message. Held, not dropped — but held is still not typed: an interrupt reached
for in that window lands after the delivery, on the turn the delivery started. On Windows that quiet is the whole instrument — ConPTY hands a child key
events rather than bytes, the bracketed paste markers do not survive, and every provider TUI then guesses at where
a paste ends from timing alone. A target that repaints on a timer of its own never goes quiet and gets its Enter
at `defaultSettleLimit` regardless, which is the case this cannot tell from a paste still arriving.
- **An install started from `go run` registers the lich on PATH, not itself** (`internal/agentplugin/crush.go`,
`resolveLichBinary`): Crush's, oh-my-pi's and Cursor's registrations name the absolute path of the lich that
wrote them, and under `go run` — `task dev` — that path is the binary the toolchain built into its cache and
deletes when the run ends, so writing it gives a registration that works for the rest of that session and then
fails silently forever. lich writes `lich` from PATH instead, recognising the cache by shape
(`go-build*/b*/exe/*`) since the toolchain exports no marker. The trap is that a dev install then points at
whatever version is installed on the machine — harmless, because the registration is only the transport and a
session reaches the lich its PTY's coordinates name, but not what the file appears to say. With no lich on
PATH at all, a dev install registers nothing: Crush and oh-my-pi still get their hooks, and Cursor's install
refuses outright.
- **An answer that names no ticket is matched by delivery order** (`internal/relay/relay.go`, `errandOfLocked`): `lich reply "<answer>"` and `reply_to_session` without a ticket close the oldest message delivered to that session and still open, because nothing in an answer itself says which request it belongs to. A session working two relayed tasks at once that answers the second one first sends it home as the answer to the first, and both senders read a confident wrong report — nothing anywhere reports the mismatch. Naming the ticket is still the only exact route, which is why every relayed message spells it and why the card's tooltip shows it.

- **Installing the plugin writes into four harnesses' own directories** (`internal/agentplugin`): Claude Code and Codex are driven through their plugin CLI, but opencode, oh-my-pi and Crush have none, so lich writes the released files itself. None of them records what is installed, so the version lives in a marker line lich wrote — edit the file by hand and lich reads it as not installed. Crush below 0.88.0 ignores those lines in silence, which is why the install asks its version first. Crush's block and omp's `mcp.json` register lich's MCP server by the absolute path of the binary that installed it, and omp's is a JSON document lich rewrites rather than appends to: every key survives, the user's formatting does not.
- **opencode does not see the session browser tools.** Claude Code and Codex get them at spawn, Crush and oh-my-pi through the plugin's MCP registration; opencode's plugin cannot register an MCP server and still defines only the original seven tools. `lich browser` still works in an opencode PTY. The plugin's `toolsMinVersion` is not bumped for this.
- **The agent browser is a second Chromium, never CDP on the lich window.** Attaching to the `--app` process would expose the UI and the session token. The sidecar uses `chromium.FindBrowser` (system Chrome/Edge/…, never `lich-shell`) with its own user-data-dir. Promoting a headless context to a visible window replaces it, so the page reloads.
- **omp's state directory answers to two variables, and the profile wins** (`internal/agentplugin/omp.go`, `internal/terminal/transcript.go`, resolving it independently as the Claude Code pair do): `OMP_PROFILE` moves the whole directory and beats an explicit `PI_CODING_AGENT_DIR`. Get it backwards and the install lands where omp is not reading and every restored card silently starts fresh.

- **A relayed Enter is timed against silence, not against the target** (`internal/relay`, `awaitSettled`): lich presses Enter once the target's PTY has been quiet for `defaultSubmitDelay`, because nothing here can read a TUI's screen to know it has taken the paste in. The window that opens on the target's own keyboard is closed rather than lived with: from the paste to the Enter its keystrokes are held and written at the prompt the Enter leaves behind (`terminal.HoldInput`), so what a person types there is late by the length of the window instead of submitted inside somebody else's message. Held, not dropped — but held is still not typed: an interrupt reached for in that window lands after the delivery, on the turn the delivery started. On Windows that quiet is the whole instrument — ConPTY hands a child key events rather than bytes, the bracketed paste markers do not survive, and every provider TUI then guesses at where a paste ends from timing alone. A target that repaints on a timer of its own never goes quiet and gets its Enter at `defaultSettleLimit` regardless, which is the case this cannot tell from a paste still arriving.
- **An install started from `go run` registers the lich on PATH, not itself** (`internal/agentplugin/crush.go`, `resolveLichBinary`): Crush's, oh-my-pi's and Cursor's registrations name the absolute path of the lich that wrote them, and under `go run` — `task dev` — that path is the binary the toolchain built into its cache and deletes when the run ends, so writing it gives a registration that works for the rest of that session and then fails silently forever. lich writes `lich` from PATH instead, recognising the cache by shape (`go-build*/b*/exe/*`) since the toolchain exports no marker. The trap is that a dev install then points at whatever version is installed on the machine — harmless, because the registration is only the transport and a session reaches the lich its PTY's coordinates name, but not what the file appears to say. With no lich on PATH at all, a dev install registers nothing: Crush and oh-my-pi still get their hooks, and Cursor's install refuses outright.

- **The plan gauge answers to two undocumented endpoints, and only two providers have one** (`internal/quota`): Claude Code's and Codex's usage routes are what their own CLIs poll, not published API. A field renamed upstream drops the window it fed rather than raising anything — an entry lich has no name for is
- **The plan gauge answers to two undocumented endpoints, and only two providers have one**
(`internal/quota`): Claude Code's and Codex's usage routes are what their own CLIs poll, not published API. A
field renamed upstream drops the window it fed rather than raising anything — an entry lich has no name for is
Expand Down
Loading