shipship, back to the landing page

Ship 0.1.0 is out. It's a terminal multiplexer, like tmux or Zellij, with a sidebar that shows what your coding agents are doing, and your programs run on a background server so closing the window doesn't stop them. Herdr is what got me started on it, and the goal is most of what I use Herdr for with a lot less code. The release notes have the full list of what's in it.

To install it on Linux or macOS, run this.

Terminal window
curl -fsSL https://raw.githubusercontent.com/shipmux/ship/main/install.sh | sh
ship

ship update downloads the newest release, checks it against the published checksum and replaces the ship you ran, and the sidebar tells you when there's a new one. There's nothing newer than 0.1.0 yet, so today this is all it does.

Terminal window
$ ship update --check
up to date

The server and its windows talk over a protocol with a version number, and when a new ship can't talk to the server that's already running, the server has to restart, which ends the programs running in it. Your tabs, splits and directories come back with fresh shells, but whatever was running in them is gone. That number went from 1 on October 4 to 24 now, so 23 bumps in about five and a half weeks.

The other way is capability negotiation, where the two sides work out which features they share and every old combination keeps working.

it's why i settled on the protocol version thing. it's like a minute of inconvinience for the end user at most every few weeks or months, at the cost of having one hell of a lot less bugs, simpler code, faster features, easier maintenance, and of course, everything is exponentially better than the opposite because debt tends to compound, especially with agents writing the code.

The case for keeping programs alive is mostly about agents, and I don't think it holds up.

agent conversations are like... the MOST resumable, pausable thing on the planet? they're literally built for "durable" execution, replayability, whatever? right? so like the only thing that even makes sense is like the jobs the agents run, and even those are usually either short lived or long lived but deterministic? like compilation?

On November 6 I went through the ways to do it anyway. One was a long-lived holder process that owns the programs, so the server can restart around them.

the holder one sounds like platform specific nightmare fuel...

So I asked for the cleanest way that wasn't that, and about ten minutes later it turned out to be the same thing.

wait i just realized something. this is basically the holder wearing a different coat of paint. right? because like... you still need the whole api thing? and the entire point of the upgrade and protocol is that we can break crap more freely and we're not afraid to do that instead of maintaining infinite layers of cruft and baggage

So restarting is the one way back, whether it's an update, a crash or a reboot.

same reason everyone tells you to just restart your pc after updating stuff like your kernel, it's just not worth it.

When the new ship can't talk to the old server, it says so and prints the command to restart it. ship update never stops the server on its own, so you pick when.

Settling the JSON before people script against it

Section titled “Settling the JSON before people script against it”

Right before the release I went through everything a script would read and settled it in #126. Some of it was renames I didn't want to be stuck with. A pane's status was really about its process, so it's process now, and every route dropped the v0 from /api/v0/.

Before that PR, if a newer server sent a kind of event an older window didn't know, the window couldn't decode it and broke, so even adding something needed a protocol bump and a restart. Now the window checks the event's type first and skips the ones it has never heard of. This is the part of api.rs that does it.

match serde_json::from_str::<Kind>(data) {
Ok(Kind { kind }) if SseKind::from_str(kind).is_err() => Ok(None),
_ => Err(err!(Serialization, "cannot decode attach event", @external: error)),
}

An event type the window does know but can't decode still ends it with an error, since that means a bug or a bump I forgot, and reconnecting would just get the same bytes again. All the code that exists only for compatibility is about 35 lines, and the rule for the project is that it stays in the tens.

i think i like the cheap one. 80/20 rule. try to be nice for the easy 80%, but if stuff has to break, or if it's not worth compat, then just break it and move on

I got one of these wrong first. I picked having the server refuse request bodies with fields it didn't know, mostly because it was more consistent, and a typo would fail loudly. Before it was built I came back to it.

that's the one thing i was worried about. is like, in the cases where programs want to act like dumb pipes, sometimes they can't and are instead forced to parse certain fields in or out, you get what i mean?... does that feel... unergonomic? i feel like it is?

It is. The most obvious thing a script does is get a pane, change a field and send it back, and what you get has fields the update doesn't take.

i feel like keeping it dumb is what most http endpoints do for the most part too, right? like just in general? it's friendlier?

So request bodies stay lenient, and typos get caught by the CLI and the config file, which is where people actually type.

Plugins were going to get their own set of events, which is a second API to keep in line with the first one, when Ship's own window already gets everything from one stream.

could we literally not just offer that as a cli command that streams events json lines to stdout? the same way the client would normally get them from the server?

So that's what ship watch is. It prints the stream the window gets, one JSON line per change, starting with the same line ship server status prints.

Terminal window
$ ship watch
{"service":"ship","protocol_version":24,"version":"0.1.0",…}
{"type":"attached","data":{"attachment":"attachment:79b0…","replica":{…}}}

It passes every line through without reading it, so if the window's stream changes, so does the plugin's, with nothing in between to keep in sync. A plugin is a script that reads it and puts short text on panes with --tag. There's no runtime and no install step. The first thing I wanted from one was something Herdr already does.

i'm pretty sure that herdr renders certain visuals, like the branch name or worktree management for example. could we support that?

Here's the heart of the git example, which shows each tab's branch and pull request under its name.

Terminal window
"$ship" watch | {
read -r health || exit 1
# ...
jq --unbuffered -r "(.data.replica // select(.type == \"state\").data) | $panes" | sync
}

sync asks git for each pane's branch and runs ship pane set --pane "$1" --tag "$2=$4" --tag-fg "$2=$5" when it changed. The color comes from the plugin too, not from Ship's config.

having the plugin itself set it feels smarter to me? that way if it has custom logic for how to color it, the user can just edit the plugin logic or whatever?

The whole thing is 135 lines of shell, and it reads protocol_version off that first line and warns you when it was written for a different one. Writing a plugin walks through it.

The README has a list of what isn't built yet. If something breaks on your machine, open an issue, and when the fix comes out, ship update will get it to you.