Skip to content

feat(cli): path resume --remote pushes a session to a remote host - #223

Open
ecalifornica wants to merge 2 commits into
mainfrom
ecalifornica/claude-retarget
Open

feat(cli): path resume --remote pushes a session to a remote host#223
ecalifornica wants to merge 2 commits into
mainfrom
ecalifornica/claude-retarget

Conversation

@ecalifornica

@ecalifornica ecalifornica commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

path resume <input> --remote <ssh-destination> [-C <remote-dir>] [--dry-run] projects a local Claude Code session onto a remote host and attaches to it under tmux. Re-running the same command reattaches to the live tmux session. Every push is a fork. The local original is untouched.

Known gap: a push over an existing remote session file replaces it when tmux is not live. An upcoming stacked branch adds keep semantics and --overwrite, ready to open if this direction holds.

Remote setup, manual today, more magical in the future. The remote needs sshd, tmux, and claude installed; the rest is seeding:

ssh exe.dev "new --name=host"

ssh user@host 'mkdir -p ~/.claude ~/work/proj'
scp -p ~/.claude/.credentials.json user@host:~/.claude/

# optional onboarding/trust seed; without it the first attach answers the prompt in tmux
jq '{hasCompletedOnboarding: true, theme: (.theme // "dark"), oauthAccount, projects: {"/home/user/work/proj": {"hasTrustDialogAccepted": true}}}' ~/.claude.json \
  | ssh user@host 'umask 077; cat > ~/.claude.json'

Demo (<input> is any resume form; a cache id shown):

# put the branch build on PATH as `path`
cargo install --path crates/path-cli

path resume claude-<session-id> --remote user@host --dry-run   # print the plan, touch nothing
path resume claude-<session-id> --remote user@host             # push, launch, attach
# detach with ctrl-b d
path resume claude-<session-id> --remote user@host             # after detach: reattach

-C names the remote project dir (absolute, physical); the default is the local cwd with the home prefix swapped to the remote $HOME.

Design for first pass:

  • The local host does all toolpath work: project in memory, mint a fork id, stamp id and cwd (Conversation::set_session_id_and_cwd), ship the JSONL over ssh's stdin. The remote does not run path.
  • Transport is the user's ssh binary, invoked with shell-string commands.
    • A typed transport (an ssh crate instead of strings) is the intended future state; today it would need its own ssh-config parser to match what the binary gives us for free (ProxyJump, aliases, host keys), so v1 delegates.
  • One batched read-only preflight captures $HOME, the absolute claude path, tmux presence, the physical project path, and tmux session liveness.
    • Some hosts answer every ssh command with a notice instead of running it (exe.dev replies "Please complete registration..." for an unregistered key); preflight rejects any captured value that is not a one-line absolute path, so that notice text becomes an error message, not a piece of the target file path.
  • The fork id is a UUID from the SHA-256 of the key-sorted document, so an unchanged re-push targets the same remote file.
  • A document is untrusted input: its strings (session ids, paths) end up inside remote shell commands, so every remote argument is single-quote escaped to make injection inert, and the shipped file lands 0600 so a conversation log is not world-readable on a shared host.

@ecalifornica
ecalifornica force-pushed the ecalifornica/claude-retarget branch from 726d6dd to 54942ed Compare August 18, 2026 01:54
@ecalifornica ecalifornica changed the title feat(claude): Conversation::retarget rewrites session id and cwd in place feat(claude): Conversation::retarget rewrites session ID and cwd in place Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

🔍 Preview deployed: https://aec1bf22.toolpath.pages.dev

Comment thread crates/toolpath-claude/src/types.rs Outdated
/// segment chain. Message content and tool-result payloads are
/// untouched, so paths quoted inside them keep referring to the
/// source machine.
pub fn retarget(&mut self, session_id: &str, cwd: &str) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"retarget" isn't imminently clear. This feels like a sort of move operation, but what are we moving?

What's the goal of this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to set_session_id_and_cwd.

Updated the PR to include the goal: path resume --remote projects the session into a fork and ships to the remote host.

@akesling akesling assigned ecalifornica and unassigned akesling Aug 18, 2026
Library (0.12.3):

- Conversation::set_session_id_and_cwd rewrites an in-memory
  conversation to a new session ID and working directory. It sets the
  conversation-level session_id, sets session_id on every entry,
  sets project_path, replaces entry cwd values where present, rewrites
  top-level sessionId and cwd keys in preamble raw lines, and clears
  the segment list session_ids. Message content and tool-result
  payloads stay untouched.
- Slug-pinning tests cover PathResolver::conversation_file with a
  foreign home and a remote cwd. The resolver slugs the string it is
  given, so callers pass the physical cwd.
@ecalifornica
ecalifornica force-pushed the ecalifornica/claude-retarget branch from 73a747d to 9a0bf84 Compare August 19, 2026 17:50
@ecalifornica ecalifornica changed the title feat(claude): Conversation::retarget rewrites session ID and cwd in place feat(cli): path resume --remote pushes a session to a remote host Aug 19, 2026
path resume <input> --remote <ssh-destination> [-C <remote-dir>]
[--dry-run] projects a local Claude Code session onto a remote host
and attaches to it under tmux. Re-running the same command
reattaches. Every push is a fork; the local original is untouched.

path-cli (0.19.0):

- cmd_resume/remote.rs holds the push flow: preflight, shell
  quoting, id minting, home-swap default for -C, ship, detached
  launch, attach, reattach shortcut, --dry-run. projection.rs holds
  the in-memory Path -> Conversation -> JSONL projection shared with
  p export claude.
