feat(cli): path resume --remote pushes a session to a remote host - #223
feat(cli): path resume --remote pushes a session to a remote host#223ecalifornica wants to merge 2 commits into
Conversation
726d6dd to
54942ed
Compare
|
🔍 Preview deployed: https://aec1bf22.toolpath.pages.dev |
54942ed to
73a747d
Compare
| /// 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) { |
There was a problem hiding this comment.
"retarget" isn't imminently clear. This feels like a sort of move operation, but what are we moving?
What's the goal of this?
There was a problem hiding this comment.
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.
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.
73a747d to
9a0bf84
Compare
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.
9a0bf84 to
d2e7cec
Compare
|
|
||
| let (graph, source_harness) = resolve_input(args)?; | ||
| let path = ensure_path_with_agent(&graph)?; | ||
| if args.harness.is_none() && source_harness != Some(Harness::Claude) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
tmux specific for this first pass.
| 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()?; |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
This should move to toolpath_claude::Conversation
| use crate::harness::Harness; | ||
|
|
||
| /// Entry point for `path resume --remote`. | ||
| pub fn run_remote( |
There was a problem hiding this comment.
Public for integration tests.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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<'_> { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Why is this not using the ssh helper?
| }; | ||
| let output = child | ||
| .wait_with_output() | ||
| .with_context(|| format!("wait for {}", self.binary.display()))?; |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
If we want to keep this, inline it. It doesn't make sense as a helper here.
| }) | ||
| } | ||
|
|
||
| fn preflight_transcript(output: &Output) -> String { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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 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:
Demo (
<input>is any resume form; a cache id shown):-Cnames 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:
Conversation::set_session_id_and_cwd), ship the JSONL over ssh's stdin. The remote does not runpath.sshbinary, invoked with shell-string commands.$HOME, the absolute claude path, tmux presence, the physical project path, and tmux session liveness.