Skip to content

feat(resume): path resume --remote <ssh-url> — resume a session on a remote host#145

Open
bdelanghe wants to merge 32 commits into
empathic:mainfrom
bdelanghe:bdelanghe/140-remote-resume
Open

feat(resume): path resume --remote <ssh-url> — resume a session on a remote host#145
bdelanghe wants to merge 32 commits into
empathic:mainfrom
bdelanghe:bdelanghe/140-remote-resume

Conversation

@bdelanghe

@bdelanghe bdelanghe commented Jul 23, 2026

Copy link
Copy Markdown

Closes #140.

🧵 Design + build session: https://pathbase.dev/u/robert-dev/pathstash/graphs/d7baa4c0-dfc7-40e7-8bd6-d0d4a9c6ec9c — resume it with path resume <url>.

Adds --remote ssh://[user@]host[:port] to path resume: the host resolves the Toolpath document and projects the session fully in memory, ships the finished harness file to the remote, and launches the harness over an interactive ssh -t. The remote needs only sshd + the harness — no path installed, no Pathbase access, no temp files.

How it works (run_remote)

  1. Resolve + project on the host — the session id and JSONL come from one in-memory projection (cmd_export::claude_session_jsonl); the id is a pure function of the document, so host and remote agree on it by construction.
  2. Preflight — resolve the remote home over the typed transport; first remote touch, so reachability/auth errors fail fast with context.
  3. Ship — write <home>/.claude/projects/<sanitized-cwd>/<id>.jsonl via libssh2 (SFTP, with an SCP-protocol + minimal-exec fallback for servers whose SFTP channel won't open). The project-dir sanitization mirrors Claude Code's own and is pinned against toolpath-claude's PathResolver by a unit test.
  4. Launch — interactive ssh -t host '[cd <cwd> && ]claude -r <id>' — the one step on the real ssh binary, since it needs the user's TTY and full ssh config.

Transport notes

  • Typed libssh2 calls (remote_home / remote_mkdirs / remote_write on ExecStrategy) instead of composed shell strings — same ethos as git2 over shelling out to git.
  • Natively parses the HostName/User/Port/IdentityFile subset of ~/.ssh/config (Host blocks, */?/! globs, first-value-wins, ~ expansion) and matches configured identities against the agent by public-key blob — required for key-pinned hosts (e.g. exe.dev, which identifies the account by key). ssh2-config was rejected for hard-depending on git2 0.20.
  • Bounded connect/per-op timeouts, one cached connection per target, tolerant exec exit codes for minimal sshds.
  • --harness is required and currently must be claude; --cwd keys the remote project dir and the launch cd (both created if missing).

Detachable sessions (--tmux)

--tmux wraps the remote launch in tmux new-session -A -s path-<id> … so the session survives SSH disconnects: detach with ctrl-b d, re-run the same resume command to re-attach (-A attaches instead of erroring). The launch argv is derived from the same per-harness invocation table the local resume uses, so remote and local can't drift.

Verified

  • All path-cli suites green (35+ new/updated unit + integration tests, incl. ssh-config parser, URL parsing, SFTP orchestration via a typed RecordingExec); clippy -D warnings clean.
  • Live end-to-end against a local sandboxed sshd (SFTP path) and a real exe.dev VM (config-pinned identity, ship, claude -r launched on the VM) — including the full --tmux loop: resume → work → detach → re-attach on the VM.

The branch keeps the v0→v3 evolution as separate commits (v0: remote path resume; v1: pipe JSON + remote incept; v3: ship projected files, no remote path) per the issue's roadmap.

Versioning

path-cli 0.16.0 → 0.17.0 (0.16.0 taken by #133); toolpath-claude 0.12.1 → 0.12.2 (carries #141's empty-seed fix, extended here with root-of-chain parentUuid handling). New deps: ssh2, base64.


Stack (review bottom-up): #141 empty-seed projection fix → #142 opencode test isolation → #145 path resume --remote.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

bdelanghe and others added 17 commits July 23, 2026 14:02
Claude Code streams an assistant message as several JSONL lines — the
first an empty "seed" (text: "") superseded by the real-text line, both
sharing one message.id. The projector wrote that seed back as
content:[{"type":"text","text":""}], an API-invalid message: on the next
turn of a *resumed* session Claude replays the whole transcript and
Anthropic rejects the empty text block with

    400 messages: text content blocks must be non-empty

so a resumed session couldn't take a second turn.

The projector now skips any assistant turn with no text, thinking,
tool-uses, delegations, or file-mutations (and no attached tool-result
events), re-linking the following turn's parentUuid to the dropped seed's
parent via the existing parent_rewrites machinery so the uuid chain stays
intact. The group token total is re-expanded onto the surviving line as
before.

Verified end-to-end by re-exporting a real captured session: empty text
blocks 1 -> 0, chain re-linked past the seed. toolpath-claude 0.12.0 ->
0.12.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dispatch a resume to a remote host that has `path` installed, over SSH.
The host builds an `ssh` invocation and hands off; the remote does the
resolve, harness pick, projection, and exec.

Host/remote arg split:
- `--remote` is the only host-side arg (never forwarded — would recurse)
- every other arg (input, --harness, --cwd, --no-cache, --force, --url)
  is forwarded into the far-side `path resume`
- `--harness` is required with `--remote`: the remote picker has no TTY
  over a non-interactive SSH session, and the host can't pick either
  (v0 never resolves the doc locally), so fail fast on the host

Toward empathic#140.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`path resume --remote` now probes `ssh host 'path --version'` before the
interactive handoff and echoes `host path: <X> / remote path: <Y>`. This
confirms SSH is reachable and `path` is installed remotely; a failed probe
aborts with a clear error instead of dropping the user into a doomed
session.

Adds a `capture` method to the `ExecStrategy` seam (RealExec runs the
command and returns trimmed stdout; RecordingExec records probes and can
simulate a failing probe) so both the probe and the dispatch stay
testable without touching a real host.

Toward empathic#140.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1 replaces v0's forward-the-ref mechanism. Instead of the remote
resolving the document from Pathbase, the host now resolves it locally
and ships the hydrated JSON, so the remote needs only `path` + the target
harness installed — no Pathbase access.

Flow (run_remote):
1. resolve + validate the doc on the host (fail fast on bad/non-agent
   input, before touching SSH);
2. version preflight `ssh host 'path --version'` (echo + abort on failure,
   unchanged from the prior commit);
3. stage the JSON via `ssh host 'cat > /tmp/toolpath-resume-<uuid>.json'`
   piped on stdin — best-effort cleanup (execvp precludes a trailing rm);
4. execvp interactive `ssh -t host 'path resume <tempfile> --harness X
   [-C cwd]'` so the remote harness gets a real terminal.

Only --cwd forwards alongside the staged file; the resolution-only flags
(--no-cache/--force/--url) are moot once the doc is a local file on the
remote and are dropped.

ExecStrategy gains `pipe` (stdin staging) alongside `capture`; RecordingExec
records staged bytes so the whole probe→stage→dispatch sequence is testable
without a real host. Verified end-to-end against the real binary: resolves a
Pathbase URL, probes, and errors clearly when the remote is unreachable.

Toward empathic#140.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The opencode `PathResolver` prefers `$XDG_DATA_HOME` over `$HOME` when
locating `opencode.db`. `ScopedHome` only pinned `$HOME`, so on a machine
that sets `$XDG_DATA_HOME` (common on Linux; set on this dev box) the
opencode resume test escaped its sandbox: it seeded — and the projector
wrote into — the *real* user database. The seed's `CREATE TABLE` then hit
the already-populated real DB and failed with "table project already
exists" (green in CI where XDG is unset, red locally), and a fresh-DB run
would silently mutate the user's live opencode data.

Pin `$XDG_DATA_HOME` to `<tempdir>/.local/share` in ScopedHome (restored
on drop) so every harness — opencode included — stays sandboxed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the v1 temp-file staging (`ssh 'cat > /tmp/…'` + remote
`path resume <tempfile>`) with a single pipe into `path p incept
claude` on the remote, then launch `claude -r <id>` directly over an
interactive `ssh -t`.

The host computes the session id locally: it's a pure function of the
document (the projector takes it verbatim from the conversation view),
so host and remote projecting the same bytes agree — new
`cmd_export::claude_session_id` pins that. Claude-only for now;
other harnesses error clearly before any SSH.

`--cwd` does double duty as incept's --project dir and the launch's
cd target; absent, both default to the remote ssh cwd ($HOME).
Deletes remote_temp_path / remote_staged_command / harness_value.

Verified live against a local sshd sandbox: version echo, incept
writes the session under the remote ~/.claude/projects keyed on -C,
host and remote ids identical, correct `ssh -t … claude -r` launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resuming into a fresh directory is the normal case, but incept
canonicalizes its --project path and bailed when the dir didn't exist
on the remote. Prefix the hydrate command with `mkdir -p`.

Verified live: `--remote … -C /tmp/somewhere` with no such dir now
incepts and launches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The good version from the roadmap: the host resolves AND projects the
Claude session fully in memory (`cmd_export::claude_session_jsonl`),
probes the remote with plain `pwd` (reachability + default launch
dir), ships the finished JSONL straight into
`$HOME/.claude/projects/<sanitized-cwd>/<id>.jsonl` via the existing
ssh pipe, and launches `claude -r <id>` over an interactive `ssh -t`.

The remote needs only sshd and the harness — no `path`, no Pathbase,
no temp files. The project-dir key mirrors Claude Code's sanitization;
a unit test pins it against toolpath-claude's PathResolver so the two
can't drift. Replaces the v2 incept flow (claude_session_id →
claude_session_jsonl, remote_incept_command → remote_ship_command +
claude_project_dir_name).

Verified live against the sshd sandbox: probe echo, 150KB JSONL landed
under the remote's ~/.claude/projects keyed on -C, correct launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2's incept created the cwd as a side effect; v3's ship only creates
the Claude projects dir, so `cd '<cwd>'` failed on a fresh remote
directory. Create it in the launch command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…trings

Replace the composed remote shell commands (`pwd`, `mkdir -p … &&
cat > …`) with typed libssh2 SFTP calls, matching the repo's
git2-over-shelling-out ethos. ExecStrategy loses capture/pipe and
gains remote_home / remote_mkdirs / remote_write against a parsed
SshTarget (new parse_ssh_url; ssh_invocation_tty rebuilt on it).

The preflight is now an SFTP realpath of the remote home (first
remote touch → reachability/auth errors surface there, agent auth
with a clear ssh-add hint); the session file and a pinned --cwd are
created over SFTP. The only remaining remote shell string is the
interactive launch (`cd <cwd> && claude -r <id>`), which stays on
the real ssh binary because it needs the user's TTY and ssh config.

RecordingExec now records typed values (targets, dirs, path+bytes)
instead of shell strings. Adds ssh2 to the workspace.

Verified live against the sshd sandbox: agent auth, home resolved,
JSONL written and launch dir created over SFTP, correct launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…transport

Two real-world fixes from testing against an exe.dev VM:

1. libssh2 reads no ssh config, so agent auth authenticated with
   whatever key the agent listed first — and key-pinned hosts (exe.dev
   identifies the account BY the key) routed the session to an
   unregistered identity. The transport now natively parses the
   HostName/User/Port/IdentityFile subset of ~/.ssh/config (Host
   blocks, */?/! globs, first-value-wins, ~ expansion — no new deps
   beyond base64; ssh2-config was rejected for hard-depending on
   git2 0.20), matches configured identities against the agent by
   public-key blob, and only falls back to any-key agent auth.

2. Servers whose SFTP channel won't open get an SCP-protocol upload
   (libssh2 scp_send) plus a minimal exec channel for pwd/mkdir -p —
   still no external binaries. Connections are now bounded (connect
   and per-op timeouts) and cached per target instead of re-dialing,
   and exec exit codes are trusted only when there's no output (some
   minimal sshds report bogus statuses).

