feat(resume): path resume --remote <ssh-url> — resume a session on a remote host#145
Open
bdelanghe wants to merge 32 commits into
Open
feat(resume): path resume --remote <ssh-url> — resume a session on a remote host#145bdelanghe wants to merge 32 commits into
path resume --remote <ssh-url> — resume a session on a remote host#145bdelanghe wants to merge 32 commits into
Conversation
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>
This was referenced Jul 24, 2026
… 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
force-pushed
the
bdelanghe/140-remote-resume
branch
from
July 24, 2026 15:46
44b2adb to
05d7507
Compare
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…and (direct-wrap backends)
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #140.
Adds
--remote ssh://[user@]host[:port]topath 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 interactivessh -t. The remote needs only sshd + the harness — nopathinstalled, no Pathbase access, no temp files.How it works (
run_remote)cmd_export::claude_session_jsonl); the id is a pure function of the document, so host and remote agree on it by construction.<home>/.claude/projects/<sanitized-cwd>/<id>.jsonlvia 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 againsttoolpath-claude'sPathResolverby a unit test.ssh -t host '[cd <cwd> && ]claude -r <id>'— the one step on the realsshbinary, since it needs the user's TTY and full ssh config.Transport notes
remote_home/remote_mkdirs/remote_writeonExecStrategy) instead of composed shell strings — same ethos asgit2over shelling out to git.HostName/User/Port/IdentityFilesubset 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-configwas rejected for hard-depending on git2 0.20.--harnessis required and currently must beclaude;--cwdkeys the remote project dir and the launchcd(both created if missing).Detachable sessions (
--tmux)--tmuxwraps the remote launch intmux new-session -A -s path-<id> …so the session survives SSH disconnects: detach withctrl-b d, re-run the same resume command to re-attach (-Aattaches 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
RecordingExec); clippy-D warningsclean.claude -rlaunched on the VM) — including the full--tmuxloop: 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 remotepath) per the issue's roadmap.Versioning
path-cli0.16.0 → 0.17.0 (0.16.0 taken by #133);toolpath-claude0.12.1 → 0.12.2 (carries #141's empty-seed fix, extended here with root-of-chainparentUuidhandling). New deps:ssh2,base64.Stack (review bottom-up): #141 empty-seed projection fix → #142 opencode test isolation → #145
path resume --remote.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.