Remote development over ssh

Vocabulary: the controller is the machine running mp (your laptop); a box is the ssh destination it talks to. mp’s --host flag and the "host" field in registry rows and JSON name a box.

Two placement models

mp can work on another machine in two ways, both built on the same proxy: your local mp forwards a command over ssh to an mp binary on the box, which runs it exactly where the repo and worktrees live. Worktree paths, lifecycle hooks, multiplexer sessions, and forge auth (gh/glab) all resolve on the box — nothing is emulated on the controller.

ModelWhat lives on the boxHow you address it
Remote project — the whole project is on the boxthe repo and every piecemp project add box:path, then mp --project <name> <cmd> (or --host/--dir) — see Setup
Placed piece — one piece of a local project is on the boxone clone per (project, box) under ~/.local/share/mp/<project>, with that piece as a normal worktreemp create --remote=<box> --name <piece>; the project, mp list, and the tmux plugin stay on the controller — see Placing a piece on a box

The remote surface is byte-identical to the local one — flags, stdin JSON, --schema, JSON out — so anything that can drive mp locally can drive a box, including mp-mcp.

Setup (once per host)

  1. Install mp on the host, on PATH or in ~/.local/bin (the proxy adds ~/.local/bin to PATH itself — plain ssh sessions don’t read your shell profile). Set MP_REMOTE_BIN=/path/to/mp locally to override.
  2. Make sure key-based ssh works without prompting: the proxy runs with BatchMode=yes and a 5s connect timeout so agents can never hang on a password prompt.
  3. Clone the repo and initialise it there — you can do both halves through the proxy already:
ssh wire git clone git@github.com:acme/api code/api
mp --host wire --dir code/api init
mp --host wire config set multiplexer none    # first-run config, once per host
  1. Register it, scp-style — the path is resolved to an absolute path on the host at add time, so relative paths are fine here (and only here):
mp project add wire:code/api
  1. Check the host:
mp remote doctor wire
# wire:
#   ✓ ssh (BatchMode)
#   ✓ mp v0.9.2 = local
#   ✓ git  ✓ tmux  ✓ herdr  ✓ gh (auth: ✓)

mp remote doctor with no argument checks every host in your registry. If a proxied command misbehaves, run it first: unreachable ssh, a missing or stale remote mp, and unauthenticated gh (PRs are created on the host, so gh auth login must have happened there) are the three usual causes.

Dispatch

mp --project <name> <cmd> routes any command to a registered project — proxied over ssh when the entry has a host, run from its path when local. Same command either way. Proxy flags go between mp and the verb — that keeps them from colliding with flags the verbs own (mp init --dir, mp switch --project), and means the forwarded argv is never parsed locally:

mp --project api create --prompt "add rate limiting"
echo '{"prompt":"add rate limiting"}' | mp --project api create
mp --project api list
mp --project api stack sync

If the same repo is registered both locally and on a host, the bare name is ambiguous and mp refuses to guess a machine — pass the path or host:path (mp --project wire:/home/u/api list); mp project add warns when it creates such a collision.

For anything not registered, --host/--dir is the raw escape hatch, and the MP_HOST/MP_DIR environment variables are their equivalents (mp prints a banner to stderr when MP_HOST reroutes a command, since an exported stray would otherwise silently proxy everything):

mp --host wire --dir /home/u/api list
mp --host wire --dir /home/u/api/.monkeypuzzle/pieces/add-rate-limiting pr create

Precedence: --host flag > project’s registry host > local. mp never proxies based on your cwd alone. --dir takes an absolute path, or one relative to the ssh login home (it ends up inside single quotes on the remote shell, so ~ never expands).

What runs where

ConcernWhere it happens
git, worktrees, branchesremote host
lifecycle hooks (.monkeypuzzle/hooks/)remote host, remote env
PR creation (gh / glab)remote host — auth there
multiplexer sessions, if you use oneremote host (create/attach yourself, e.g. ssh -t wire tmux new -A -s <session>)
interactivityremote: a pty is allocated only when your local stdin+stdout are a real terminal

Piped/JSON invocations get no pty, so JSON output stays byte-clean; when the local caller supplies no stdin at all, the proxy feeds the remote {} (stdin JSON mode’s “all defaults”) because ssh always presents a pipe on the far end.