- The local host does all toolpath work. The conversation is
  projected in memory, stamped with a minted session id and the
  remote project directory, and shipped over ssh's stdin. The remote
  does not run path.
- One batched, read-only preflight call captures $HOME, the absolute
  claude path, tmux presence, the physical project path (pwd -P),
  and tmux session liveness. Captured values must be single-line
  absolute paths before they become path components; a login banner
  errors verbatim.
- The remote id is a UUID from the SHA-256 of a key-sorted canonical
  serialization of the parsed document, so unchanged content targets
  the same remote file across invocations and input shapes.
- Every remote argument passes a POSIX single-quote escaping helper;
  the session file lands 0600 via umask 077. tmux targets use
  exact-name matching (-t '=name').
- run_remote takes local_home, local_cwd, and the search path as
  caller-computed parameters; the composition root reads the
  environment.
- Integration tests drive run_remote against a fake ssh shim that
  records argv and stdin per call: shipped bytes are byte-identical,
  early argument errors touch the remote zero times, a banner reply
  and a symlinked -C are rejected, --dry-run writes nothing.

toolpath-cli (0.19.0): lockstep bump of the deprecated shim.
@ecalifornica
ecalifornica force-pushed the ecalifornica/claude-retarget branch from 9a0bf84 to d2e7cec Compare August 19, 2026 18:22

let (graph, source_harness) = resolve_input(args)?;
let path = ensure_path_with_agent(&graph)?;
if args.harness.is_none() && source_harness != Some(Harness::Claude) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude specific for this first pass.

if conv.session_id.is_empty() {
bail!("projected session has no id");
}
let tmux_name = tmux_session_name(&conv.session_id);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tmux specific for this first pass.

Comment on lines +113 to +119
let search_path: Vec<PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default();
let local_home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from);
let local_cwd = std::env::current_dir()?;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace with Config after that refactor lands.

.map_err(|e| anyhow::anyhow!("Projection failed: {}", e))
}

pub(crate) fn serialize_jsonl(conv: &toolpath_claude::Conversation) -> Result<String> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should move to toolpath_claude::Conversation

use crate::harness::Harness;

/// Entry point for `path resume --remote`.
pub fn run_remote(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public for integration tests.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either let's refactor this so we can unit test it reasonably here, or have an integration test that works on path-cli that let's us control calls into remote. This feels like the wrong boundary to surface "publicly" that will be fragile over the changes we expect to land on this crate.


/// `path-<short8>`: the first 8 characters of the source session id,
/// with anything outside `[A-Za-z0-9_-]` replaced by `-`.
pub fn tmux_session_name(source_session_id: &str) -> String {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public for integration tests.

/// bits. Callers pass the canonical serialization of the parsed
/// document, so unchanged content mints the same id across
/// invocations and input shapes.
pub fn mint_remote_id(doc_json: &str) -> String {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public for integration tests.

eprintln!("Dry run: nothing was written or launched.");
return Ok(());
}
return exec.exec(&ssh.binary.to_string_lossy(), &attach_args, local_cwd);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exec instead of SshRunner because attach is interactive, needs the user's terminal, and SshRunner captures output.

fn test_conversation_file_foreign_home() {
// A resolver over another machine's home computes that machine's
// session-file layout; nothing here touches the local filesystem.
let resolver = PathResolver::new().with_home("/home/exedev");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it important that this home is the same as the one in the paths below? What's the correspondence? Maybe explain what is expected or choose constants that make the correspondence more obvious when debugging?

/// segment chain. Message content and tool-result payloads are
/// untouched, so paths quoted inside them keep referring to the
/// source machine.
pub fn set_session_id_and_cwd(&mut self, session_id: &str, cwd: &str) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the semantic operation we're actual intending here? As phrased, this combined setter makes more sense as a helper where this is called than as a general purpose member of Conversation, but maybe there's a generally valuable op that consumers might want?

}

let mut conv = conv;
conv.set_session_id_and_cwd(&remote_id, &project_path);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the implementation, this appears to hide the truth about what is being set (there are more fields being juggled than these two... of which cwd isn't a field).


let dir = remote_dir_spec(args.cwd.as_deref(), local_cwd, local_home)?;

let ssh = find_binary("ssh", search_path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just call this ssh_bin or something and don't shadow. Alternatively, this could just be inline without ever getting a top level name here.

remote: &'a str,
}

impl SshRunner<'_> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull this out into it's own ssh module + helpers so namespacing makes it clear that it's A) ours and B) keeps everything in one place.

);
}

exec.exec(&ssh.binary.to_string_lossy(), &attach_args, local_cwd)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this not using the ssh helper?

};
let output = child
.wait_with_output()
.with_context(|| format!("wait for {}", self.binary.display()))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the remote doesn't close, do we hang forever?

/// `spawn` with a retry on `ExecutableFileBusy`. Parallel test threads
/// can hold a freshly written ssh shim open across another thread's
/// fork-to-exec window; retrying is harmless in production.
fn spawn_retrying_busy(cmd: &mut Command) -> std::io::Result<std::process::Child> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want to keep this, inline it. It doesn't make sense as a helper here.

})
}

fn preflight_transcript(output: &Output) -> String {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name doesn't quite reflect it's behavior. Maybe "concatenate_stdout_and_stderr"? Or refactor it to take an array of handles or something to concat?

use crate::harness::Harness;

/// Entry point for `path resume --remote`.
pub fn run_remote(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either let's refactor this so we can unit test it reasonably here, or have an integration test that works on path-cli that let's us control calls into remote. This feels like the wrong boundary to surface "publicly" that will be fragile over the changes we expect to land on this crate.

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.

2 participants