feat(resume): remote persistence & transport picker (--persist / --via)#146
Draft
bdelanghe wants to merge 40 commits into
Draft
feat(resume): remote persistence & transport picker (--persist / --via)#146bdelanghe wants to merge 40 commits into
bdelanghe wants to merge 40 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>
… 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>
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>
Assert describe() for every variant (Plain/Tmux/Abduco/Dtach/Zellij/ Shpool) and that each blurb leads with the backend's name; length-check against DISPLAY_ORDER so a new backend can't be added without a test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd --via et gating Three end-to-end run_remote tests that the per-method unit tests didn't reach: (1) --via et fails before any remote touch (no probe/ship/launch); (2) with no --persist, the remote_which probe auto-selects the preferred backend (tmux over zellij) and wraps the launch; (3) with no backend available, it ships + launches plain (claude -r, no wrapper). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live testing against a macOS sshd surfaced a real bug: with `--cwd /tmp/x`, the session shipped to `~/.claude/projects/-tmp-x/` but `claude -r` (which keys on the *physical* cwd) looked in `-private-tmp-x/` (macOS `/tmp` → `/private/tmp`) and reported "No conversation found" — a silent resume failure. Fix: new `ExecStrategy::remote_realpath` (SFTP realpath, with a `cd && pwd -P` fallback for SFTP-less hosts) canonicalizes the launch cwd after creating it; the shipped project dir and the launch `cd` now use that physical path. No --cwd → the already-canonical SFTP home. Verified end-to-end: `path resume --remote --persist tmux -C /tmp/…` now ships to the `-private-tmp-…` dir and `claude -r` resumes the shipped session (replied to a print-mode prompt). Adds a RecordingExec `with_realpath` seam + regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mosh was a reserved seam; wire it up. launch_invocation gains mosh_invocation: mosh owns the TTY (no ssh -t), a custom SSH port rides mosh's `--ssh` handshake override (its own -p is the UDP range), and the persist-wrapped remote command runs via `sh -c` after `--`. Ship/probe still go over SFTP; only the interactive launch changes client. Verified live against a local mosh-server: the client connects over the box's custom SSH port and runs the remote command (claude on PATH). Unit test pins the argv shape; integration test asserts run_remote launches the mosh client + ships. Docs/CHANGELOG updated (mosh no longer reserved; et still is). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the transport decision: --via implied routing, but this flag selects a *carrier protocol* for the interactive byte stream — nothing else. Rename to --transport; the name polices its own boundary (teleport/tailscale aren't transports). - Transport enum reduces to a single `Ssh` variant (default). The flag stays as the extension seam — a future carrier is a variant + match arm — but nothing is reserved: drop the `mosh`/`et` values and their bespoke error strings. Unknown input now gets clap's generic `invalid value 'X' … [possible values: ssh]`. - Reverts the just-added mosh transport (mosh_invocation + tests): transport is low-stakes for an agent TUI — `claude -r` reconstructs the session, and what must survive a disconnect is the *process* (tmux/dtach's job, --persist). Not worth a named reservation. - launch_invocation loses its error arms (single transport); run_remote drops the now-moot --via pre-validation. Field/flag/help/docs updated. Internal design-doc prose about the excluded transports is left for a separate editorial pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
libssh2 dials a raw TCP socket and can't traverse ProxyJump/ ProxyCommand, so a host reachable only through a bastion/tunnel/mesh launched fine (the ssh CLI honors config) but silently FAILED at the probe/ship step. Close the gap: - RemoteConn becomes an enum: Libssh2 (SFTP/SCP as before) | Cli. - connect_remote returns Cli when ssh_config declares a ProxyJump/ ProxyCommand for the host, OR when a direct libssh2 dial fails. - Cli mode runs every file op through the ssh binary (pwd, mkdir -p, cat > dest, cd && pwd -P, command -v) — honors full ~/.ssh/config. - ssh_config parser learns has_proxy. Unit tests: proxy detection + ssh-CLI argv shape. CLI ship primitives verified live over the real ssh binary against the sandbox. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The job is narrow — hold one `claude -r` PTY across a disconnect — and Claude collapses the usual multiplexer trade-offs: `claude -r` is the crash-recovery layer (state lives in Claude Code's session files, not the terminal), and Claude's Ink TUI repaints itself. That deletes tmux's two distinguishing wins (remain-on-exit post-mortem, server-side repaint), leaving tmux (tested default, ubiquitous, introspectable) and dtach (no daemon, socket-is-the-API, argv-exec) as the only backends that earn their place, plus plain. - Drop Abduco/Zellij/Shpool from PersistBackend + bin/describe/ DISPLAY_ORDER/preferred_backend; delete zellij_plan/shpool_plan. - PersistPlan struct collapses to a plain String (no more extra_file/ post_note plumbing in run_remote — those existed only for zellij/ shpool). - dtach upgraded to `-A <socket> -r winch sh -c '…'` (-r winch makes the self-repainting TUI redraw on reattach; was -z). Both shipped backends verified live: tmux (persistence + real claude -r resume) earlier; dtach now (socket lifecycle + argv fidelity — `-r` and the session id arrive as separate args, so the quoting-class bug can't exist). Tests + module docs updated; full path-cli suite + clippy green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the transport/backend decisions, the repo shouldn't carry the non-goals/exploration prose (the excluded tools live in the decision record + CHANGELOG + git history, not the tree): - Remove remote-session-persistence-landscape.md (a survey of tools we evaluated and mostly rejected), the six-backend picker design spec, and its plan doc — all superseded by the shipped tmux+dtach+plain / ssh-only design documented in the code + CHANGELOG. - Trim remote-resume-targets.md to the verified exe.dev runbook: fix the stale 'path needed on the remote' claim (v3 ships the session — remote needs only sshd + claude), drop the design-spec pointer, note the proxied-host ssh-CLI fallback, and cut the speculative Sprite section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bdelanghe
force-pushed
the
bdelanghe/remote-persistence-picker
branch
from
July 25, 2026 21:45
1e72e5e to
4a1c806
Compare
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.
Follow-up to #145 (
path resume --remote). Adds a persistence + transport picker so a remote-resumed session can survive SSH drops and (later) ride faster transports.What it adds
--persist <backend>— wrap the remote launch in a detachable session so it survives disconnects:tmux,zellij,abduco,dtach,shpool, orplain(no persistence).--tmuxbecomes a deprecated alias for--persist tmux.--via <transport>—ssh(default), withmoshandetseams reserved (eterrors as not-yet-supported; validated early).ExecStrategy::remote_whichprobes the target over SSH for installed backends;persist_candidates/preferred_backendpick the best available so it degrades gracefully instead of failing at launch.persist_plan): direct-wrap backends (tmux/zellij/abduco/dtach) build the full run command;shpoolis attach-only with a post-note telling you to run the command yourself. Session names are sanitized.run_remote: probe → ship (SFTP) → persist-wrapped launch.Still host does all the toolpath work; the target needs only sshd + the harness, plus whichever persistence/transport binary you ask for (not
path).Testing
cargo clippy -D warningsclean; 62cmd_resumeunit tests + 13 resume integration tests pass, including per-backendpersist_plan, exhaustivePersistBackend::describe()coverage,resolve_persist_flagconflict rejection, theremote_whichprobe, andpreferred_backendselection.Versioning
path-cli0.17.0 → 0.18.0 (new--persist/--viasurface).Docs
docs/agents/remote-resume-targets.md(verified exe.dev + Sprite runbook) anddocs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md(four-layer model, transport landscape) ride along.Stack (review bottom-up): #141 empty-seed → #142 opencode test isolation → #145
path resume --remote→ this (persistence & transport picker).🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.