The worktree_path in JSON responses from a proxied command is a path on the host. Registry rows for remote projects carry a "host" field (mp project list --json, mp go --json) so scripts and the tmux plugin can tell them apart.

Placing a piece on a box

mp create --remote=<box> --name <piece> (or "remote" in stdin JSON) puts one piece of the project you are standing in onto a long-lived box. mp owns the placement — which box a piece lives on — and the box contract (mp remote doctor passes). Provisioning, teardown, toolchains, credentials and gh auth on the box are not mp’s: prepare the box by hand (below) or with whatever system owns it.

cd ~/code/api                 # local project (the controller side)
mp create --remote=wire --name fix-auth
# Connecting wire for api (clone + mp init under ~/.local/share/mp)...
# ✓ Placed fix-auth on wire (/home/u/.local/share/mp/api/.monkeypuzzle/pieces/fix-auth)
mp list                       # fix-auth shows up with host "wire"

What happens, in order:

  1. Validate on the controller: the box name is a sane ssh destination; the piece name is free across local worktrees and placed pieces (one namespace); --parent is main or a piece already placed on the same box (a local parent or one on another box is refused — stacks never span boxes).
  2. Link — .monkeypuzzle/placements.json gets {"fix-auth": {"box": "wire", "pending": true}} before anything touches the box, so a crash can never leave a box-side worktree nobody can find.
  3. Connect (first piece of this project on this box only): if the controller has .monkeypuzzle/hooks/on-box-connect.sh, it runs — on the controller, blocking — and owns this step (see Hooks). Otherwise, over ssh, the repo’s origin is cloned to $HOME/.local/share/mp/<project> — into <project>.tmp first, renamed into place once git is done, so an interrupted clone is never taken for a connected box; “already cloned” means git can verify HEAD there, and anything else at that path is replaced — mp init --name <project> runs there (skipped if already a project), and the controller’s .monkeypuzzle/hooks/ is rsynced across (same ssh options as every other box call: BatchMode, 5s connect) — hooks only exist on the box if shipped. Either way the path is then resolved on the box (readlink -f), so $HOME never has to expand on the controller. The box is recorded as a hidden registry row <project>@<box> (mp project list --all), which is what “connected” means — later pieces skip this step, and a failed connect (hook or built-in) is retried next time.
  4. Doctor the clone: ssh, mp on the box, and init=yes for that path.
  5. Create by proxy: mp --host wire --dir <clone> create --name fix-auth [--parent …] [--prompt …] [--branch …] --skip-switch --json — an ordinary create on the box, hooks and all. The proxy exports MP_PLACEMENT_HOST=wire MP_REMOTE=1 into that call; the box-side create persists the box name into the piece’s metadata (placement_host), which is what every later hook in that worktree reads (see Hooks).
  6. Placed: the link flips to pending: false with the box-side remote_path.

Steps 3–6 run under a per-(project, box) lock, so two creates onto the same box queue rather than race one clone; two creates of the same name lose to piece already exists at step 2. Any failure after step 2 removes the pending link; the registry row stays once the box passed doctor (it is connected). Named errors: piece already exists, parent must be main or a piece on the same box, box unreachable over ssh (ssh exit 255), box connect failed (clone/init/rsync or on-box-connect.sh stderr), box clone is not an mp project, mp is not installed on the box.

The origin URL is forwarded to the box verbatim by the built-in connect — use ssh remotes rather than URLs with embedded credentials.

Working on a placed piece

Piece verbs that take a piece selector — mp status <piece>, mp done <piece>, mp abandon <piece> (positional or --piece) — look the name up in placements.json first. A placed piece is proxied to its box as mp --host <box> --dir <remote_path> <verb> … with the selector stripped (the box-side mp runs inside the worktree), stdout/exit code passed through. Verbs that end a piece (done, abandon) drop the link once the box has succeeded; when that was the project’s last piece on the box, the hidden registry row goes too. Verbs that only run from inside a piece (pr create, pr ready, update, merge, sync) have no selector: address the box-side worktree directly, which is what the hint after mp create --remote prints:

mp --host wire --dir /home/u/.local/share/mp/api/.monkeypuzzle/pieces/fix-auth pr create --draft

A link whose create never finished is pending: the verbs refuse it (piece placement is still pending) and mp remote doctor lists it under pending_links. mp cleanup asks each box whether its side of every link exists (test -d <remote_path>): a pending link whose piece the box does have (the create finished, the controller crashed before recording it) is healed into a placed link; a pending link with nothing on the box, and any stale link (piece gone on the box, e.g. merged from inside the box worktree), is dropped; unreachable boxes keep their links. Its JSON carries the verdicts under links (present, pending, healed, dropped).

