mossferry
v2.7.4
Published
The green ferry between your machines — open remote tmux sessions over mosh/ssh with a fzf picker.
Maintainers
Readme
mossferry
The green ferry between your machines. Moss carries the green identity and the
mosh phonetics; ferry is the job — carrying you across to your remote tmux
sessions. Short daily command: ferry.
|>
__|__ __
__|_o_o_|__ / _|___ _ _ _ _ _ _
_|___________|_ | _/ -_) '_| '_| || |
\ o o o / |_| \___|_| |_| \_, |
~~~\_____________/~~~~~~~~~~~~~~~~~~~~~~~~ |__/ ~~A two-part tool for opening remote tmux sessions over mosh. Host-agnostic: written for a laptop ↔ workstation pair, but nothing is hard-coded to those machines.
┌──────────── local ────────────┐ ┌──────────── remote ───────────┐
│ bin/mossferry │ mosh │ bin/repo-session │
│ - parse args / config │ ──ssh──▶│ - fzf (or menu) session picker│
│ - launch mosh or ssh │ │ - create / attach / claim │
│ - update, doctor │ │ - all tmux logic │
└───────────────────────────────┘ └───────────────────────────────┘Installed paths are symlinks into this repo, so git pull is deploy.
Safety (door, not building)
mossferry is the hop into remote tmux — not an agent sandbox or secrets manager. Host AI-session safety (trust tiers, YOLO sandboxes, env hygiene) stays on the machine you land on.
Coming soon: optional share/preview modes and clearer doctor guidance. See docs/safety-roadmap.md for the boundary and planned nits. Vulnerability reporting: SECURITY.md.
Name origin
mossferry was chosen after the previous short name collided hard with a popular speech model (owning PyPI/crates) and a mobile SSH/mosh terminal in the same space. The name is clean on npm, crates.io, PyPI, Homebrew, AUR, and GitHub. Identity color: green.
Install
Local machine (where you type ferry):
npm install -g mossferry # or: npx mossferry …
# bins: ferry | mossferry | repo-session
ferry doctorSeeds config on first run if needed; or copy config.example → ~/.config/mossferry/config.
Remote host (where tmux sessions live) still needs the same tools on PATH —
install the package there too, or clone + ./install.sh:
# option A — npm (same as local)
npm install -g mossferry
# option B — git checkout (symlinks into ~/.local/bin)
git clone <url-or-path> ~/Repositories/mossferry
cd ~/Repositories/mossferry
./install.shinstall.sh is idempotent: it creates ~/.local/bin and ~/.config/mossferry,
symlinks bin/mossferry as both mossferry and ferry, plus repo-session,
into ~/.local/bin/, migrates legacy config when present, and seeds
~/.config/mossferry/config from config.example only if absent.
Usage
| Command | Behavior |
|---|---|
| ferry | uses FERRY_DEFAULT_HOST → global picker (error if key unset) |
| ferry <host> | fzf picker, all sessions + ➕ new session… row |
| ferry <host> <repo> | fzf picker, that repo's sessions + ➕ new session… row |
| (picker keys) | enter=attach · ctrl-x=kill (instant, no confirm — spam-friendly) · ctrl-r=rename · ➕ → nested start menu · cycle · esc; kill/rename stay in-picker. Optional FERRY_KILL_CONFIRM=1. Version skew shows a subtle ↑ update … ferry update header line |
| ferry <host> <repo> --primary\|-p | attach primary, create if missing (old default, now explicit) |
| ferry <host> <repo> --new | force fresh session (unchanged) |
| ferry <host> [repo] --list\|-l | list sessions via ssh (unchanged) |
| ferry <host> <repo> --resume-closed | atomic claim, unchanged (grid driver) |
| ferry <host> <repo> --resume-or-new | claim-or-create, unchanged (grid driver) |
| ferry <host> --resume [N\|name] | bare → same fzf picker; N/name → direct attach (unchanged) |
| ferry <host> <repo> --claude\|-c | fresh sessions run claude (unchanged) |
| ferry <host> <repo> -- cmd… | custom startup command (unchanged) |
| ferry update [host] | git pull local clone + ssh <host> git -C ~/Repositories/mossferry pull; prints both versions |
| ferry doctor [host] | health checks (see § Health) |
| ferry --help\|-h | usage printed locally, no connection (unchanged behavior) |
| ferry <host> typoo | no repo 'typoo' under <base> — pick one below, or run 'ferry <host>' to browse all sessions + repo list, exit 1 |
mossferry is the long form of the same binary; everyday use is ferry.
Default action is the picker. Direct attach-primary is behind --primary.
Grid flags (--resume-closed, --resume-or-new) are unchanged.
Errors are local. Before launching mosh, repo-bearing invocations run
repo-session --validate over ssh. Unknown repos print the typo error + repo
list on your local terminal and never start mosh (so alternate-screen restore
cannot wipe the message).
The picker
Runs on the remote, inside the mosh session:
- Banner: green ferry ASCII art sits in the fzf header (top-left), width-
tiered: wide panes get the boat+figlet lockup (≥52 cols), medium panes get
the liner with the name on the hull (≥24 cols), and short or narrow panes
get a one-line
⛴ mossferry. The main picker's banner uses 40% of terminal width (header left of theright:60%preview). SetFERRY_BANNER=off(or0) to hide it. Layout is reverse with the header first; list navigation wraps (--cycle). - One fzf list; each row: session name, active-window name, window count, attached/detached, current command.
- Preview panel:
tmux capture-pane -ep -t <session>— the session's live screen, in color. ➕ new session…→ second fzf over directories inFERRY_REPO_BASE(pre-filtered to<repo>when one was given) → create + attach.- Global new-session chain also offers
➕ new repo…(prompt name,mkdir+git init -b mainunderFERRY_REPO_BASE, then create + attach) and🏠 home session…(prompt name, empty →home; session cwd$HOME). - AI launchers: on destination rows (repo /
➕ new repo…/🏠 home session…) and on the main picker's➕ new session…row, press a configured key (defaultctrl-a→claude,ctrl-g→grok) instead of enter to create that session with that start command. OverridesFERRY_DEFAULT_CMD,--claude, and-- cmd…for that one creation. Enter keeps today's default. On an existing-session row, launcher keys are ignored (list reloads). Configure viaFERRY_LAUNCHERS(key:commandpairs, comma-separated; empty disables). Sub-picker header shows a dynamic hints line from the parsed config. No-fzf menu is unaffected (always uses the default command). - Grok wrap (local, not remote): AI launchers only set the remote start
command. Clipboard OSC 52 and dirty-disconnect restore need
grok wrapon the laptop around the whole mosh/ssh hop. With defaultFERRY_WRAP=auto, ferry runsgrok wrap mosh …/grok wrap ssh …whenever localgrokis on PATH — so you keep typingferry, pressctrl-gfor a grok session, and wrap is already under you. Install the Grok CLI on the client machine (curl -fsSL https://x.ai/cli/install.sh | bash). SetFERRY_WRAP=offto disable. mosh note: many mosh builds strip OSC 52, so wrap cannot invent clipboard bytes that never arrive; useFERRY_TRANSPORT=sshwhen you need reliable Grok → local clipboard, or keep host-side yank pipes (e.g. tmux →pbcopy). voice / mic:ctrl-gstarts Grok on the remote host — laptop microphone and Ctrl+Space PTT do not cross mosh/ssh. Use local Macgrokfor voice; ferry for remote coding. See docs/guides/grok-and-ferry.md. - Repo-scoped picker's new-session chain stays pre-filtered — no special rows.
- Zero-session fast path:
ferry <host> <repo>with no live sessions skips the picker and creates + attaches the primary. - Esc / Ctrl-C: exit 130; mosh ends; you land back at the local prompt.
- fzf missing on host → plain numbered menu (sessions +
n) new+q) quit; global new-session menu addsr) new repoandh) home session). Never falls through to a shared session.
Configuration
~/.config/mossferry/config — plain KEY=value, shell-sourced. Every key has a
built-in default; environment variables override the file; the file overrides
defaults.
| Key | Default | Where |
|---|---|---|
| FERRY_REMOTE_BIN | .local/bin/repo-session | remote path of repo-session, relative to remote $HOME |
| FERRY_REPO_BASE | $HOME/Repositories | remote: where repos live |
| FERRY_DEFAULT_CMD | neofetch | remote: startup command in fresh sessions |
| FERRY_DEFAULT_HOST | (unset) | local: host used by bare ferry |
| FERRY_SERVER_TIMEOUT | 86400 | local: mosh-server self-exit after N seconds clientless |
| FERRY_REMOTE_REPO | Repositories/mossferry | remote: repo checkout, relative to remote $HOME |
| FERRY_HIDDEN_WINDOW_GLOB | _* | remote: window-name glob skipped for picker labels/previews |
| FERRY_BANNER | on | green ferry art in picker header and --help (off/0 hides) |
| FERRY_START_MENU | claude,grok | remote: nested create menu (comma-separated start commands; empty disables). Personal profile names stay in your config only. |
| FERRY_LAUNCHERS | (empty) | remote: optional picker hotkeys (key:command pairs; empty = menu-only; ctrl-x/ctrl-r reserved) |
| FERRY_WRAP | auto | local: prefix interactive transport with grok wrap when local grok exists (auto/on/off); not used for --list |
| FERRY_TRANSPORT | mosh | local interactive hop: mosh (roam) or ssh (better OSC 52 with wrap) |
See config.example for a ready-to-edit template.
Grok + ferry (Mac client → headless host)
Keep ferry as the entrypoint. Do not replace it with raw grok wrap ssh.
| Layer | Who | What |
|---|---|---|
| ferry on Mac | local | mosh/ssh (+ optional grok wrap) into remote repo-session |
| picker ➕ + start menu | remote | nested pick: default (no AI) or a CLI from FERRY_START_MENU |
| optional hotkeys | remote | FERRY_LAUNCHERS chords (off by default) |
| enter on existing | remote | attach |
# on the Mac (once)
curl -fsSL https://x.ai/cli/install.sh | bash # provides `grok wrap`
# optional, only if you want SSH clipboard over mosh roam:
# echo 'FERRY_WRAP=auto' >> ~/.config/mossferry/config
# echo 'FERRY_TRANSPORT=ssh' >> ~/.config/mossferry/config
# remote ~/.config/mossferry/config — your CLIs / profiles (not shipped):
# FERRY_START_MENU="claude,grok,grok --profile work"
# FERRY_LAUNCHERS="ctrl-a:claude" # optional hotkeys
ferry # same as always
# in picker: ➕ new → pick destination → start menu (default / claude / grok / …)
ferry doctor # reports local grok wrap + transportComposition with ghostty-grid
ghostty-grid knows nothing about mossferry; it just runs a command per pane:
ghostty-grid -8 -- ferry manjaro-remote syndcast --resume-or-new --claude
# everyday driver: 8 panes, reattach existing syndcast sessions, fill the
# rest with new ones running claude
ghostty-grid -8 -- ferry manjaro-remote syndcast --resume-closed
# reattach existing sessions only; leftover panes stay blankUpdating
ferry update # uses FERRY_DEFAULT_HOST
ferry update <host> # explicit hostPulls the local clone (git pull --ff-only), then pulls the remote clone
over ssh, and prints local <v> / remote <v>. On a TTY the crossing is
shown as step lines (local pull → remote pull) plus a wave strip
local <v> ~~~⛴~~~ remote <v> (green on match, red on divergence). Raw
git output is dimmed, not removed. FERRY_UPDATE_VERBOSE=1 keeps git loud.
Health
ferry doctor
ferry doctor <host>Read-only checks (config, ssh resolution, auth, mosh, remote bin, version
match, fzf, lingering mosh-servers, MagicDNS notes). Exit 1 if any check
fails. On a TTY: small ferry banner, green ✓ / red ✗ / dim info glyphs,
end summary, and a dim fix hint (e.g. ferry update) when versions
mismatch. Non-TTY keeps the plain ok / FAIL / info tokens for
scripts and tests.
Ops screens (v2.5.0)
Chrome is TTY-gated via the vendored GREEN-UI-KIT (lib/green-ui.sh).
Pipes and CI stay monochrome and parse-stable.
| Surface | Non-TTY (stable tokens) | TTY chrome |
|---|---|---|
| ferry doctor | ok / FAIL / info lines | ⛴ banner · glyphs · summary · fix hints |
| ferry update | local <v> / remote <v> | step checklist · wave strip |
| --help | sectioned body + version footer | green section labels |
| validate typo | exact repo-session: no repo… + list, exit 1 | dim capped list + did you mean …? |
| install.sh | symlink / seeded action lines | MEDIUM hull · ✓ steps · ready panel · PATH check |
Errors are unified as mossferry: (client) or repo-session: (remote),
with a red ✗ glyph when stderr is a TTY. Missing lib/green-ui.sh never
kills the tool — plain fallbacks apply.
Testing
bash tests/run.shPlain bash harness (fake tmux / mosh / ssh stubs). No framework
dependency beyond bash. Nonzero exit on any failure.
Migration
If you are upgrading from the v1 command name (or the pre-repo zsh shell
functions, or a .bashrc block that auto-attaches tmux on SSH_CONNECTION):
- Run
./install.shon both machines (back up existing~/.local/bin/repo-sessionfirst if it is a plain file). It installsferry/mossferry, drops a repo-owned v1-name symlink, and migrates the old config dir into~/.config/mossferry/config(MOSHI_→FERRY_; old file becomesconfig.migrated). - On the laptop: remove the old
mosh()/ helper function blocks from~/.zshrc(keep a timestamped backup). - On the remote: remove the
.bashrcauto-attach block so plainsshlands in a normal shell (not a sharedmainsession). - Confirm
type ferryresolves to~/.local/bin/ferryandssh <host> 'echo $TMUX'prints empty. The old v1 command name should no longer resolve onPATH.
install.sh warns on stderr when it still sees those legacy blocks.
Open-sourcing
mossferry is built with no personal paths or hardcoded hosts. When you want to open-source it, just add a GitHub remote and push: git remote add origin <url> && git push -u origin main.
Community
- Website: mossferry.muslewski.com
- Questions & ideas: Discussions
- Bugs & features: Issues
- Contributing: CONTRIBUTING.md
- Code of Conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md (private reports only)
- Support matrix: SUPPORT.md
If you're not sure whether something is a bug, start a Discussion — maintainers can promote it to an issue when it is.
