Session performance, litter-bridge removal, and the models page redesign - #403
Open
0xSero wants to merge 33 commits into
Open
Session performance, litter-bridge removal, and the models page redesign#4030xSero wants to merge 33 commits into
0xSero wants to merge 33 commits into
Conversation
* test: make release acceptance repeatable * fix(controller): preserve logs after container removal * fix(controller): normalize partial download totals * fix(frontend): reconcile dropped model launches
* fix(release): remove GLM vision deployment assets * fix(ops): remove GLM vision runtime tooling * fix(release): remove obsolete vision release inputs
* feat(agent): define realtime session contract * docs(agent): specify realtime mobile lifecycle
Dialogs filled their backdrop with an opaque --color-background, so opening any confirm blanked the whole app. Swap in a translucent --color-scrim plus a light blur, and give the sheet ChatGPT's shape: 14px radius, no header divider or tinted header bar, sentence-case 16px title. Add UiModalBody/UiModalFooter (with a leading slot for destructive actions) and move all nine call sites onto them — they had been hand-rolling four different paddings and footer layouts. Retire the uppercase micro-cap label. One FIELD_LABEL_CLASS now drives FormField/Input/Select, the decorative page eyebrows are gone (prop deleted, not just hidden), and the same treatment comes off code-fence tags, diff headers, stat labels and section headings. Monospace goes back to paths, commands and code; hardware and plugin metadata read as prose again. ModelRow left-aligned its value column at 32% width, stranding text mid-row from the badge at the right edge — read-only values now sit beside the status they qualify. Replace four ad-hoc max-heights with PreviewScroll. Height latches: short output hugs its content, but once content would exceed the cap the box locks there and never resizes, so a streaming turn cannot walk the page under the reader. It also sticks to the bottom while streaming unless the reader has scrolled up. Same reasoning turns the controller log tail fixed-height. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
patchAssistantMessage copied session.messages on every patched event. The live path needs that copy — React diffs the array identity to decide what re-renders, so writing through it would make a streamed delta invisible — but canonical replay does not. foldSessionEvents builds a private session from an empty array and only the final result escapes, so every intermediate copy was garbage the moment the next event landed. Thread the ctx.replay flag that already exists into the patch. An 800-turn, 1600-message transcript folds in ~78ms instead of 113ms, and the superlinear tail flattens from 1.25x to ~1.1x per doubling — the array copy was the part that grew with transcript length. Three tests pin the invariant this trades on: the live reducer must still allocate, replay must produce the settled log unchanged, and folding one log twice must not bleed state between folds. scripts/bench/session-fold.bench.ts is how the numbers were taken; ledger and the open questions are in docs/session-performance.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
loadSession caps the transcript it returns at ~500 events, then calls two helpers that read the entire rollout file anyway. On real sessions under ~/.pi/agent/sessions that is 306ms for a 40MB log and 1.1s for a 145MB one, paid to return a few hundred events. readSessionUsageTotals was already memoised on (size, mtime). activeBranchEvents was not, and it runs on every open AND every "load earlier" page. Give it the same cache, on the same reasoning its neighbour already states: a rollout is append-only, so a file that has not grown cannot have a different active branch, and one that has grown invalidates on the next open — correct, since branching and compaction both write to the file. Through loadSession on the 40MB rollout: a history page drops from 100ms to ~10ms, a warm reopen from 213ms to ~120ms. The cold open is untouched, since it has to build the cache. The remaining number is the cold usage scan — 741ms on 145MB, re-paid whenever the file has grown, which is every open of a session you are actively using. It sums append-only totals, so it should be computable incrementally rather than from zero. Noted in docs/session-performance.md. bench/session-load.bench.ts measures loadSession against a real rollout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lifetime usage totals are append-only sums, but a file that had grown was rescanned from byte zero. That made the session you are actively working in the slowest one to open, since it is the one whose file keeps changing: 493ms per open on a 145MB rollout, 200ms on a 40MB one. Cache the byte offset just past the last COMPLETE line folded into the totals and resume from there. "Complete" is the whole correctness story — a rollout is appended to while being read, so a scan's last line is often half-written, and counting it as scanned would drop that turn's usage permanently. A head fingerprint and a size check catch the two ways append-only can break, rewrite and truncation, and force a full rescan. After one appended turn: 493ms -> 3ms on 145MB, 200ms -> 5ms on 40MB. Cold scans also got 14-35% faster as a side effect of hand-rolling the line split; readline cannot report the byte offset a resume needs. Seven tests pin the offset arithmetic, including multi-byte content where character offsets and byte offsets diverge. None of these failures throw — they silently report a wrong lifetime spend, so they are worth pinning. Measured next target, recorded in docs/session-performance.md: the 3.56GB rollout on this machine still costs ~32s to open cold, 25.1s of it inside buildContextEntries. Both caches are per-process, so a controller restart re-pays it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The active-branch walk and the usage scan are both memoised in process, which a controller restart throws away — and the sessions they are expensive for are exactly the ones a user keeps returning to. Every restart re-paid the whole cost on the next open of each large session. Back both with a small JSON entry per rollout under <dataDir>/rollout-cache/<kind>/, validated on (size, mtime) and versioned by schema. Strictly derived data: a miss, a corrupt entry, an unwritable directory and a schema bump all degrade to "recompute", never to a wrong answer. Entries are written through a temp file and rename so a reader never sees a partial one, and the directory is capped at 512 entries per kind — rollouts get deleted and renamed without telling us, so nothing else would ever remove their entries. Measured with a fresh process per open, which is what a restart is: a 40MB session goes from ~1200ms every time to ~220ms after the first, a 145MB one from ~1400-2750ms to ~400-540ms. The usage entry carries its own resume offset, so a restart resumes the scan instead of restarting it — readStale exists for that case: a stale entry is useless for a whole-file answer but is the entire point for a resumable one. Three tests spawn real subprocesses rather than clearing a module-level Map, since a cleared Map does not prove anything about a restart. Also fixes the suite writing cache entries into the developer's real ~/.local-studio: LOCAL_STUDIO_DATA_DIR is now pointed at a temp dir per test. Checked and rejected: skipping the walk for "linear" sessions. It is not close to a no-op — on the 40MB rollout it drops 3161 of 4562 entries, because real sessions compact and compaction prunes hard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chased the "64 events from a 500-event tail" flag from the last iteration. It is not a bug and not the active-branch filter — all 500 tail lines survive that filter, and the session really does have ~64 renderable entries on its branch. The census found the actual reason session opens scale with file size: 91-95% of a rollout is not transcript. On the 145MB session, 12142 transcript entries account for 6.9MB and 23816 inert custom/custom_message entries account for 138.5MB. The 40MB one is 91% inert. The inert bytes are attributable to two third-party pi extensions listed under `packages` in ~/.pi/agent/settings.json — npm:pi-goal (138.3MB across pi-goal-event and pi-goal, ~8.7KB and ~3.5KB per turn) and npm:@vanillagreen/pi-background-tasks (36.3MB, ~10KB per turn). Neither is Local Studio code; our own goal feature persists via goals-store and never writes to the rollout. They re-serialise their whole state on every turn. Nothing here can fix the writers, but it explains the floor the last three commits ran into: the remaining warm-open cost is the unavoidable scan of 40-145MB to find a few hundred messages, which matches the measured 213ms and 400ms exactly. readTailRegion already skips JSON.parse on inert lines, and seeking smarter does not help because renderable lines are interleaved throughout the file. Adds bench/rollout-census.bench.ts so any slow session can be diagnosed as "large transcript" or "polluted by an extension" rather than guessed at, and records the offset-index design plus its cursor-continuity risk as the only remaining reader win. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paging had one assertion in the whole suite — a single tail: 100 call. The cursor is a raw byte offset into the rollout, so getting it wrong does not throw; it silently drops or duplicates a stretch of someone's conversation. The next change here reads the transcript from a different file entirely, so the properties it must preserve need to be written down first. Pins: a short tail leaves a cursor and puts the newest turn on the first page; a long tail ends paging; pages tile the transcript exactly once and in order, so concatenating them oldest-first rebuilds the log verbatim; paging terminates on a rollout padded with inert entries, the shape from the census where the scan crosses long stretches containing no message; inert entries never reach the transcript; cursors decrease strictly. Two traps documented in the fixture for whoever writes the next one. Rollouts have to be built through SessionManager — entries are a parentId tree, and hand-written JSONL has no valid chain, so the active-branch filter correctly discards all of it and the tests "fail" against perfectly good code. And a fixture small enough to run fast finishes in about two pages, since the backward scan reads in 8MB chunks, so the assertion is on cursor ordering rather than a page count. No production change. Ledger records the sidecar design this net exists for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91-95% of a rollout is custom/custom_message entries written by pi extensions on every turn, inert to the transcript and thrown away on every read. Paging therefore meant scanning 40-145MB to find a few hundred messages, and indexing offsets into the original does not help — the messages are interleaved throughout, so the span from the 500th-last one to EOF is still most of the file. Keep a second copy without the noise. transcript-sidecar.ts writes the non-inert lines to a plain .jsonl under rollout-cache/transcript/. Keeping the format identical is the point: readTailRegion runs over it unchanged and cursors stay opaque byte offsets, just into a file 20x smaller. Both files are append-only, so the sidecar is extended rather than rebuilt and a cursor handed out for an earlier page stays valid. Restart open: 213ms -> 28ms on a 40MB session (3.6MB sidecar), 396-540ms -> 61ms on a 145MB one (7.2MB sidecar). Event counts are identical before and after, 64 and 12142, which is the check that matters when you swap the file a transcript is read from. The sidecar is an optimisation, never a dependency: every failure path returns the original rollout, which reads identically and only costs time. One test occupies the sidecar directory's name with a regular file to prove it. Sidecars are evicted like the envelopes — they live outside writeEnvelope, so without this they would accumulate one file per session ever opened at ~5% of each rollout's size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntries The timeline stitches each turn's assistant segments into one bubble on every streamed frame and caches the result so a settled turn keeps its object identity. Without that identity, MemoMessage sees a new object and React re-renders the entire transcript for every token. The cache was capped at 512 entries and cleared wholesale when full, which inverted its purpose on any conversation with more runs than the cap: each frame missed on entries it had just evicted. Measured turns re-rendered per streamed token — 600 turns: 600. 1000 turns: 1000. 2000 turns: 2000. Under the cap it was correctly 1. An LRU bound is no better; measured at 600 turns it still rebuilds 600, because a sequential walk longer than the cache evicts exactly the entries it is about to ask for. The bound was the bug. The cache is now scoped to the transcript: entries leave when their run leaves it, never because a counter filled. Rebuilt turns per frame is 1 at every size tested up to 2000. The derivation's own cost improved too (2000 turns: 1.03 -> 0.42ms per frame) but that is the small part — the cost was the React re-renders it forced. Pure logic extracted to visible-messages.ts so the test and the bench exercise what ships instead of a copy. The regression is invisible to any rendering assertion — it only makes things slower — so it is pinned directly on identity stability across frames. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… blocked
Standing up a runtime + frontend against a synthetic long session, so anything
can be measured in a real browser, cost most of an iteration and none of it is
discoverable from the code. Written down with the three traps that each fail
silently:
- WORKSPACE_ROOTS is enforced by the runtime and the frontend independently,
and GET /api/agent/sessions/:id answers {events: []} rather than an error when
a path is outside the roots — so a rejected request reads as an empty session.
- The roots must include the real home dir, not just the scratch dir: the
sidebar queries a "Chats" pseudo-project at ~/.local-studio.
- `bun --cwd X run src/server.ts` resolves as a package script and prints the
script list instead of starting the server.
Still blocked with all of that green: the runtime serves 501 events, the
frontend proxies them, the project is registered and selected — but the sidebar
never lists the session, so the timeline never mounts and there is no browser
measurement this round. GET /api/agent/sessions?cwd=… returns it correctly, so
the gap sits between that response and the sidebar. Whether that is
harness-specific or a real bug in listing sessions for a freshly added project
is the next thing to find out.
Also noted, so it is not later mistaken for a storm: 7 identical
GET /api/agent/runtime/sessions land within 6ms at mount. Steady state is
correct at one per 5s.
No production change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion The sidebar gap recorded last round was not a bug. session-rows.tsx renders ProjectSessions only when the project row is expanded; the click that looked like it should expand had selected the project instead. Corrected in the ledger so nobody hunts it. With that unblocked, the first real browser measurements on an 800-turn session: Opening paints 250 merged bubbles from a 500-event tail — 4,516 DOM nodes, ~18 per message. Scrolling never becomes the problem: at 1,250 messages and 21,016 nodes across a 317,567px scroll height, a scroll jump still costs 6-9ms, inside a frame budget. "Load earlier" is the cost. Each page adds a constant 250 messages, but the click goes 635ms -> 1001ms -> 1891ms as the transcript grows. The fetch behind those same pages took 14-40ms for 303KB, so ~95%+ of that latency is client side — fold, merge, and React reconciling a list that keeps growing — not the server. The six server-side findings did their job; what remains is render. That narrows virtualization considerably: it is NOT justified by scroll jank, because there is none. If it is justified at all it is to bound the mount and reconcile cost of loading earlier history, which means bounding mounted subtrees rather than the scroll container. The ledger had been carrying a much broader claim. Caveats recorded with the numbers: the synthetic transcript is plain text with no tool blocks or diffs, so node counts are a floor; and requestAnimationFrame never ticks while the browser pane is hidden, so rAF-based frame timing hangs silently. No production change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reloading a session showed a truncated transcript with no way back to the rest of it. Measured on an 800-turn session: with the cache present a reload painted 100 messages, no "Load earlier", and zero replay fetches; with the cache cleared it painted 250, showed the affordance, and fetched once. One clause causes it. chat-pane-hooks skips the canonical replay for a session that "already has messages", but loadInitialFromStorage seeds messages from the localStorage snapshot before that runs. The snapshot is a deliberately lossy placeholder — capped at 200 messages and 512KB, carrying no history cursor — so it satisfied the guard, the replay never ran, and the session was stuck showing whatever fit in the cache. Navigating away and back was the only way out. Nothing about it fails loudly; the transcript is just quietly short. Mark snapshot-seeded messages hydratedFromCache so the guard can tell a placeholder from the real transcript, and clear the flag once a replay lands. The cache keeps its purpose — the reloaded session still paints in 2ms — and the replay now runs behind it: 250 messages, affordance present, one fetch. Tests pin the marking rather than the symptom, since the symptom is silent. Found while trying to attribute the 635-1891ms "load earlier" click, which remains unattributed and is recorded as such. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ruled the candidates out one at a time rather than reasoning about them. The fetch is 14-40ms, the fold ~1ms, the merge 0.42ms, the snapshot write 0.2ms, and a pure state-change re-render at 1250 messages produces zero long tasks — so reconciliation of the existing list is not the cost either. What is left is mounting: ~3.5ms per newly mounted message. A page is 250 messages, so the click is ~0.9s of main-thread JS. The content-visibility A/B is what makes that solid. It skips layout and paint for offscreen subtrees but cannot skip React mounting or markdown parsing, so a per-message cost that barely moves (3.59ms -> 3.42ms across 250-mounted and 100-mounted runs) says the work is JS. It also rules out CSS containment as the fix, which was the cheap thing worth trying first. Corrects an earlier entry in this ledger: the cost does NOT grow with transcript length. Per-message cost is flat; the apparent growth was long-task chunking and a busier browser. What is constant is the page size. Leaves two candidate fixes recorded and unattempted: mount fewer messages per page (a UX trade), or make a message cheaper to mount — 3.5ms is a lot for mostly-plain text, and assistant-markdown runs ReactMarkdown + remark-gfm on every message. No production change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The obvious next move was a fast path in assistant-markdown: skip ReactMarkdown for messages that contain no markdown. Measured it first. The whole markdown pipeline is 0.288ms for plain text and 0.687ms for marked-up text, against a 3.5ms per-message mount cost. A plain-text fast path would save 0.28ms — 8% — and only on messages that happen to be plain. Not worth it: misclassifying a marked-up message renders its syntax as literal text, a visible regression traded for nothing. The benchmark measures marked-up text as well as plain on purpose. Measuring only plain text — which is all the synthetic transcript in the harness contains — would have made the fast path look several times better than it is, because the case it helps is the case the fixture over-represents. So the 3.5ms is the aggregate message subtree, not a hotspot inside it. There is nothing single to make cheaper. Which finally justifies virtualization on a number: at ~3.5ms per mounted message, a 250-message page costs ~0.9s, and that is paid on the initial session open too, not just "load earlier". Against a server that now answers in 30-60ms, mounting is the dominant cost of opening a session. Worth noting the justification differs from the one this ledger carried for most of the pass. It is not scroll jank — finding 9 measured scrolling at 6-9ms and found none. It is never mounting 250 subtrees at once. No production change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Last round extrapolated 3.5ms x 250 messages and concluded a cold session open spends ~0.9s mounting. Measured it directly with buffered longtask entries instead: two cold opens with the snapshot cleared came in at 204ms and 230ms total blocking, fetch included. So mounting into an empty list is ~0.9ms per message, not 3.5ms. The 3.5ms figure came from prepending 250 messages into a list already holding 1000+. Both are real and they measure different things: mounting is cheap, prepending into a long keyed list is what costs. That also explains finding 11's other result — re-rendering the same list in place produced zero long tasks, because a prepend shifts every key while an in-place render lets every memo bail. Which removes the case for virtualizing. Cold open 220ms, scrolling 6-9ms at 1250 messages, and the only slow path is repeated "load earlier" at ~0.9s — an explicit action with a pending state, on a transcript already ~1000 deep. Weighed against putting stick-to-bottom, scroll restore, the ResizeObserver pin and native scroll anchoring at risk, each documented as fixing a specific bug, it is not worth it. Halving the page size is the cheap lever if it ever is. Also recorded: the first attempt at this read 10.2s, because performance.now() counts from navigation while the polling only began when the probe ran. It measured when the probe looked, not when the work happened. No production change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… fills The transcript cache's own ceiling is 24 sessions x 512KB, about 12.6MB, against the ~5MB most browsers give an origin. On overflow it removed every other session's entry and retried. Driving 24 writes into a 5MB quota: text-only transcripts (~98KB each) never overflow, but tool-heavy ones (~506KB each) went 1..10, collapsed to 1, climbed to 10, collapsed again. So with tool-heavy sessions every cached transcript was lost roughly every tenth write, and the cache that exists to make reopening instant was empty exactly when the most sessions were open. Nothing visible — sessions just stop reopening fast. Drop the least-recently-updated entries one at a time until the write fits instead. Same 24 writes now hold steady at 10 cached sessions with zero mass evictions, ending at 10 rather than 4. Three tests pin it, including that an entry too large to ever fit is dropped rather than thrown; the cache must never surface as an error. The measurement error is worth recording too. The first version of the harness hardcoded length: 0 on its fake storage, and cacheKeys walks 0..length-1 — so every eviction path silently no-opped and the run reported the exact shape of a healthy cache. It read as proof there was no problem. length has to be a live getter, and the test says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifteen session switches across eight sessions, measured with performance.memory. Heap climbs while bounded caches fill — 12.7-22.5MB on the first pass, 23.9-29.4MB on the second — then plateaus: the third pass moves 1MB across five switches against 12MB over the first ten. DOM node count never moves at 2058, because only the active pane's session is mounted and pruneSessions drops the one it replaced. That is caches warming, not a leak. Three passes would not catch a slow one, but nothing here scales with the number of sessions visited. Nothing to fix. Closes the pass with a summary of what landed and, more usefully, what was checked and rejected so nobody rebuilds it: timeline virtualization (cold open is 220ms, scrolling 6-9ms), a markdown fast path (the pipeline is under 0.69ms of a 3.5ms mount), skipping the active-branch walk for "linear" sessions (it drops 69% of entries on a real rollout), and LRU for the merge cache (a sequential walk longer than the cache evicts exactly what it needs next). Session opens went from ~1.2s per process, and ~32s for the 3.56GB rollout, to ~30-60ms server-side and ~220ms cold in the browser. The largest remaining lever is not in this repo: two pi extensions write 91-95% of the bytes in these rollouts. No production change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bridge was a second, uncached session API: its own rollout discovery, header reads, pagination, cursors and event translation, sharing exactly one function with sessions-store. It was never wired to anything in this repo and never carried load — the on-disk idempotency ledger holds three agent turns, all from 2026-07-20, and nothing since. Litter reaches the same sessions another way. Its pi bridge reads ~/.pi/agent/sessions/ directly: 1,403 threads, 82 of them in the Local Studio workspace, 19 of those active after the gateway's last request. Watching the live daemon for five minutes showed zero loopback connections — every socket went to a remote relay on :443, and every connection to the agent-runtime port had Local Studio on both ends. promptDurably and persistLitterPromptBoundary go with it. They existed only so the bridge could correlate a mobile dispatch with a transcript entry, and nothing else ever read the local_studio_litter_turn_v1 marker they wrote. -7,366 lines. KittyLitter QR pairing is untouched — it shells out to the kittylitter CLI and never involved this endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ported the structure from the ChatGPT desktop bundle's plugins page: a section is a header plus a bounded card, rows live inside that card, and the hairline between rows is inset from the border rather than full-bleed. A `divide-y` that runs into the card edge reads as a spreadsheet; the inset makes each row read as its own object, which is the whole difference in feel. Rows become flex instead of a two-column grid. The old grid pinned every label to a fixed 180px/260px track, so short labels left a dead gap and the value cell floated in the middle of nowhere. Now the label takes the space it needs and trailing content — value, status, actions — sits against the right edge. Progressive disclosure: expanded content renders in a nested rounded panel instead of hanging off a hardcoded left margin, and interactive rows with children get a chevron that turns. `expanded` is parent-owned and optional, so existing callers are unaffected. Kept our type ramp rather than Codex's absolute sizes — the structure is what was wrong, not the density. Also fixes the recipe editor's backdrop, which still used the opaque `--color-background` and made the app appear to vanish behind the drawer. Same bug already fixed in ui/modal.tsx; this overlay was hand-rolled and missed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mic button in the composer was broken on this deployment, and had been since it shipped. It records audio and POSTs to the controller's /v1/audio/transcriptions with a `file` and a `mode` and no `model`; the controller falls back to LOCAL_STUDIO_STT_MODEL, which was never set, and throws model_missing. `mode: best_effort` does not save it — mode only feeds the service lease, which is checked after model resolution. So every press recorded the microphone and then failed with a 400. Dictation belongs on the machine the microphone is attached to anyway. A laptop has an accelerator, these models are a few hundred MB, and it removes a round trip to a GPU box over the tailnet for two seconds of speech. Engines are probed in order — parakeet-cli, whisper-cli, mlx_whisper — and the first usable one wins, because which of these a machine has is not something the app can know. parakeet and whisper.cpp need a model path (LOCAL_STUDIO_PARAKEET_MODEL / LOCAL_STUDIO_WHISPER_MODEL); mlx_whisper fetches and caches its own, which makes it the dependable last resort. Everything goes through ffmpeg to 16kHz mono first: browsers hand us webm/opus or mp4 and none of these engines read either. Verified end to end on an 11s webm recording — resolved mlx-whisper, returned the correct transcript in 9.6s including model load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Installed 2026-07-10, reports ready, 9.5GB on disk — a 5.7GB venv plus 3.8GB of weights — and it has synthesized nothing. The voices, uploads and outputs directories are all still empty, the worker is stopped, and voice_count is 0. Chatterbox cannot speak without a voice profile and none was ever recorded. modules/speech was the largest module in the controller (3,376 lines) for a feature with no recorded use. Going with it: the audio routes, the stt and tts services, the speech contract, the desktop plugin bundle, the chatterbox voice UI, and the speech API client. The plugin runtime's hostCapability mechanism goes too — it was hardcoded to the chatterbox-voice plugin id. Two things fell out as dead once speech left, both found by knip rather than by me: nvidia-compute-processes.ts, and boundedFormData, whose only caller was multipart audio upload. The GPU lease registry also went. It had two owners, "llm" and "speech"; the llm owner only validated against the instance record it already had, so the speech worker's pinned hold was the entire reason it existed. What survives is recipe GPU-visibility resolution, which is what compute/bridge.ts actually uses, so gpu-leases.ts becomes gpu-visibility.ts. Speech-to-text is unaffected — it moved to the local machine one commit ago. The 9.5GB on the controller is untouched; deleting it is a separate call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two /v1/audio routes they sized are gone with chatterbox. Caught by a grep sweep rather than by a gate — nothing type-checks a route string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0xSero
added a commit
that referenced
this pull request
Aug 13, 2026
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.
Three separate pieces of work, each landed as its own commit so they can be read — or reverted — independently.
1. Session performance (16 commits,
0960519d…402a1a94)Opening a session went from ~1.2s per process — and ~32s for a 3.56GB rollout — to ~30–60ms server-side, ~220ms cold in the browser.
Six measured optimizations, each with a before/after number recorded in
docs/session-performance.md:Two correctness bugs surfaced along the way and are fixed here, neither of which was a performance problem:
496a7841)3f9877b7)Four optimizations were rejected on evidence and are documented so nobody rebuilds them: timeline virtualization (a cold open is 220ms and scrolling 6–9ms), a markdown fast path (≤0.69ms of a 3.5ms mount), skipping the branch walk for "linear" sessions (drops 69% of entries on a real rollout), and LRU for the merge cache (evicts exactly what it needs next).
2. Remove the litter-bridge gateway (
00f62e66)−7,913 lines. The bridge was a second, uncached session API — its own rollout discovery, header reads, pagination, cursors and event translation, sharing exactly one function with
sessions-store.It never carried load. The on-disk idempotency ledger holds three agent turns, all from 2026-07-20, and nothing since. Litter reaches the same sessions another way: its pi bridge reads
~/.pi/agent/sessions/directly — 1,403 threads, 82 in the Local Studio workspace, 19 of those active after the gateway's last request. Watching the live daemon showed zero loopback connections; every socket went to a remote relay on :443.promptDurably/persistLitterPromptBoundarygo with it — they existed only so the bridge could correlate a mobile dispatch with a transcript entry, and nothing else read the marker they wrote. KittyLitter QR pairing is untouched; it shells out to the CLI and never involved this endpoint.3. Models page on the Codex plugins pattern (
2b07d9fb, and09835a84for the studio chrome)Ported from the ChatGPT desktop bundle's plugins page: a section is a header plus a bounded card, rows live inside it, and the divider between rows is inset from the border rather than full-bleed.
Rows became flex instead of a two-column grid — the old grid pinned every label to a fixed 180px/260px track, which is what opened the dead gap mid-row. Expanded content now renders in a nested panel rather than hanging off a hardcoded left margin.
Our type ramp is deliberately kept rather than adopting Codex's absolute sizes; the structure was the problem, not the density.
Also fixes two overlays that used an opaque
--color-backgroundbackdrop and made the app appear to vanish behind them.Verification
npm run checkis green at every commit — typecheck, lint, cycles, structure gates, knip, jscpd, depcheck, controller standards, all package test suites, and a full production build.Not visually verified: the models redesign passes typecheck, lint and build, but has not been looked at rendered.