Verified live against a real exe.dev VM end-to-end: correct identity
picked, home resolved, session shipped, and  launched on
the VM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rom harness table

- `--tmux` (requires --remote) wraps the remote launch in
  `tmux new-session -A -s path-<id> '…'` so the session survives SSH
  disconnects and can be detached; `-A` re-attaches on a re-run.
- `remote_launch_command` now derives binary+argv from the same
  per-harness invocation table the local resume uses (name() +
  argv_for) instead of hardcoding `claude -r`, so the two can't
  drift when more harnesses go remote.
- New `shell_quote` quotes only when needed, keeping the echoed
  recipe human-readable.

Verified live against the exe.dev VM: correct tmux launch line issued.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the release checklist: crate version, workspace dep entry,
site/_data/crates.json, and a CHANGELOG section covering --remote
(host-side projection + libssh2 SFTP/SCP transport, ssh_config-aware
auth) and --tmux (detachable remote sessions). CLAUDE.md's resume
bullet now documents both flags. Site builds 11 pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six findings from the branch review:

- **auth misroute (normal):** authenticate() no longer falls back to
  any-key agent auth when IdentityFile(s) are configured or
  IdentitiesOnly is set — pinned-but-failed auth now errors instead of
  silently trying an arbitrary agent key (the exact misroute the config
  parsing exists to prevent). Also parse IdentitiesOnly and fix the
  `Key = value` (padded `=`) split that mangled IdentityFile paths.
