Skip to content

Session performance, litter-bridge removal, and the models page redesign - #403

Open
0xSero wants to merge 33 commits into
mainfrom
perf/session-performance-and-cleanup
Open

Session performance, litter-bridge removal, and the models page redesign#403
0xSero wants to merge 33 commits into
mainfrom
perf/session-performance-and-cleanup

Conversation

@0xSero

@0xSero 0xSero commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Three separate pieces of work, each landed as its own commit so they can be read — or reverted — independently.

1. Session performance (16 commits, 0960519d402a1a94)

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:

change effect
fold canonical replay in place 113ms → 78ms at 1600 messages
cache the active-branch walk per rollout removes a full re-walk per open
resume the usage scan instead of restarting O(file) → O(new bytes)
persist both caches across restarts first open after a restart is warm
page the transcript from a de-noised sidecar 145MB → 7.2MB read per session
scope the timeline merge cache to the transcript stops eviction thrash on long sessions

Two correctness bugs surfaced along the way and are fixed here, neither of which was a performance problem:

  • Reloading a session silently truncated it, with no way to recover the rest (496a7841)
  • The snapshot cache emptied itself whenever localStorage filled, wiping every other session to store one (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 / persistLitterPromptBoundary go 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, and 09835a84 for 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-background backdrop and made the app appear to vanish behind them.

Verification

npm run check is 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.

0xSero and others added 30 commits August 3, 2026 17:15
* 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>
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