fix(herd): the sidebar crash, one click to open, and herd opens the UI - #545
Merged
Merged
Conversation
Four things Anthony reported about `moshcode herd`.
THE CRASH. "it crashed when i click on agent in sidebar". Three causes,
all in the click path:
1. `paneIndex()` in src/herd.mjs read `dead.trim()` on a list-panes line
that may have fewer fields than the format string asked for. Every
click runs it through showMember(). Reproduced with a stand-in tmux:
TypeError: Cannot read properties of undefined (reading 'trim')
at paneIndex (src/herd.mjs:604)
at showMember (src/herd-workspace.mjs:140)
2. The stdin handler was `async` with no try/catch, so anything thrown
under it became an unhandled promise rejection, and Node ends the
process on one. There is no unhandledRejection handler anywhere in
src/ or bin/, so nothing caught it.
3. The pin that stops a member renaming its own pane is a WINDOW option,
and the whole trick this file is built on is moving panes between
windows, so the pin never travelled. claude and a login shell both
emit OSC 2 the moment they arrive: the member vanished from
paneIndex, and the next click tried to park it under a session name
containing ":" and ".", which tmux refuses. The pane could not leave,
the next one joined in anyway, and the window ended up with two
content panes and a sidebar squeezed to nothing. Reproduced live, and
it is what "it crashed" looks like on screen.
The restore path made all of this worse: it wrote escape sequences and
never lifted raw mode, so a death here left the pane with no echo, no
cursor and the mouse still captured. Escape sequences are undone by the
next full-screen program; a terminal with no echo is not.
Fixed: paneIndex reads the field defensively, the handler body is
guarded and paints the reason on the sidebar instead of dying, the
workspace window pins titles, parkPane falls back to a slug so a renamed
pane can always leave, and one idempotent restore puts back raw mode as
well as the escapes, wired to exit, SIGINT, SIGTERM and SIGHUP.
ONE CLICK. A click on a member now opens it: shown AND given the
keyboard. It used to browse on the first click and open on a second
click of the same row, which is the double-click affordance Anthony
rejected in diskpush 0.7.0. Hover does the browsing that argument was
about: 1003 motion reporting lights the row under the pointer. The same
change in the plain list, which carried the same idiom. Enter still
works, and the sidebar's own s/a/x keys now reach the handler at all,
which they never did: the shared parser only ever emitted the list's
keys. Clicking an action no longer overwrites the member selection,
which is why "stop" could never stop anything.
BARE `herd` OPENS THE UI. Someone typing the noun with no verb wants to
see the herd. `--json`, a pipe, a non-tty and an injected writer all
keep the roster, because a full-screen UI must never be launched into
something that is collecting lines. The pit closes readline first, like
it does for /attach, or tmux and readline fight over every keystroke.
CONFIDENCE. PRD 0019 gave every state a confidence, and a sidebar that
draws a guess and a report identically puts back the lie the heartbeat
exists to stop. Inferred states carry the roster's "?".
More worked examples in `herd help`, covering the UI, tiling, the bar,
cost, swarm and fleet, hooks, tasks, remote agents, restore after a
reboot, and driving an agent without attaching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ThreatCrush Security Scan8 finding(s) in the 9 file(s) this pull request changes. MEDIUM: 8
96 pre-existing finding(s) elsewhere in the repository — **HIGH/CRITICAL**: 8 | **MEDIUM**: 77 | **LOW**: 11Not introduced by this pull request. The full set is in the Security tab.
…and 76 more. Full results in the Security tab. Snippets are redacted; ThreatCrush never prints matched credential material. |
This was referenced Sep 25, 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.
Four things Anthony reported about
moshcode herd, split off from the hqtui port so the crash fix is not held hostage by it.(a) The crash
Three causes, all in the click path, all verified rather than assumed.
1.
paneIndex()throws on a short line.src/herd.mjsreaddead.trim()on alist-panesline that may have fewer fields than the format string asked for.showMember()runs it on every click. Reproduced with a stand-in tmux:2. Nothing caught it. The stdin listener was
stdin.on("data", async (buf) => {...})with no try/catch, so a throw from any of the half-dozen tmux shell-outs under a click became an unhandled promise rejection, and Node ends the process on one. There is nounhandledRejectionoruncaughtExceptionhandler anywhere insrc/orbin/, confirmed by grep.3. Pane identity collapses inside the workspace. This is the one that bites in practice.
startSessionpins a member's pane title withallow-set-title off, but that option is per window, and the whole trick this file is built on isjoin-pane-ing panes out of their own window into the workspace. The pin does not travel. So the first thing claude or a login shell does after arriving is emit OSC 2 and rename its own pane:From that moment the member is invisible to
paneIndex(which keys on the title), and the next click tries to park it under a session name containing:and., which tmux reads as target separators and refuses. The pane cannot leave, the next one joins in anyway, and you get two content panes and a sidebar squeezed to nothing. That is what "it crashed" looks like on screen. Reproduced live and fixed; the same run now reads:And the restore path made every one of these destructive.
restore()was escape sequences only: it never lifted raw mode. Escape sequences are undone by the next full-screen program to run; a terminal left with no echo, no cursor and the mouse captured is not.What changed
paneIndex()reads the trailing field defensively. One short line out of tmux is a cosmetic problem; it must never be a fatal one.-tnaming it starts answering "can't find pane".setIntervalcallback is not inside the awaited promise at all and no downstream catch can see it.pinTitles()setsallow-set-title offon the workspace window (a no-op on tmux 3.4, which is what Ubuntu 24.04 ships, soparkPanecovers that case rather than relying on it).parkPane()falls back to a slug when the title is not a legal session name, so a renamed pane can always leave.restore()puts back raw mode as well as the escapes, wired toexit,SIGINT,SIGTERMandSIGHUP, and removed again on a clean return.(b) One click, not two
A click on a member now opens it: shown and given the keyboard, which is what "open" means. It used to browse on the first click and open only on a second click of the row already on screen, and the comment called it "the same second-click-opens idiom". Anthony rejected exactly this in diskpush 0.7.0: "i had to double click that was odd."
Hover does the browsing that argument was really about. The sidebar turns on 1003 motion reporting and lights the row under the pointer with reverse video, redrawing only when the row actually changes. The plain list in
herd-ui.mjscarried the identical idiom and got the identical treatment, and its help line no longer advertises a double-click.Two more defects found while in there:
s/a/xbeside its actions, and the shared input parser only ever emitted the list's keys, so three of the five shortcuts did nothing at all.parseInputnow takes the key set.✕ stoplooked for a member called"x"and never stopped anything.(c)
moshcode herdopens the UISomeone typing the noun with no verb wants to see the herd. Three guards, all tested:
--jsonstill goes toherdPs.herdPs.writestill goes toherdPs— everything that injects one (the mosh bar's one-row pane, tests) is collecting lines, and cannot be handed a program that paints the screen and waits for a click.moshcode psis unchanged and still one word.The pit needed one more thing:
/herdnever closed readline, and now that bare/herdhands the terminal to tmux, readline and tmux would fight over every keystroke.takesTerminal()says which verbs need it closed, the way/attachand/ssh shellalready do. That was latent for/herd uiand/herd tilebefore this.(d) More examples in
herd helpKept every existing one, added worked examples for: opening the UI (now the default) and what a pipe gets instead, tiling and untiling, the bar and F12, cost, driving a member without attaching, swarm and fleet reaching into the same herd, hooks and doctor, tasks, log, stats, watch, remote agents and
herd serve, and restore/prune/wait after a reboot.Also: consuming PRD 0019's
confidence#542 landed while this was in flight.
sessionState()now returnsknownorinferred, and the roster marks inferred states with a trailing?. The sidebar drew both identically, which puts straight back the confident lie the heartbeat exists to stop, so it now carries the same mark. It consumes the field and does not reimplement the classification. Suppressed onunknown, which already prints?as its state.Tests
node --testover the whole repo, on the committed tree:New:
test/herd-sidebar-click.test.mjs(13) andtest/herd-default-verb.test.mjs(5). The sidebar tests driveherdSidebarwith a fake stdin and a stand-in for tmux, because the thing under test is not what the screen looks like, it is what happens when a shell-out fails halfway through a click.Verified end to end as well, with the real
moshcode herd uiattached inside an outer tmux and clicks delivered through the attached client the way a mouse delivers them.