Hooks

Two hook moments sit at the controller/box boundary; everything else is the ordinary lifecycle, just running on the box.

on-box-connect.sh — controller-side, blocking, once per (project, box). Fires from step 3 above the first time a project places a piece on a box, and replaces the built-in connect entirely: clone, mp init, toolchain, credentials, shipping hooks — whatever the box needs is the hook’s job. mp only resolves the clone path afterwards (cd <path> && readlink -f .) and runs the doctor probe; non-zero exit is box connect failed with the hook’s stderr, the pending link is removed and nothing is registered, so the next mp create --remote runs it again. Its env:

VariableValue
MP_BOXthe ssh destination (wire)
MP_REMOTE_PATHwhere the clone must end up: $HOME/.local/share/mp/<project>, unexpanded — pass it to the box, don’t expand it here
MP_REPO_URLthe controller’s origin URL
MP_PROJECTproject name (mp init --name must match)
MP_HOOKS_DIRthe controller’s .monkeypuzzle/hooks/, if you want them on the box too

It runs with the controller repo as cwd. A worked recipe (clone + toolchain

# .monkeypuzzle/hooks/on-box-connect.sh
#!/bin/bash
set -euo pipefail
# env: MP_BOX  MP_REMOTE_PATH ($HOME/.local/share/mp/<project>, unexpanded)
#      MP_REPO_URL (origin)  MP_PROJECT  MP_HOOKS_DIR (this dir)
ssh "$MP_BOX" "set -e
  export PATH=\"\$HOME/.local/bin:\$PATH\"
  mkdir -p \"\$(dirname $MP_REMOTE_PATH)\"
  [ -d $MP_REMOTE_PATH/.git ] || git clone $MP_REPO_URL $MP_REMOTE_PATH
  cd $MP_REMOTE_PATH
  [ -f .monkeypuzzle/monkeypuzzle.json ] || echo '{}' | mp init --name $MP_PROJECT >/dev/null
  mise install                              # toolchain pinned by the repo
  gh auth status >/dev/null 2>&1 || gh auth login --with-token < ~/.gh-token
"
rsync -a -- "$MP_HOOKS_DIR/" "$MP_BOX:$MP_REMOTE_PATH/.monkeypuzzle/hooks/"

Box-side hooks of a placed piece — on-piece-create.sh, before-pr-create.sh, all of them — run on the box as usual, from the clone the hook or built-in connect made, and see two extra variables: MP_PLACEMENT_HOST=<box> (the box’s name as the controller addresses it) and MP_REMOTE=1. The controller’s proxy exports both into the placement create (create --remote) and into verbs routed to a placed piece; the box-side create persists the box name into the piece’s metadata (placement_host in piece-metadata.json), and the hook runner reads it from the worktree whenever the invocation’s env lacks it. So any mp run in that worktree on the box — proxied with plain mp --host <box> --dir <worktree> … as the create’s hint suggests, or from an agent’s session on the box — gives its hooks the same two variables. MP_HOST is never set on the box (it is the reroute variable and would make the box proxy onward). An older mp on the box simply ignores the variables — mp remote doctor already flags the version skew.

One hook file can branch on where it runs:

# .monkeypuzzle/hooks/on-piece-create.sh
#!/bin/bash
cd "$MP_WORKTREE_PATH"
if [ "${MP_REMOTE:-}" = 1 ]; then
  echo "placed on $MP_PLACEMENT_HOST" >> .git/mp-placement
fi
go mod download

Preparing a box by hand

The built-in connect needs only ssh + git + mp on the box. To control the clone yourself (a different remote, credentials, a pre-warmed checkout), do its work up front and mp will find it:

ssh wire
mkdir -p ~/.local/share/mp && cd ~/.local/share/mp
git clone git@github.com:acme/api api && cd api
mp init --name api            # must match the controller's project name
mp config set multiplexer none
gh auth login                 # PRs are created on the box

Back on the controller, mp remote doctor wire should be all green; the first mp create --remote=wire then finds the clone and only ships hooks. To do the same thing repeatably, put it in on-box-connect.sh instead (Hooks).

Known limits (v1)