Repository navigation
Ask the host less and redraw less: one-channel polls, stable rows, instant reopen - #174
Merged
Merged
Conversation
This was referenced Oct 16, 2026
9 of 10 tasks
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
force-pushed
the
hc-45-agent-support
branch
from
October 16, 2026 15:35
30f030a to
9002aac
Compare
woosal1337
force-pushed
the
hc-46-speed
branch
from
October 16, 2026 15:35
7c99a05 to
1e949bd
Compare
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
force-pushed
the
hc-46-speed
branch
from
October 16, 2026 16:52
1e949bd to
06eaded
Compare
cobanov
requested changes
Oct 20, 2026
cobanov
left a comment
Owner
There was a problem hiding this comment.
Three things to fix before this goes in, each with a jest case that would have caught it:
- A tail can write the old session into the new one's cache. The tail loop's
batcherflush callsingestwithout checkingcurrent(), which the old loop did before every ingest.ingestonly comparessigwithboundSig, and that still holds the old value while the poll awaitsrebind. So an abort'sdrain(), or a pending batch timer, can append the old session's lines afterrebind's DELETE, andseedMessageshas 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. - Sharing the in-flight snapshot weakens the #76 guard.
snapshot()inclient.tsnow hands back a read already on its way, andpaneStateindeliveruses it to decide whether the second Enter is safe. On theunverifiedpath that can be a snapshot that started before the keys were sent: it saysidlewhen the agent has just goneblocked, and the Enter approves a tool call nobody saw. Send paths (paneState,currentPane) need a fresh read that skips the dedupe. - The small-gap catch-up puts a two-agent chat out of order. Each tail streams its own backlog and
appendMessagesfiles 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.
Merged
5 tasks done
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
Owner
|
Thanks @woosal1337. Since nothing had moved since the 20.10 review, we rebased the stack onto
|
cobanov
marked this pull request as ready for review
October 26, 2026 13:22
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)
3 of 4 tasks
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.
Summary
Rebased onto
mainat 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 onhc-45-agent-support. It retargets tomainonce #172 merges.Fewer round trips to the host
f143182An 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.1ccbaa7Fewer 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.3bb8d22One 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.b1aaac6Pictures 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.3407373A 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: BSDcutstopped at a cut inside an emoji.713b61fA 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.fb3eec9Workflow cards pause in the background, with every other poll.A more robust connection
8976644A refused channel no longer drops the connection. It only fails its own command. A host at OpenSSH'sMaxSessionsused to kill every tail and the event stream. Native, iOS and Android.06eadedA 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
5654f8dNo 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, throughprimaryPaneand the callbacks built on it.22f730aRows keep their message objects across rebuilds.Bubbleis 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.a2087edThe chat list keeps its rows when a poll sees nothing new (keepUnchanged).0a7f0edThe compiler now covers the two busiest components. The React Compiler skipped all ofThreadScreenandChatsForServer, each over onetry/catchit 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.91c2060Picture 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
0c91ca8A 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;rebindstill judges it once the host speaks.24abf52A 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.8c47e8fThe cache stays small and cheap to write. It keeps the newest 500 messages per chat, inserts a batch through one prepared statement, and runs withsynchronous = NORMALbeside WAL.5654f8d,9bd63d7A 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
npx tsc --noEmit: no errorsnpx expo lint: no errors, no warningsnpx jest: 2,007 passed, 14 skippedpython3 -m unittest discover -s scripts -p 'test_*.py': passesshin jest, with BSD tools on this Mac; CI runs them with GNU tools8976644compiles, through the iOS and Android jobs from Make dev builds open again, build native code in CI, run the UI suite nightly #173 once the branches meet. Not compiled here.npm run e2ein Dark and Light on the iPhone 17 Pro simulator (iOS 27), Release build of the rebased stack (Make every agent herdr detects visible: Grok and Antigravity chats, terminal rows for the rest, Codex fixes #172 to Close the remaining product gaps: one socket bridge, Antigravity dialogs, encrypted keys, accessibility #177): 52 of 52, the four flows5a4cf24fixes rerun on their own after it. Screenshots opened in both modes.Not in this PR
Three speed changes are not here: