Repository navigation
perf: an overnight pass over the daemon, the laptop agent and the app - #46
Merged
Merged
Conversation
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>
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
opt-level = "s": Mac binary 7.51 → 6.02 MB.body:has()rule inscenes.cssrestyled the whole document whenever any element was added. It is replaced by adata-berth-dialogattribute, 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.Access-Control-Max-Age: 600).internal/copybuf: proxied GET 82 → 15.5 KB.routes.jsonis cached until the file changes: 14.9 → 2.2 µs per lookup.Every change, with charts and the raw benchmark output, is in the report linked at the end.
Needs a careful look
internal/trustnow cachesclients.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.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.app/src/lib/chat-controls.tsimports. data-flow droppeduseEventLog, and bundle swapped the staticmockDemoimport for a dynamic one, so neither import stays.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.Checks on the merged tree
go vet ./... && go test -race ./...cargo checkpnpm install --frozen-lockfile,typecheck:plugins,check:titles,check:csp,check:themes(24/24),pnpm test(301/301),pnpm build,npx tsc -p e2eteam-setup:241passes after it, along with the 50 tests in its area.a11y settings-boxes in berth-darkalso 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.TestWakingFromSleepReconnectsEveryBoxlost its free port to another test. InTestReviewRoutesOverTheWire, 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