- **path traversal (normal):** validate the projected Claude session id
  (`[A-Za-z0-9_-]`, ≤128, no leading dot) at the write chokepoints
  (claude_session_jsonl + write_into_claude_project) so a malicious
  third-party doc can't escape the session dir over SFTP or locally.
- **--cwd misroute (normal):** normalize the remote cwd (trailing
  slash / `.` / `..` / relative-to-home) to the absolute path the
  remote shell lands on, so the host's project-dir key matches what
  remote Claude derives from getcwd. Used for both the shipped-file key
  and the launch `cd`.
- **known_hosts (nit):** verify the server host key against
  ~/.ssh/known_hosts before shipping the transcript (the SFTP upload
  precedes the interactive ssh's own check); abort on mismatch,
  accept-new on first contact.
- **chain root (nit):** the empty-seed drop now records a rewrite even
  for a rootless seed (parent_rewrites value is Option), so a surviving
  child inherits None instead of dangling on the never-emitted seed id.
- **stale docstring (nit):** rewrite run_remote's v2 doc to v3 and drop
  the link to the renamed claude_session_id (fixes cargo doc warnings).

New unit tests for each; full workspace green, cargo doc -D warnings
clean, re-verified live against the exe.dev VM (incl. trailing-slash
--cwd now shipping to the correct project dir).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Some sshds authenticate a key at the transport layer but answer every
command with a notice instead of running it — e.g. exe.dev replies
`Please complete registration by running: ssh exe.dev` for a key not
yet bound to an account. Without a check that banner became a session
path component and failed deep in the file ship. validate_remote_home
requires a single-line absolute path, failing early with the offending
output and a hint about unregistered keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…resume

# Conflicts:
#	CHANGELOG.md
#	CLAUDE.md
#	Cargo.toml
…s session_chain)

