Skip to content

Ask the host less and redraw less: one-channel polls, stable rows, instant reopen - #174

Merged
cobanov merged 19 commits into
mainfrom
hc-46-speed
Oct 26, 2026
Merged

cobanov merged 19 commits into
mainfrom
hc-46-speed

Conversation

@woosal1337

@woosal1337 woosal1337 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Rebased onto main at 2ce4a9a (#178), with no change apart from the rebase. Commit ids below are the rebased ones.

An open chat asks the host for a lot, and the phone redraws more than it shows. This branch cuts both, one cause per commit, each with a test that fails without it.

Stacked on #172. It changes useThread, which #172 changes too, so it is based on hc-45-agent-support. It retargets to main once #172 merges.

Fewer round trips to the host

  • f143182 An open chat's poll is one SSH channel. On a socket host, the snapshot, the screen the last poll read and any queued presence report now go together (HerdrSocket.callMany, HerdrClient.pollSnapshot). That was about 60 channels a minute while an agent worked: each a login shell and a Python process on the host. Presence (reportMetadata) is queued, and goes out alone only if no poll carries it within 5 s.
  • 1ccbaa7 Fewer wake-ups and duplicate reads. A host event about another workspace no longer wakes an open chat's poll. A snapshot already in flight is shared, as when the iPad's list and chat poll side by side, or a send re-reads the target.
  • 3bb8d22 One event reads one preview. A host event refreshes the preview of the pane it names. It used to force a sweep of every chat's transcript.
  • b1aaac6 Pictures stripped before the cut. The preview and header reads take the last megabyte, strip base64 pictures, then keep 48 KB and 256 KB. A chat ending on a screenshot showed no preview and no model, and a quarter-megabyte of picture crossed the link.
  • 3407373 A reopen reads 64 KB, not the whole prefix. The line-start lookup used to read everything before the cursor, 30 MB for a big transcript. The pipe now also runs in the C locale: BSD cut stopped at a cut inside an emoji.
  • 713b61f A small gap catches up through the tail. A reopened chat that grew by 64 KB or less streams just the new lines, and no longer re-reads a 300-line window. A bigger gap still gets the bounded window from Open chats at the latest messages without replaying old history #66.
  • fb3eec9 Workflow cards pause in the background, with every other poll.

A more robust connection

  • 8976644 A refused channel no longer drops the connection. It only fails its own command. A host at OpenSSH's MaxSessions used to kill every tail and the event stream. Native, iOS and Android.
  • 06eaded A dead socket is dropped in seconds. Back after a minute or more away, a 3 s probe (HerdrClient.wake) drops a connection that died while iOS held the app suspended. The first poll used to wait out its 15 s timeout on it.

Less rendering

  • 5654f8d No redraw on an unchanged poll. A poll that saw the same agents keeps its array, and the same question keeps its object. A fresh array every 2 s gave every visible thread row new props, through primaryPane and the callbacks built on it.
  • 22f730a Rows keep their message objects across rebuilds. Bubble is memoized on its message, and a reply's cut-down message was new on every transcript line. The commit also drops a redundant quadratic filter in the result matcher.
  • a2087ed The chat list keeps its rows when a poll sees nothing new (keepUnchanged).
  • 0a7f0ed The compiler now covers the two busiest components. The React Compiler skipped all of ThreadScreen and ChatsForServer, each over one try/catch it cannot lower. The blocks moved out of the components, and both compile; checked with the compiler's logger. A keystroke in the composer re-rendered every visible row before.
  • 91c2060 Picture bubbles keep their proportions. A picture bubble starts at its measured proportions once it has loaded before. Recycled rows used to start square and jump in height each time.

Faster to open, cheaper to keep

  • 0c91ca8 A chat opens from its cache at once. The list passes the session it saw (sig). When the cache is that session's, it shows before the first poll. A cache of another session is neither shown nor deleted; rebind still judges it once the host speaks.
  • 24abf52 A burst of tail lines is one write. It was a transaction, a cursor write and a rebuild per line; now one of each per 100 ms batch.
  • 8c47e8f The cache stays small and cheap to write. It keeps the newest 500 messages per chat, inserts a batch through one prepared statement, and runs with synchronous = NORMAL beside WAL.
  • 5654f8d, 9bd63d7 A command budget test (useThread.budget.test.ts) counts what an open chat asks per minute, against the Demo. The Demo has no socket bridge, so it measures the CLI path: 60 working, 30 idle, 2 idle with events. The one-channel poll is pinned at client level.

Test plan

Not in this PR

Three speed changes are not here:

woosal1337 added a commit that referenced this pull request Oct 16, 2026
A transcript line with a screenshot is about a megabyte, and the live tail
sent it whole: across SSH, through the native bridge, into JSON.parse, for a
picture the thread shows as a path. The preview and window reads already
strip pictures on the host (#174); the tail could not, because the cursor
counts the file's bytes and a stripped line is shorter.

The tail now runs through one awk in the C locale, which prints each line's
size in the file, a tab, and the line with its pictures stripped. In the C
locale `length` counts bytes, so the cursor stays in the file's UTF-8 bytes.
The picture pattern is the one the other reads use, spelt out rather than
written with `{128}`: older mawk, Debian's awk for years, has no interval
expressions, which is why this was held. `fflush` sends each line as it
lands. A line without the frame counts as it reads.

The store's tests run the real script under sh: a megabyte screenshot line
crosses as under 10 KB, and the cursor after emoji, a picture and a data URI
matches the file. CI runs it with Ubuntu's mawk. The Demo frames its tail
the same way.

(cherry picked from commit 79e18b7)
woosal1337 added a commit that referenced this pull request Oct 16, 2026
A transcript line with a screenshot is about a megabyte, and the live tail
sent it whole: across SSH, through the native bridge, into JSON.parse, for a
picture the thread shows as a path. The preview and window reads already
strip pictures on the host (#174); the tail could not, because the cursor
counts the file's bytes and a stripped line is shorter.

The tail now runs through one awk in the C locale, which prints each line's
size in the file, a tab, and the line with its pictures stripped. In the C
locale `length` counts bytes, so the cursor stays in the file's UTF-8 bytes.
The picture pattern is the one the other reads use, spelt out rather than
written with `{128}`: older mawk, Debian's awk for years, has no interval
expressions, which is why this was held. `fflush` sends each line as it
lands. A line without the frame counts as it reads.

The store's tests run the real script under sh: a megabyte screenshot line
crosses as under 10 KB, and the cursor after emoji, a picture and a data URI
matches the file. CI runs it with Ubuntu's mawk. The Demo frames its tail
the same way.

(cherry picked from commit 79e18b7)
@cobanov
cobanov changed the base branch from hc-45-agent-support to main October 20, 2026 08:03

@cobanov cobanov left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things to fix before this goes in, each with a jest case that would have caught it:

  1. A tail can write the old session into the new one's cache. The tail loop's batcher flush calls ingest without checking current(), which the old loop did before every ingest. ingest only compares sig with boundSig, and that still holds the old value while the poll awaits rebind. So an abort's drain(), or a pending batch timer, can append the old session's lines after rebind's DELETE, and seedMessages has no session filter: the recycled-workspace bug from CLAUDE.md. Returning early in the flush when !current() is enough; the cursor isn't advanced, so a restarted tail re-reads those lines.
  2. Sharing the in-flight snapshot weakens the #76 guard. snapshot() in client.ts now hands back a read already on its way, and paneState in deliver uses it to decide whether the second Enter is safe. On the unverified path that can be a snapshot that started before the keys were sent: it says idle when the agent has just gone blocked, and the Enter approves a tool call nobody saw. Send paths (paneState, currentPane) need a fresh read that skips the dedupe.
  3. The small-gap catch-up puts a two-agent chat out of order. Each tail streams its own backlog and appendMessages files it in arrival order; only the window read sorted by timestamp. A workspace with Claude and Codex that both answered while the phone was away comes back as all of one, then all of the other, and the cache keeps that order. Use the catch-up only when one source has a gap.

Also worth a look: a chat opened from the list's cache sets boundSig but leaves boundPanes empty, so it never unbinds when the agent was replaced in another pane that hasn't reported its id yet (#87 again, until the id arrives). And wake()'s 3 s deadline resets the client, so a slow but healthy link just after resume (Tailscale re-handshaking on cellular) loses its tails.

woosal1337 and others added 19 commits October 26, 2026 14:43
The poll set a freshly filtered agents array every two seconds while an
agent worked, even when nothing had changed. `sendTarget` returns an element
of that array, so `primaryPane` was a new object each time, and `send`,
`retry`, `sendKeys` and `interrupt` got new identities with it. The list's
`renderItem` uses `retry`, so FlashList's per-row check failed and every
visible row rendered again, every poll. A blocked prompt did the same: a new
object each poll for the same question.

A poll that saw the same agents, field for field, keeps the array it had
(`sameAgents`), and the same question keeps its object. A test pins the
identities across polls that see nothing new.

`useThread.budget.test.ts` counts what an open chat asks the host, per
minute, against the Demo: 60 commands while an agent works, 30 idle without
an event stream, 2 idle with one. The numbers the next changes are measured
against.

(cherry picked from commit 1d1a0c0)
The count was printed; now it is asserted, as upper bounds a change may only
lower. BUDGET_DEBUG=1 prints the commands for a closer look.

(cherry picked from commit 700e3cf)
Any `transport_failed` from a command reset the whole client, on iOS and on
Android. A refused channel is one: a host at OpenSSH's session limit
(`MaxSessions`, 10 by default) refuses a new channel on a link that is
fine, and the app holds several open at once (the event stream, one tail per
agent). The reset killed every one of them, so the chat stopped updating and
its tails restarted, and asked for more channels.

A failed command now drops the client only when the client has lost its
connection; a refused channel fails only its own command. The deadline still
drops the client, for the half-open socket it exists for.

Not compiled here: the native builds in CI (#173) are where this is checked.

(cherry picked from commit 1f10ce5)
The window read strips base64 pictures on the host before anything crosses
the network, but the chat list's preview batch (`tail -c 48000` per chat)
and the header's `sessionMeta` (`tail -c 262144` on every open) did not.
A transcript that ends on a screenshot, which Claude stores as base64 in a
tool result, filled the preview window with one picture line: the row showed
its status instead of the last reply, the header had no model, and up to a
quarter of a megabyte of picture crossed the link to say so.

Both now read the last megabyte, strip pictures with the window's own
`sed` pattern, and only then keep the bytes they need. A test runs the real
preview and header scripts under `sh` against a transcript ending on a
300 KB screenshot: the preview is the reply, and no picture data comes back.

(cherry picked from commit d297cb9)
Reopening a chat with a cached cursor asks the host where the line before
the cursor starts. It ran `head -c <cursor> | grep -b` over everything before
the cursor: on a 30 MB transcript, the whole file, on the host, each reopen.
It now seeks to the 64 KB before the cursor as the window read does, and
reads further back only when that chunk holds no newline at all (one line
longer than 64 KB).

The pipe also ran `cut` in the host's locale: `LC_ALL=C` was set for `grep`
alone. A read that starts or ends inside a character (an emoji, a Turkish
letter) made BSD `cut` stop at "Illegal byte sequence" and print a wrong
offset. The whole script now runs in the C locale.

The tests run the real script under `sh`, with a line longer than the chunk
and a cursor inside a run of two-byte characters.

(cherry picked from commit 03e498d)
The thread's poll ran on every host event, including the host-wide ones
about other workspaces: one created, renamed or closed, an agent found
anywhere. `concernsWorkspace` lets through only events about the chat's own
workspace, or ones that name none (a resync).

`HerdrClient.snapshot` now shares a read already on its way. On iPad the chat
list and the open chat poll side by side, and a send re-reads the snapshot a
poll may be reading at that moment; each asked the host on its own channel.

(cherry picked from commit efca59d)
Every host event forced the chat list into a full preview sweep: the end of
every chat's transcript read and sent, for news about one pane. With several
agents working, that was a sweep per status change.

An event now marks only the pane it names; the next refresh reads that
preview with the busy and moved ones it already reads. An event that names no
pane (a resync after the stream starts or lost events) still sweeps all.

(cherry picked from commit 2b34718)
While an agent works or waits for an answer, each poll read the snapshot and
then the agent's screen (the live preview, or the question), each on an SSH
channel of its own, and the presence report took a third every 20 s. On a
host with the socket bridge, a channel is also a login shell and a Python
process: about 60 a minute for one open chat.

`HerdrSocket.callMany` sends different requests in one batch, as `callEach`
sends one request many times, and `HerdrClient.pollSnapshot` uses it: the
snapshot, the screen the last poll read, and any queued presence, together.
A poll that turns out to need another screen still reads it on its own.
`reportMetadata` now queues: the next poll carries the report, and one that
no poll picks up within five seconds goes out alone, on the socket where
there is one. Hosts without the bridge (the CLI path) keep reading as
before.

Tests: one channel for the three requests, a queued report sent alone, and
the hook asking for last poll's screen with the next snapshot.

(cherry picked from commit c3c38da)
A running workflow's card re-read its run every three seconds whatever the
app was doing: in the background, or under a sheet, where every other poll
stops (`usePollGate`). It now pauses on the same gate and picks up again when
the screen is back.

(cherry picked from commit 42a86f5)
`threadItems` runs again for every transcript line, and it gave each reply
row a new `{ ...message, segments }` (its prose, cut from the thinking and
tool calls around it), and each user row with a tool result a new one too.
`Bubble` is memoized on its message, so every visible reply rendered again,
Markdown parse and all, once per line while an agent streamed.

A cut is now made once per message and cut, and handed back on every later
rebuild: a transcript's message objects never change once read, so a
WeakMap keyed on them holds the cuts and lets them go with the messages.

The result matcher also filtered its list of unmatched calls on every
result, a quadratic step: a call with an id was never in that list, and one
without was just taken off its front, so the filter removed nothing.

(cherry picked from commit 5b6d4ea)
…build

An agent writes its transcript in bursts: a tool call, its result, the
reply. Each line the live tail read was its own SQLite transaction, its own
cursor upsert (written even for a line that held no message) and a rebuild
of the whole thread list, on the JS thread while the reader scrolled.

Lines now gather for 100 ms (`batcher`, in src/lib) and go together: the
messages in one transaction, then the cursor at the last line's end, then
one rebuild. Messages still land before the cursor, so a crash in between
re-reads lines it already has and deduplicates them, as before. A write that
fails still ends the stream, which restarts it.

(cherry picked from commit b00fd36)
Every poll, three seconds apart without an event stream, built every row's
summary anew and set a new pane id list, so the list and every row in it
rendered again with nothing changed, and the equality check that publishes
another host's feed (useHostChats) could never match.

`keepUnchanged` hands back last poll's object for every row that is the same
as JSON, and last poll's list when no row changed; the pane ids keep their
array the same way. A test pins the list's identity across polls that see
the same host.

(cherry picked from commit a69428e)
The messages table only grew: every line of every session was kept for as
long as the chat was open, while a reopen reads the newest 150. An append
now deletes what falls more than 500 behind the newest, in the same
transaction.

The rows of a batch go through one prepared insert instead of a fresh
statement each, and the database runs `synchronous = NORMAL`, the usual
partner of WAL: a commit stops waiting on a disk flush, and a crash still
leaves a consistent database, which for a cache of the host is the right
trade.

A test appends 700 messages to a real SQLite (node:sqlite) and finds the
newest 500, and the newest one first in line for a reopen.

(cherry picked from commit 1c9154e)
The compiler is on for the app, and two of its busiest components never got
it. ThreadScreen and ChatsForServer each held one try/catch the compiler
cannot lower (a `finally`, then a conditional inside the try), and the
compiler skips a whole component over one such statement. So the thread's
inline `renderItem` and everything else in that component was rebuilt on
every render of the screen, a keystroke in the composer among them, and
every visible row rendered again with it.

Each try/catch moves to a small function outside the component
(`gatherAttachments`, `quietly`), with the same handling, and both
components compile now. Checked by running babel-plugin-react-compiler over
the files with its logger. Still skipped, and rarely rendered: Header, three
Settings sections, the folder picker and the host editor.

Behaviour is meant to be unchanged, and the gates pass; the UI suite on a
simulator is the check that matters for a change like this.

(cherry picked from commit 9bab2cb)
A picture bubble started square and took its proportions when the picture
loaded. The thread list recycles rows, so each time a picture scrolled back
into view it started square again, jumped to its height, and the list
measured the row again under the reader's thumb.

Each picture's proportions are kept by its uri once it has loaded, and a
bubble for it starts there. The first load of a picture still settles once.

(cherry picked from commit c8218a7)
Opening a chat showed nothing until a host round-trip came back: the cached
messages were read only once the first snapshot had named the session, and a
cold open then took the snapshot, a file probe and the window in a row.

The chat list knows each chat's session from its last poll. `openChat` now
passes it (as `sig`, through the route and the iPad selection), and the
thread shows the cached messages at once when they are stamped with that
session (`seedIfSession`), bound to it, so the first poll goes straight to
the tail with no loading state in between. The rule against a recycled workspace flashing another chat holds: a
cache of another session is not shown and not deleted here, only judged by
`rebind` once the host has named the session, and a host that names a
different one by the first poll is a rotation, handled as before.

A workspace that mixes in an agent the app cannot read gets a list
signature that differs from the thread's, so it opens as before. Tests:
the early open, the wait on another session's cache, and the read-only seed
against a real SQLite.

(cherry picked from commit b929c1c)
A chat reopened after a reply or two re-read its 300-line window from the
host and replaced its cache, as it did for an 11 MB backlog. The bounded
window is right for a big gap (#66: replaying one line by line took
minutes), but for a few kilobytes it re-sent the whole recent conversation
to add a message.

When the transcript grew by 64 KB or less past the stored cursor, the tail
now streams just those lines from the cursor (batched since the last
change), onto the cache that is already on screen. A bigger gap, a file that
shrank, or a chat with nothing cached still reads one window, as before.

(cherry picked from commit bc9e457)
iOS suspends the app in the background, and the iOS SSH layer sends no
keepalive, so a connection the network dropped meanwhile looks open on
return. The first poll then waited out its 15 s timeout on the dead socket,
plus the JS grace after it, before anything redialled: a blank list or a
frozen chat for up to 20 s after picking the phone up.

Back in the foreground after a minute or more, the chat list now has the
client `wake`: one `true` on a 3 s deadline. A dead connection times out
there, the native layer drops it on that timeout, and the polls racing it
fail fast and redial.

(cherry picked from commit e6d8dee)
…onest

Three fixes from the review of #174, each with the test that would have
caught it:

- The tail's batch flush returns when the tail is no longer current. An
  abort's drain, or a pending batch timer, ran while the poll awaited
  `rebind`, when `ingest` still saw the old session bound, and appended the
  old session's lines to the new one's cache. The cursor is not advanced, so
  a restarted tail reads those lines again.
- `HerdrClient.snapshot({ fresh: true })` skips the shared in-flight read,
  and the send paths (`paneState`, `currentPane`) use it. A poll's snapshot
  that started before the keys went out could say idle when the agent had
  just gone blocked, and the second Enter approved a tool call (#76).
- The small-gap catch-up is for one source with news only. Each tail files
  its backlog in arrival order and only the window read sorts by time, so a
  Claude and Codex chat that both answered while the phone was away came
  back as all of one, then all of the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nGz6RQQsBfLJAGByzb1Bn
@cobanov

cobanov commented Oct 26, 2026

Copy link
Copy Markdown
Owner

Thanks @woosal1337. Since nothing had moved since the 20.10 review, we rebased the stack onto main ourselves and made the fixes it asked for. Here, in f50caab, each with a jest test that fails without it:

  • the tail's flush checks current(), so an old session's lines never land in the new one's cache;
  • the send paths (paneState, currentPane) read fresh (snapshot({ fresh: true })) instead of sharing a snapshot already in flight;
  • the small catch-up runs only when one source has new lines; with two agents, it reads a window and orders by time.

@cobanov
cobanov marked this pull request as ready for review October 26, 2026 13:22
@cobanov
cobanov merged commit 09897c9 into main Oct 26, 2026
3 checks passed
@cobanov
cobanov deleted the hc-46-speed branch October 26, 2026 13:22
@cobanov
cobanov restored the hc-46-speed branch October 26, 2026 13:24
@cobanov
cobanov deleted the hc-46-speed branch October 26, 2026 13:28
lnguyen pushed a commit to lnguyen/herdrchat that referenced this pull request Oct 26, 2026
A transcript line with a screenshot is about a megabyte, and the live tail
sent it whole: across SSH, through the native bridge, into JSON.parse, for a
picture the thread shows as a path. The preview and window reads already
strip pictures on the host (cobanov#174); the tail could not, because the cursor
counts the file's bytes and a stripped line is shorter.

The tail now runs through one awk in the C locale, which prints each line's
size in the file, a tab, and the line with its pictures stripped. In the C
locale `length` counts bytes, so the cursor stays in the file's UTF-8 bytes.
The picture pattern is the one the other reads use, spelt out rather than
written with `{128}`: older mawk, Debian's awk for years, has no interval
expressions, which is why this was held. `fflush` sends each line as it
lands. A line without the frame counts as it reads.

The store's tests run the real script under sh: a megabyte screenshot line
crosses as under 10 KB, and the cursor after emoji, a picture and a data URI
matches the file. CI runs it with Ubuntu's mawk. The Demo frames its tail
the same way.

(cherry picked from commit 79e18b7)
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.

2 participants