Skip to content

perf: an overnight pass over the daemon, the laptop agent and the app - #46

Merged
sean-brydon merged 49 commits into
mainfrom
perf/overnight
Oct 10, 2026
Merged

sean-brydon merged 49 commits into
mainfrom
perf/overnight

Conversation

@sean-brydon

@sean-brydon sean-brydon commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

An overnight performance pass over the Go daemon, the laptop agent and the React app. Five agents each profiled one area, kept only changes they could measure before and after on the same machine, and left larger ideas as suggestions. Everything is merged here as one branch.

What you get

  • The app opens faster. It loads 4 files before first paint instead of ~300 (every lucide icon used to be its own chunk). Mock fixtures, ten pages, four dialogs and the browser panes now load after start. Startup JS: 3218 → 1892 KB raw, 836 → 459 KB brotli. First paint, real bootstrap without an agent (Chromium): 492 → 96 ms. Tauri opt-level = "s": Mac binary 7.51 → 6.02 MB.
  • Chats keep up while an agent writes. A page-wide body:has() rule in scenes.css restyled the whole document whenever any element was added. It is replaced by a data-berth-dialog attribute, with a unit test that fails on any page-wide :has(). Main thread per streamed draft update: 15.7 → 5.2 ms. Home composer keystroke at 300 worktrees: 71 → 13 ms. The reply box, sidebar projects, folded rail, closed ⌘K palette and Home's agent lists are memoized: components per draft update 152 → 18.5, rail 2,912 → 558 per update.
  • The app asks for less.
    • CORS preflights are cached for 10 minutes (Access-Control-Max-Age: 600).
    • Chat controls take their signals from the transcript feed instead of a second poll.
    • The dev-servers status count no longer fetches services for every worktree.
    • The review inbox rereads only the box an event came from.
    • Artifacts are read once per worktree open.
    • Idle chat: 195 → 100 requests/min. Streaming: 430 → 261.
  • Box answers are gzipped over the link for JSON of 1 KB or more: about 10–15× smaller (300 worktrees' locations 65 → 6.2 KB). Go's client unpacks them on its own.
  • berthd does less per poll.
    • Transcript poll: 5 tmux commands → 1, CPU 4.96 → 1.14 ms.
    • Draft poll: 2 → 1 commands.
    • Screen poll: 9 → 5 commands.
    • Stats: one git command per location instead of four.
    • Opening a long conversation: 115 → 9.5 MiB allocated.
    • The touched-files cache, which grew forever, now holds 64 entries.
    • The parser was checked byte-for-byte against 405 real records (hashes only).
  • The laptop agent allocates less per request.
    • Copy buffers are pooled in a new internal/copybuf: proxied GET 82 → 15.5 KB.
    • The proxy request log no longer copies itself on every request.
    • routes.json is cached until the file changes: 14.9 → 2.2 µs per lookup.
    • Kits and team suggestions ask boxes concurrently: 206 → 51 ms with 4 boxes.

Every change, with charts and the raw benchmark output, is in the report linked at the end.

Needs a careful look

  • internal/trust now caches clients.json (8ef13f6). It re-reads whenever a stat of the file changes (dev, inode, size, mtime and ctime in ns), and never caches errors, so an unreadable store still refuses everyone. Writes are temp file + rename. Tests cover a revoke from another process, a same-size swap and a corrupt file. In theory it could miss a same-inode, same-size rewrite within one timestamp tick. It decides who may connect to a box, so please review it as a security change.
  • Box gzip (7aaecb1) needs boxes to run the new berthd. Older laptop agents are fine: Go's transport asks for and unpacks gzip on its own.

How this branch was put together

Merge order onto origin/main (c8e2f99): perf/go-agent, perf/data-flow, perf/bundle, perf/go-box, perf/react-render.

  • Conflict: app/src/lib/chat-controls.ts imports. data-flow dropped useEventLog, and bundle swapped the static mockDemo import for a dynamic one, so neither import stays.
  • Cross-branch check: go-box's "no read when the transcript hasn't grown" returns inside the parser's state, so data-flow's signals-from-the-feed still get signals on every poll.
  • Dropped at merge: data-flow's "first fetch waits for the event stream" (565112c) broke e2e/team-setup.spec.ts:241. The team deep link decided its page before any box was read. Reverted in e1dd853; the report lists it as a suggestion.
  • Changelog: six plain-words bullets under Unreleased.

Checks on the merged tree

  • go vet ./... && go test -race ./...
  • cargo check
  • pnpm install --frozen-lockfile, typecheck:plugins, check:titles, check:csp, check:themes (24/24), pnpm test (301/301), pnpm build, npx tsc -p e2e
  • docs-site build
  • Full Playwright suite: 292/294 before the revert. team-setup:241 passes after it, along with the 50 tests in its area. a11y settings-boxes in berth-dark also fails on main (a list with non-<li> children); it's not from this branch.

CI is green. The first Go run failed on two timing flakes and passed on rerun; both passed 15/15 locally under -race. TestWakingFromSleepReconnectsEveryBox lost its free port to another test. In TestReviewRoutesOverTheWire, the review's setup script, started in a goroutine (prreview.go:574, untouched here), was still writing when the temp dir was removed.

Numbers are from one Apple-silicon laptop with the other agents' load on it. Timings are medians of interleaved runs. Bytes, allocations, request counts and component counts are exact. Startup was measured in Chromium; the app runs in WebKit, so check those numbers there before quoting them.

Report with every change, its charts and raw readings: https://claude.ai/artifact/MHYQV8F1m1YpqtzYpepQ7R (private until shared).

🤖 Generated with Claude Code

sean-brydon and others added 30 commits October 9, 2026 22:29
The ?mock fixtures (lib/mock*.ts, the acme team files, the demo terminal)
were statically imported by the connection hook, chat controls, the
composer and the conversation pane, so every start of the app parsed
~400 KB of them. They are now imported when mock mode asks for them.

Initial JS: 3218 -> 2922 KB raw, 836 -> 753 KB brotli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening and polling a long conversation, paging it, a tool call's
details, assigning transcripts, a chat's transcript poll, the screen
poll and the ledger's write, on synthetic acme fixtures. The box ones
count the tmux commands each op runs (execs/op).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plugins/ui.tsx imported lucide-react/dynamic for its Icon, which brings
the map of all ~1,700 icons (a lazy import each) into the startup bundle.
Icon now loads it, and DynamicIcon, on first use; like DynamicIcon it
draws nothing until the icon has loaded.

Initial JS: 2922 -> 2669 KB raw, 753 -> 715 KB brotli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ole window for every item

conv.add rebuilt the 300-item window on every item past the limit, so
reading the last 4 MB of a record allocated ~120 MB. The window now
slides along a backing array with room for as many again, and only
moves when that fills: ~11 MB for the same read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…five

EnvVar remembered a session's variable only when it was set, so the
usual answer (no CLAUDE_CONFIG_DIR of its own) asked tmux again for
every Claude session in the folder on every poll. Unset is now
remembered too; a session that is gone still isn't. The poll also
listed the sessions twice (to find the session, then its neighbours):
one list now serves both, and the tool-detail, helper and touched-files
reads reuse theirs the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bout ten times smaller

A box's JSON answers of 1 KB or more go gzipped to a client that asks;
Go's HTTP client (the laptop agent, the CLI) asks and unpacks on its own,
so every caller reads the same JSON. 300 worktrees' locations go from
65 KB to 6 KB, 100 sessions from 41 KB to 2.7 KB, a chat's first window
from 25 KB to 2.3 KB. Streams, files and errors pass through untouched,
and an answer flushed early goes out plain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…essions again

Sessions.Screen lists every session to check the one asked for exists
before capturing its pane. The 2-second screen poll, the startup watch
(every 250 ms while an agent starts) and the controls read had just
found the session in a list, so each read cost two tmux commands. They
now capture directly: the screen poll of four agents runs 5 tmux
commands instead of 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es are the same

Every ledger save (up to ten a second while agents work) also rewrote
the legacy per-folder states file, each with a full fsync, though most
saves (a prompt sent, the inbox, the journal moving on) leave it the
same. It is written now only when its bytes change: a save that leaves
it alone costs one fsync instead of two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and fan-out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e log

Past 200 requests a host's log was copied into a new slice on every
request (about 64 KB and 8 us each for a page and its API host). It now
grows to twice the limit and drops the old half in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
httputil.ReverseProxy and io.Copy between a socket and an HTTP/2 stream
each made a fresh 32 KB buffer per request or connection. They now share
a pool (internal/copybuf).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Detail scans up to the last 32 MB of a record for one call, and
readLine allocated every line afresh: ~36 MB of garbage per click on a
long conversation. The scan (and the latest turn's) now reads each line
into the same buffer: ~0.3 MB and 35 allocations, and faster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings, Automations, Dashboard, Review, Kits, Worktrees, Project
settings, Team setup, Onboarding and plugin pages were all in the startup
bundle. They are now separate chunks (lib/lazy-view.tsx), fetched one at a
time once the app is up and idle, so a first visit still draws at once;
a page whose code isn't in yet waits under a Suspense for it. openSettings
and OrgField move to small modules so the eager code that uses them
doesn't pull their pages back in.

Initial JS: 2669 -> 2288 KB raw, 715 -> 676 KB brotli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every open chat polls its transcript every few seconds (and the signals
bar every three), and each poll opened the record, allocated a 64 KB
read buffer and read to EOF though it had not grown. A stat now answers
that case, and the buffer for a real read comes from a pool: 15 us and
66 KB per idle poll become 1.8 us and 0.5 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xied request

The proxy read and parsed routes.json first thing for each request it
relayed (about 33 us on macOS). The routes are now kept until a stat
shows the file changed, so edits from the CLI still apply at once
(statefile.Cache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… whole status

Each request to a worktree name without a box built the agent's full
status, which reads three files, just to list the boxes' names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A body:has() rule that paused scenes behind a dialog made Chrome restyle
the whole document on every element added anywhere (each streamed draft
update, a menu opening): about 9 ms a time in a long chat. The root now
carries data-berth-dialog while a scene is drawn, kept by a
MutationObserver that only looks at what came, went or changed its role.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…all is one request

Every app call carries the token, so the webview preflights it; without
Access-Control-Max-Age it keeps a preflight 5 s, and most polls cost an
OPTIONS round trip first. On a fake agent with two boxes: 75 of 195
requests a minute at idle were preflights, 128 of 429 while a reply
streamed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eads it already makes

The chat's mode, model, context and tasks come with every transcript
read, yet the controls read the transcript again on their own every 3 s
(and on each agent event) just for them. The feed now keeps them, so a
shown chat makes about 20 fewer reads of its box a minute, and they are
as fresh as the conversation (at once on each write).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e's services

The status bar's server count used the screen's hook, which also asked
each worktree on every box for its configured services (one request per
worktree, again on every service or worktree event). The count needs only
what is listening; the screen still reads the rest. On two boxes with 51
worktrees that was ~51 requests at start and after each service event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…both reads at once

Every agent, worktree or session event on any box reread the review inbox
of every online box, and a box's GET review runs git in each worktree
with finished work. Now only the box the events came from is read (the
others keep theirs; a box that comes back, the backstop and Refresh still
read all), and a box's review and its recent checks are asked at once
instead of one after the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app fetched everything on connecting and again as soon as its event
stream opened, so each start read every box's locations, sessions,
stats, services and info twice. The first fetch now waits for the stream
(or 1.5 s at most), so nothing can change unheard in between.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…turn

The Kits view's installed list and team suggestions asked each online
box one after another, so four boxes took four answers' time and one
slow box held up the rest by up to its whole timeout. They now ask up to
eight boxes at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… time

A box read clients.json under an exclusive file lock for every request a
laptop made, so parallel requests queued behind each other (73 us to
2.7 ms a lookup on macOS with ten at once). The store is now kept until
a stat shows the file changed: a pairing or a revoke, by any process,
still applies to the very next request, and an unreadable store still
fails closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…part that shows them

The chat's chip, the board buttons and the board each asked for the
worktree's artifact list in the same render, before the first read had
marked it loading: three identical reads on every first open of a
worktree. The first read now serves them all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…settings read each

Every request the app sends a box through the agent made a 32 KB copy
buffer and read box-routes.json while syncing the boxes. The buffer now
comes from the shared pool and the route settings are kept until the
file changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rolldown cut the startup code into some 300 chunks, since lazily loaded
code shares much of it (every lucide icon became a file of its own), and
index.html preloaded them all before the first paint. A code-splitting
group now puts everything the entry statically reaches in a libraries
chunk and an app chunk; lazy code still splits as before.

Startup files: 312 -> 4. Initial JS: 2288 -> 2137 KB raw, 677 -> 509 KB
brotli. First paint (vite preview, Chromium, median of 11): ~660 ->
~110 ms; DOMContentLoaded ~620 -> ~70 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… busy machine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not two to five times

The parser decoded a line, then the signals decoded all of it again
(every assistant line), then the message, then its content as a string
before trying it as blocks, and each tool result the same way. The
signals' fields now come from the same pass, the message is decoded
once for both, and content is told string or blocks by its first byte.
Reading a long conversation takes ~25% less CPU and 20% fewer
allocations; what it reads is the same (checked against 405 real
records, byte for byte).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t every one

cachedTurn kept each record's latest turn, with every file it edited as
it found it, for every conversation the box ever showed, and never let
one go. It now keeps the 64 read most recently: after 500 conversations
it holds ~4 MB instead of ~31 MB, and stays there however long berthd
runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sean-brydon and others added 19 commits October 9, 2026 22:59
…of two

GET …/draft, polled every 0.6-1.5 s while an agent writes, listed every
session to find this one and then captured its pane. One tmux command
now prints the session's line (display-message with the list's format)
and its pane together; a session that is gone, or an answer that doesn't
parse, takes the old path, so errors stay the same. Half the execs, and
berthd's own CPU per poll goes from ~2.2 ms to ~1.0 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anged rows

Every project group listened to every box and rebuilt its tree, and every
worktree node drew again, whenever any agent anywhere changed state. A
project now follows only its own boxes' data, keeps its rows while they
are the same, and a worktree without children draws again only when its
row would. useProjects follows the boxes' locations, not their sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very box update

While closed it keeps what it last listed (its closing frames show it)
and lists sessions and worktrees afresh as it opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dget

useAllSessions and useAgentRows give every caller the same list for the
same store, so a dozen widgets and counts share one pass over the
sessions instead of each matching every session to its worktree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its controls

The reply box, its controls (mode, model, context, crew, todos) and the
chat background drew again with every draft read. They now take props
that stay the same while a draft streams, with actions that call the
pane's latest: about 150 components a draft update down to about 20.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release profile already had LTO, one codegen unit, panic=abort and
strip; opt-level "s" instead of 3 halves the Rust code. The shell only
answers the webview's calls and lays out browser panes, so speed there
doesn't show. The macOS release binary (the web assets it embeds
included): 7.51 -> 6.02 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cation, not four

GET /v1/stats, which the app reads again on every agent event, listed
the locations in full (worktrees, remote, default branch, configs) to
find which worktree each agent works in. It now lists only the
worktrees, and nothing at all when no agent process is found (always
on macOS, where /proc does not exist). The answer is the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eir content later

Their frames stay in the app (they open from anywhere), but what they
show - the add-a-box flow with its install, SSH and tailnet steps, the
add-a-project body, the PR review sheet's body and the What's new card
with its pictures - is fetched with the other pages once the app is up
(lib/lazy-view.tsx), so it is there by the time one opens.

Initial JS: 2137 -> 1979 KB raw, 509 -> 478 KB brotli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s covered

css-has.test.ts fails on any stylesheet selector or Tailwind variant that
asks body, html or :root :has(), the rule that restyled the whole page on
every element added. lastOf moves to lib/last-of.ts with tests of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BrowserPane and PreviewPane, with the devtools drawer and the sandbox and
login helpers they bring, are fetched with the other lazy pages; a pane
that shows before they arrive waits under a Suspense, as File and
artifact panes already do. The tab strip's error badge moves to
components/devtools-badge.tsx so it doesn't pull them back in.

Initial JS: 1979 -> 1892 KB raw, 478 -> 459 KB brotli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	app/src/lib/chat-controls.ts
All of the rail's tiles (258 at 300 worktrees, each with its menu and
hover card) drew again whenever any agent changed. A tile now draws
again only when what it shows changed, and each worktree finds its
sessions from a map instead of going through every session on its box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Waiting for the event stream before the first full read (565112c) made
team setup's deep link decide its page before any box had been read, so
?team=done opened the setup instead of "You're set up"
(e2e/team-setup.spec.ts:241). It saved two reads per box at start; not
worth an ordering change on every launch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
berth Ready Ready Preview Oct 9, 2026 11:22pm UTC
berth-docs Ready Ready Preview Oct 9, 2026 11:22pm UTC

Request Review

@sean-brydon
sean-brydon marked this pull request as ready for review October 10, 2026 08:44
@sean-brydon
sean-brydon merged commit 0703991 into main Oct 10, 2026
12 of 13 checks passed
@linear-code

linear-code Bot commented Oct 10, 2026

Copy link
Copy Markdown

ENG-5869

@sean-brydon
sean-brydon deleted the perf/overnight branch October 10, 2026 08:49

This branch was successfully deployed

2 active deployments
Preview – berth-docs — e1dd8535 Deployed Oct 9, 2026 by vercel[bot]
Preview – berth — e1dd8535 Deployed Oct 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant