Conversation
Add a one-shot "Balance splits" action and a persistent "Auto-resize splits" toggle to the pane right-click menu. Balance equalizes pane areas using leaf-count weighting rather than equal split ratios, so panes get the same area regardless of tree depth. Ratios stay within the existing 0.1..0.9 clamp; splits beyond a 9:1 leaf ratio balance best-effort. The toggle is an opt-in [ui] config key, off by default. It rebalances only the ancestor chain of the pane that was added or closed, so manual ratios elsewhere in the tab survive. Balancing is a no-op while a pane is zoomed, since the zoomed view resizes only the focused pane and would otherwise corrupt ratios that reappear after un-zoom. Adds the layout.balance JSON-RPC method, mirroring the existing split ratio handler. Ratio-only changes do not trigger federation resync, so mounted remote workspaces may show stale ratios until a structural event forces one.
…ncestor Closing a direct child of the root produced a length-1 path, which saturating_sub(2) clamped to an empty path. An empty path is not a no-op: balancing walks the node it stands on before consuming the path, and after the collapse that node is the promoted sibling, so its manual ratio was silently reset. When the removed pane's parent was the root, no ancestor survives and nothing may be balanced. Also flip the auto-resize toggle from the value its menu label was rendered from rather than from live state, so a config reload while the menu is open cannot make the click do the opposite of what the item says. Correct the socket-api note on split ratios under federation: a resync reconciles workspaces, tabs, and panes and never applies layout ratios, so stale mirror ratios are not recovered by a structural event.
The translation from a pre-close pane path to the ancestors that survive remove_pane's collapse lived at the call site and was wrong twice, each time one level shallower. The underlying trap is that an empty path is not "no ancestors" but "balance the root", so every case with no surviving ancestor silently rebalanced the promoted sibling instead. Move the rule into TileLayout::balance_areas_after_removal, where the L < 2 case returns early rather than clamping into an empty path, and cover it with a sweep over every tree shape with 2-5 leaves and every removable pane. The sweep asserts the actual contract — a split that is not an ancestor of the removed pane keeps its exact ratio — and also asserts surviving ancestors are rebalanced, so a no-op cannot pass it.
…direct attach Ownership was session wide, so any mount froze the size of every terminal on the host — including ones the mounting client never opens, since it opens them lazily. Track the terminals a mount actually drives instead, claimed on its first federated resize or redraw and dropped wholesale when the lease goes. The direct attach paths resized the PTY with no ownership check at all, so an attach behind a mount left the mirror laid out for the wrong width with nothing to restore the size on detach. A mounted controller holds the single-controller lease, so it now outranks a direct attach the same way it outranks the host's own render pass; attaching still works, at the mount's size.
…actor The nudge slept 30ms between the jiggle and the restore, on the same thread that serves the pane's PTY reads and user-input writes — so a drag-resize over a mount stalled both in 30ms bursts for the length of the drag. Schedule the restore on the run loop instead, bounding the poll timeout so it fires on time, and flush it on actor exit so the PTY is never left jiggled. The restore reads the current size when it fires, so a resize arriving inside the window survives.
…rigin feat: balance splits and auto-resize toggle in the pane context menu
… like a resize Three fixes from the pre-merge review. The claim matched terminal ids exactly while the resize beside it resolved through resolve_terminal_target, so any target form the resolver accepts but an exact match misses was resized without being claimed. Both go through the resolver now. A claim was only ever dropped wholesale on unmount, so a terminal the mount stopped mirroring stayed frozen at the mount's geometry while nobody drove it. Closing a mirrored terminal now hands its size back; reopening re-claims it. The claim was also untested through the command path — removing both call sites left the suite green, because the tests called the helper directly. A dispatch level test now covers claim-on-resize and release-on-close.
…spin Truncating the remaining nudge window to whole milliseconds yields a zero timeout for the final sub-millisecond, so the run loop polled with no timeout and spun — draining commands and taking the controls mutex each pass — until the restore deadline actually passed.
…der-on-origin fix(federation): repaint mounted panes after a geometry change
…on link A clipboard image pasted into a mounted remote pane had nowhere to go: the capture path produces bytes, not a path, and the remote host cannot read the local filesystem. Stage the bytes on the serving host instead and paste the path the remote shell can actually open. Adds a FileStaging channel (24 MiB cap) gated by a two-sided FILE_STAGING capability, a detached bounded staging worker on the host with an RAII admission permit, and the client-side pending-stage map with a payload-proportional timeout, an in-flight cap, and epoch-fenced purges so a stale or foreign response cannot evict a live entry. Writes are guarded by an ordered sequence of checks that runs before the file is created, and every failure path removes the partial artifact. Paths are rejected rather than sanitised in place. Local panes and non-terminal modes keep byte-identical ctrl+v behaviour.
…irectory The staging-prefix check reads only the final component, so a returned path containing .. passed every other guard and was pasted verbatim into the pane — a hostile serving host could answer a clipboard stage with a path pointing at any file the remote shell could read. Reject any path with a . or .. component rather than normalising it: resolving the traversal here would produce a path the host never wrote and hide the fact that it answered a question it was not asked.
replaces contributions from othavi0
replaces contributions from wayneleelwc
replaces contributions from wbxl2000
replaces contributions from udirom
replaces contributions from soar
replaces contributions from sf-jin-ku
replaces contributions from DeevsDeevs
replaces contributions from Pitchfork-and-Torch
…o origin/master Merges local master (v0.7.5 base, remote-workspace-paste-image-files federation work) into origin/master (PRs #1-4: federation v2, TUI remote workspace dialog, pane auto-resize, resize-rerender fix), two independently evolved forks of the same project. 354 original conflicts resolved: vendor/libghostty-vt and website/docs taken wholesale from origin (different vendored library snapshot; mixing would be unsafe); everything else resolved per-file by comparing both implementations' actual capability, not a blanket "one side wins" rule (confirmed both sides led in different subsystems: origin ahead on federation protocol/vendor lib, local ahead on the herdr agent CLI redesign). Post-merge verification on two independent Linux machines (this repo's Mac checkout has a pre-existing broken Zig/macOS-SDK toolchain, unrelated) found and fixed: - src/pty/actor/unix.rs silently destroyed by an earlier automated conflict resolution pass (511 lines vs 1241-1399 in the originals); re-resolved from git history, kept origin's non-blocking redraw-nudge design (confirmed a strict superset of local's blocking version) - tests/cli_wrapper.rs, an obsolete monolithic test file fully superseded by local's modular tests/cli/ split, caused a real test-run hang via duplicate test names sharing derived resource paths; removed (coverage confirmed intact under the new names) - ghostty_unicode_codepoint_width/ghostty_unicode_grapheme_width declared in origin's vendored C header but not exported by the compiled library; reimplemented in pure Rust via the already-linked unicode-width crate instead of depending on the vendored library gap - Terminal::take_pwd_changes adapted to origin's simpler PWD polling API (origin's vendored library lacks local's OSC-push-callback mechanism) - scroll_viewport_row adapted to origin's DELTA/TOP-only viewport API (no absolute-row variant in this vendored library version) - OSC/PTY reply logic: added OSC 1337 CurrentDir= parsing, multi-index OSC 4 palette query support, and a live-color-override reply path the merge had dropped (8 tests, src/pane/osc.rs + src/pane/terminal.rs) - plugin manifest whitespace-only argv rejection bug (src/app/api/plugins/manifest.rs) - regenerated the stale docs/next/api/herdr-api.schema.json artifact - 8 integration tests in tests/cli/{panes,workspace}.rs hardcoded the old bare-numeric pane ID format instead of the new workspace-qualified one (w1:p1) that workspace create now returns - two leftover references to the removed top-level `wait` CLI command (src/main.rs command whitelist + --help text) Final state: 3145 lib tests + 193 integration tests passing, 0 failures, on two independent machines (appn-ltu-vm-100, appn-ltu-vm-105).
…ocket-api
The merge's deliberate CLI redesign (herdr agent start/prompt/wait/send-keys
replacing herdr agent send and the top-level herdr wait command) was not
reflected in docs/next/website/src/content/docs/{cli-reference,socket-api}.mdx
across all 3 locales (EN/JA/ZH). Fixed:
- herdr agent send <target> <text> -> herdr agent prompt / agent send-keys
- herdr agent wait <target> --status X -> --until X (repeatable)
- herdr wait output / herdr wait agent-status (removed top-level command,
confirmed removed from src/main.rs's command whitelist and --help text
in this same merge) -> herdr pane wait-output / herdr agent wait
- socket-api.mdx method table and inline examples: agent.send -> agent.prompt,
agent.wait, agent.send_keys, agent.view.set, agent.view.clear
Found by an independent breaking-change review of PR #5.
…ster merge: reconcile local master's paste-image-files/federation work into origin/master
refs herdrdev#1752 Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
The refusal is evaluated per clipboard event rather than captured at mount time, specifically so that revoking acceptance takes effect on the next remote write instead of the next restart. Two comments say so. The reload handler never refreshed the field, so it was not true. The failure is in the unsafe direction: setting accept_clipboard_writes to false and reloading reported "applied" with no diagnostics, and the very next remote clipboard write was still accepted using the old value. Only a full server restart picked the change up. Found by the two-host smoke, not by a test: every existing test set the state field directly, so none of them exercised the config path that populates it. The omission predates this merge, but the comments asserting live reload were added by this branch's re-integration work, so the branch was shipping a claim it did not keep. Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
Upstream v0.9.0 split remote arg parsing into its own module, and the merge kept both that module's parser and the fork's own. Only the fork's runs: every call site reaches extract_federated_remote_args, which parses a target list rather than a single target and carries the federation flag. Upstream's RemoteLaunch and extract_remote_args had no callers left. They were also the last two code warnings on the branch, which CI denies. The rest of the module stays, because all of it is still live: the two env var names, RemoteKeybindings, and validate_remote_target. This does not finish the consolidation. The fork's parser still lives in attach.rs rather than here, so the next upstream merge will still conflict across two modules. Moving it is a larger job than it looks -- the launch arg cluster is about ten interleaved items whose cfg(not(unix)) dead-code allowances are justified by the tests sitting beside them -- so it belongs in its own commit, not folded into a merge this size. Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
The fork's clipboard origin travels server to client on
ServerMessage::Clipboard, and the client uses it to say which host a copy
came from instead of an unattributed "copied to clipboard". The merge kept
the behaviour but lost the test that pinned it: the surviving clipboard
tests in this file destructure ServerMessage::Clipboard { data, .. }, so
the origin could be dropped on the way out and every one of them would
still pass.
The client-side test only proves the toast formats a host it is handed. It
cannot notice the server never handing one over.
Verified by mutation: replacing the forwarded origin with None fails this
test and nothing else.
Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
… merge The merge pulled upstream's preview snapshot into docs/preview while website/preview.json still pins the fork's own preview commit, so the snapshot check compared two unrelated trees and failed. Restore the snapshot that matches the pin, and keep the fork's versions readme, which still documents the website tooling this fork owns. Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
The upstream translation parity gate compares heading outlines, and the fork's federation docs plus upstream's startup hooks section existed only in English. Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
…eader The merge rehomed the terminal-drop bridge into src/client/clipboard_images.rs and pointed both platforms at the local metadata-then-open copy. On unix that undoes the hardening image_path owns: metadata() and open() on the same path string are two syscalls, and the file can be swapped between them. Route the unix read back through image_path::read_local_image_file, which takes every fact from one file descriptor, and keep the local copy for windows, where image_path is not compiled. Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
Dropping the standalone federated session mode left dial_federation, dial_and_mount and their outcome types reachable only from the cfg(unix) server-daemon mount path, and the same is true of the mount-outcome variants and the shared close-index helper. The windows binary builds with -D warnings, so each needs the allow the rest of this module already uses. Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
v0.9.0's ci.yml runs the full `just check` on Windows instead of the curated windows_check.ps1 filter list, so these three tests ran there for the first time and hung. They drive a real local pane spawn, and on Windows the ConPTY child outlives the dropped App, so the pane's child.wait() blocking task never finishes and the test runtime's drop blocks forever. The serving side these commands arrive on, server::federation_accept, is cfg(unix) already. Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
Same ConPTY hang as the real-pane split/create tests: a real tab create spawns a real local pane, the Windows child outlives the dropped App, and the test runtime's drop never finishes. This was the only test still running when nextest was interrupted, so the module has no others. Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
chore(merge): merge upstream v0.9.0 into the fork
TerminateProcess needs PROCESS_TERMINATE, but signal_processes opened each process with PROCESS_QUERY_LIMITED_INFORMATION alone and discarded the result, so every terminate failed with access denied and was never reported. The pane shutdown ladder's TERM and KILL rungs both silently did nothing, leaving the ConPTY child alive and the pane's child.wait() watcher blocked forever. Open with PROCESS_TERMINATE as well and log a failed terminate. Removes the cfg(unix) gates from the four federation_actor tests that this hang forced off Windows, so the suite itself now covers the behaviour. Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
The federation mounting side was gated off Windows even though every
primitive it uses is already portable: the dial is a plain tokio ssh
process, the mount runs over the interprocess named-pipe abstraction, and
the mount dialog already compiled. Only the gates stood in the way.
Un-gate the mount path end to end: the workspace.mount_remote handler and
its unsupported_platform stub, the FederationMount{Ready,Failed,Ended}
events and their dispatch and fallback arms, dial_federation,
prepare_and_mount_federation_target, and dial_and_mount.
Clipboard file staging stays Unix-only, so the mount-ended handler gates
its pending-stage purge rather than the whole handler. The advertised
FILE_STAGING capability is unchanged: it describes the mounting side,
which only sends bytes and reads back a path, and the Windows client
never sends a stage request.
Serving federation from a Windows host is unaffected and still
unsupported.
Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
The three workspace.mount_remote rejection tests reject their target synchronously, before any dial is spawned, so they run anywhere and now give Windows real coverage of a handler it could not reach before. The accepting sibling really does spawn dial tasks against ssh, so it stays Unix-only rather than making CI depend on a Windows runner's ssh. Also record the client-side support change and the pane-terminate fix in the unreleased changelog and docs. Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
Review of the un-gating found two gates it had missed and three stale justifications it had falsified. The headless notifier still gated its FederationMountFailed arm on unix, so on Windows the event fell through to the catch-all and no toast was sent. The remaining fallback only fires for the herdr toast delivery mode, so with terminal or system delivery a failed Windows mount was silent -- exactly what that arm's comment says it exists to prevent. --remote-workspace and HERDR_REMOTE_FEDERATION were also still gated, so on Windows they fell through to a classic full-screen remote attach with no word that the requested mode had been dropped. Un-gate that branch and auto_detect_launch_with_mount; every helper they reach is already cross-platform. Drop three allow(dead_code) attributes whose comments claimed the items had no non-unix caller, which the un-gating made untrue, and a let _ = connection_epoch that suppressed a warning that cannot fire. Record the platform split on the federation docs section that actually changed, and note in local_capabilities that the FILE_STAGING advert is honest only because app::remote_clipboard_stage is cfg(unix). Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
fix(windows): terminate pane children with the required access right
feat(windows): let windows clients mount federated workspaces
…chain Names in the four scopes drifted apart because each resolved independently. A workspace rename froze its automatic label permanently, tabs never derived a name from their directory at all and fell back to a bare position, and no scope propagated a name to any other. Resolve all four through one ladder: an explicit override, then a mounted scope's mirrored remote label, then the pane's agent identity, then the cwd or git root, then the scope's stable public ordinal. Every resolved name now carries the rung that produced it, so callers can tell a name somebody set from one derived locally instead of guessing from a one-bit flag. Renaming a tab reaches the agents inside it that carry no name of their own, including over the JSON API and the agent sidebar, while leaving the addressable agent handle alone so `herdr agent send <name>` keeps working. Clearing a rename snaps the scope back to its live derived name. A mounted scope's remote label lands in a mirror slot rather than the override slot, so it never inherits onto the panes inside it and never freezes a remote pane's live agent identity. Federation-materialized workspaces no longer drive the periodic local git pass. The wire protocol goes 23 to 24 for the new name-source field. The deprecated one-bit override flag stays, populated with its original meaning, so endpoint clients built before this change keep working without a generation bump. The federation protocol stays at 7: a new negotiated capability tells the two sides apart, because an older peer omits the name-source field entirely and its absence must not be read as a value. Claude-Session: https://claude.ai/code/session_016SWJ3DTdN2Ny4vweAttAa1
The new naming capability carried a comment saying federation only negotiates capabilities on Unix, and an allow(dead_code) attribute matching two sibling constants. Enabling federated mounts for Windows clients removed that attribute from those same siblings, so the comment now pointed at a pattern that no longer existed and described a constant that has a live Windows reader. What the capability asserts is a property of this build's PaneInfo schema, not of its operating system, so it is never platform-gated. Claude-Session: https://claude.ai/code/session_016SWJ3DTdN2Ny4vweAttAa1
fix: sync workspace, tab, and agent names with the directory and with each other
The mount dialog, "Close on host" and "Balance splits" were rebuilt onto the client-shell command lane without their methods being added to the list that lane accepts, so every request they sent was dropped before reaching the server and the controls looked inert. Advertise workspace.mount_remote, workspace.close_remote, tab.close_remote and layout.balance, and add a maintenance test asserting every method the client shell sends is advertised. An unsupported server now reports itself on the mount dialog rather than behind it. Claude-Session: https://claude.ai/code/session_01G82QeFnX3UxWYfJUZ8sNkh
Only a server rejection landed on the mount dialog's error line. The three client-side refusals -- endpoint offline, method not advertised, no snapshot yet -- reported themselves solely through an endpoint notice, which the modal is drawn over, so the press produced feedback nobody could see. Mirror whichever refusal happened onto the dialog itself. Tighten the surrounding verification: the mount test fixture now seeds the real advertised method list instead of leaving it unset (unset is permissive, so the existing tests passed straight through the outage), the lane test asserts all four methods positively, and the advertisement guard reuses the shared production_code() helper with a bidirectional canary so an advertised method nobody sends is caught too. Narrow request_changes_ui to layout.balance; workspace.close_remote and tab.close_remote complete asynchronously through AppEvent and already repaint. Claude-Session: https://claude.ai/code/session_01G82QeFnX3UxWYfJUZ8sNkh
fix(client-shell): advertise the methods the shell actually sends
…nder loop A stuck far timer deadline plus silent channels parked the client loop in its tokio select forever, with should_quit polled only at the loop head — so Ctrl-C could not exit it and the host terminal showed a macOS "Not Responding" dialog. Close the park three ways: - Add a quit-wake arm: the ctrlc termination handler (SIGINT/SIGTERM/ SIGHUP) now signals an Arc<Notify> that is a first arm of the select on both OS paths. A Notify permit is retained across iterations, so a termination signal can never be lost even if it lands between loop passes; the Quit arm re-stores the flag and lets the existing shutdown path run. - Clamp ClientLoopTimer deadlines to MAX_LOOP_TIMER_DELAY (250 ms) before the sticky-min, so any far/stale deadline re-wakes the loop (quit-flag check + health tick) at least every 250 ms. Normal delays are unaffected; an earlier sticky deadline still wins. - Probe local shell connections in the existing heartbeat: a silent-but-alive local server now surfaces as a 10 s health timeout (clean ConnectionLost) instead of an undetectable wedge. Local terminal-attach connections (no shell surface) stay exempt, so they cannot false-expire; the server already echoes pongs for every probed connection. Also add ui.render_interval_ms (default 16, clamped to >= 1) as the single source of the render/presentation pacing interval previously hard-coded as MIN_RENDER_INTERVAL, so the headless render loop's cadence is tunable without a rebuild; reloadable live via [ui] config.
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Hi @vietairs, thanks for your interest in contributing. Herdr does not accept unsolicited implementation pull requests from contributors who are not listed in The pull request author is not an approved contributor. If you encountered a reproducible bug, report the observed behavior through the bug issue template. A report does not reserve the work or authorize a pull request; accepted fixes are normally implemented by Herdr’s maintainer-controlled agents. Feature requests, behavior changes, and other proposals belong in GitHub Discussions. Do not open an issue merely to justify an implementation that was already written. If a maintainer explicitly wants this implementation, they can reopen the pull request. Reopening by anyone else will be closed again automatically. See https://github.com/herdrdev/herdr/blob/master/CONTRIBUTING.md for the contribution policy. |
|
Accidental push: this branch belongs to the fork vietairs/herdr, not upstream. Closing; the change is being PR'd in the fork. |
Why
On macOS 27.2 beta the herdr client hangs the host terminal (cmux + Terminal.app) with a
"Not Responding" dialog. Root cause (proven from hang report 17314: 2.19 h unresponsive, all 8
threads parked): the client event loop parked in its
tokio::select!with a far timerdeadline + silent channels, and
should_quitwas polled only at the loop head — so Ctrl-Ccould neither wake nor exit it. A secondary pressure source was the fixed 16 ms render cadence.
What changed (7 files, +182/−12)
1. Quit-wake arm — the loop can no longer park forever
ClientLoopEvent::Quit; thectrlctermination handler (SIGINT/SIGTERM/SIGHUP) nowshould_quit.store(true, Release)andquit_notify.notify_one().quit_notify.notified()is a first arm of the select on both the unix (biased) and windowspaths. A
tokio::Notifypermit is retained across iterations, so a termination signal isnever lost even if it lands between loop passes. The
Quitarm re-stores the flag and lets theexisting shutdown path run — no new teardown path.
2. Timer clamp — bounded re-wake
ClientLoopTimer::deadline()clamps every requested delay toMAX_LOOP_TIMER_DELAY(250 ms)before the sticky-min. Any far/stale deadline re-wakes the loop (quit-flag check + health
tick) at least every 250 ms. Normal delays are unaffected; an earlier sticky deadline still wins.
3. Local-endpoint heartbeat
silent-but-alive local server surfaces as a 10 s timeout → clean
ConnectionLostinstead of anundetectable wedge.
The server already echoes pongs for every probed connection (verified end-to-end).
4.
ui.render_interval_msrender/presentation pacing interval previously hard-coded as
MIN_RENDER_INTERVAL. Bounds theheadless render loop's cadence without a rebuild; reloadable live via
[ui].Scope notes
await; I6 kitty-graphics flush pacing; I7 server-side dead-client reaper (the server still can't
detect a silent client); I8 ops tooling.
render_interval_msknob paces the headless (server) render loop specifically; theclient-side TUI shell render path is paced by the 250 ms timer clamp instead.
client; documented in-code).
Testing
cargo checkclean (0 new warnings).client::timer,client::endpoint,config::model,app::runtime); new tests pin the clamp, the localprobe/exempt behavior, and the render-interval parse/clamp/reload.
strict subset — zero new failures).
Merge note (AD-04)
Base branch
macos-anr-mitigationis cut fromv0.9.0-hvn.2(792b8cf4), which is now ==origin/master, so this is a clean single-commit diff intomaster. No auto-merge.