Skip to content

fix(herd): the sidebar crash, one click to open, and herd opens the UI - #545

Merged
ralyodio merged 1 commit into
mainfrom
herd-ui-default
Sep 25, 2026
Merged

ralyodio merged 1 commit into
mainfrom
herd-ui-default

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

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

it crashed when i click on agent in sidebar

Three causes, all in the click path, all verified rather than assumed.

1. paneIndex() throws on a short line. src/herd.mjs read dead.trim() on a list-panes line that may have fewer fields than the format string asked for. showMember() runs it on every click. Reproduced with a stand-in tmux:

TypeError: Cannot read properties of undefined (reading 'trim')
    at paneIndex (src/herd.mjs:604:62)
    at showMember (src/herd-workspace.mjs:140:17)

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 no unhandledRejection or uncaughtException handler anywhere in src/ or bin/, confirmed by grep.

3. Pane identity collapses inside the workspace. This is the one that bites in practice. startSession pins a member's pane title with allow-set-title off, but that option is per window, and the whole trick this file is built on is join-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:

after click "+ shell" -> panes: herd  anthony@dev:~/src/moshcoder/moshcode
after click a member  -> panes: herd  alpha  anthony@dev:~/src/moshcoder/moshcode

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:

after click "+ shell" -> panes: herd  shell-agent-a2b8ec68c
after click a member  -> panes: herd  alpha

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.
  • The handler body is guarded. A failed tmux call now costs a line of red in the sidebar, not the sidebar. Failing mid-click is ordinary: a pane dies between two refreshes and every -t naming it starts answering "can't find pane".
  • The refresh timer is guarded separately, because a throw in a setInterval callback is not inside the awaited promise at all and no downstream catch can see it.
  • pinTitles() sets allow-set-title off on the workspace window (a no-op on tmux 3.4, which is what Ubuntu 24.04 ships, so parkPane covers 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.
  • One idempotent restore() puts back raw mode as well as the escapes, wired to exit, SIGINT, SIGTERM and SIGHUP, 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.mjs carried 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:

  • The sidebar printed s / a / x beside its actions, and the shared input parser only ever emitted the list's keys, so three of the five shortcuts did nothing at all. parseInput now takes the key set.
  • Clicking an action overwrote the member selection with the action's own key, so ✕ stop looked for a member called "x" and never stopped anything.

(c) moshcode herd opens the UI

Someone typing the noun with no verb wants to see the herd. Three guards, all tested:

  • --json still goes to herdPs.
  • stdout or stdin not a tty still goes to herdPs.
  • an injected write still goes to herdPs — 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 ps is unchanged and still one word.

The pit needed one more thing: /herd never closed readline, and now that bare /herd hands the terminal to tmux, readline and tmux would fight over every keystroke. takesTerminal() says which verbs need it closed, the way /attach and /ssh shell already do. That was latent for /herd ui and /herd tile before this.

(d) More examples in herd help

Kept 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 returns known or inferred, 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 on unknown, which already prints ? as its state.

Tests

node --test over the whole repo, on the committed tree:

tests 3514   pass 3510   fail 0   skipped 4

New: test/herd-sidebar-click.test.mjs (13) and test/herd-default-verb.test.mjs (5). The sidebar tests drive herdSidebar with 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 ui attached inside an outer tmux and clicks delivered through the attached client the way a mouse delivers them.

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>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

8 finding(s) in the 9 file(s) this pull request changes.

MEDIUM: 8

Severity Rule Location
MEDIUM sql-string-concatenation src/cli-schema.mjs:219
MEDIUM sql-string-concatenation src/cli-schema.mjs:306
MEDIUM sql-string-concatenation src/cli-schema.mjs:716
MEDIUM sql-string-concatenation src/cli-schema.mjs:749
MEDIUM sql-string-concatenation src/cli-schema.mjs:856
MEDIUM sql-string-concatenation src/cli-schema.mjs:1219
MEDIUM sql-string-concatenation src/cli-schema.mjs:1745
MEDIUM sql-string-concatenation src/cli-schema.mjs:1763
96 pre-existing finding(s) elsewhere in the repository — **HIGH/CRITICAL**: 8 | **MEDIUM**: 77 | **LOW**: 11

Not introduced by this pull request. The full set is in the Security tab.

Severity Rule Location
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
HIGH sh-remote-script-execution install.sh:86
HIGH sh-remote-script-execution install.sh:90
HIGH sh-remote-script-execution install.sh:258
HIGH sh-remote-script-execution install.sh:269
HIGH sh-remote-script-execution install.sh:275
HIGH tls-verification-disabled src/dns.mjs:766
MEDIUM sql-template-interpolation apps/pwa/src/lib/moshpit-certs.mjs:44
MEDIUM sql-template-interpolation apps/pwa/src/lib/moshpit-certs.mjs:82
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:139
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:153
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:179
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:373
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:377
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:422
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:671
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:867
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:869
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:928

…and 76 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 73c25a6 into main Sep 25, 2026
6 checks passed
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