Main bumped toolpath-claude to 0.12.1 for `ClaudeConvo::session_chain`;
the merge silently unified that with this branch's empty-seed fix,
which also claimed 0.12.1. Give the empty-seed fix its own 0.12.2 so
the two changes don't collide on one version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… review)

Same cleanup the empathic#141 reviewer asked for, applied here where the
projector's seed-drop check lives in its improved (root-of-chain) form:
promote the intrinsic predicate to Turn::is_content_empty() on
toolpath-convo and keep the tool-result-events check at the call site.
toolpath-convo 0.11.1 → 0.12.0 (additive), with a unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bdelanghe
bdelanghe force-pushed the bdelanghe/140-remote-resume branch from 44b2adb to 05d7507 Compare July 24, 2026 15:46
bdelanghe and others added 9 commits July 24, 2026 12:03
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eserved)

Adds Transport enum with Ssh, Mosh, Et variants (derives Debug, Clone, Copy,
PartialEq, Eq, clap::ValueEnum) and launch_invocation function that:
- Ssh: delegates to existing ssh_invocation_tty(remote, remote_cmd, true)
- Mosh/Et: return anyhow::bail with "not yet supported" message

Includes test_launch_invocation_ssh_and_deferred_transports validating the
behavior. Task 9 will wire launch_invocation into the remote resume flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add pure functions for Task 9:
- persist_candidates: assembles DISPLAY_ORDER filtered to available + Plain
- preferred_backend: picks first of [Tmux, Zellij, Abduco, Dtach] or Plain

Test: persist_candidates_and_preference passes (available {dtach,zellij}
→ candidates [Zellij, Dtach, Plain], preferred Zellij; empty → [Plain],
preferred Plain; {tmux,shpool} → preferred Tmux).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bdelanghe and others added 5 commits July 24, 2026 12:03
Captures the four-layer model (reachability/connection-survival/session-survival/
workspace) and the tool landscape behind --persist and --via: tmux/zellij/shpool/
abduco/dtach, mosh/et/ssh3, tailscale/wireguard/cloudflare/teleport, and the
libssh2 ProxyCommand ship limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rent rebase

The design spec (2026-07-23-remote-persistence-picker-design.md) and the
exe.dev/Sprite targets runbook were committed earlier on this branch but
dropped when the branch was rebased by concurrent work. Restored from the
dangling commits (5a796a6, 42086c4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y; sanitize session name

Reorders run_remote so the persistence backend is resolved before the
session file ships, and makes a remote_which probe failure fall back
to Plain instead of aborting a resume that previously never needed an
exec channel. Validates --via up front so mosh/et bail before any
remote side effects. Sanitizes the multiplexer/session name (and the
dtach socket path) to [A-Za-z0-9_-] so a crafted session id can't
steer the zellij layout file write. Drops the misleading close_on_exit
mention from the zellij layout comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

Add support for remotely starting a toolpath via path resume

1 participant