Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ All notable changes to the Toolpath workspace are documented here.
## `path resume --remote` pushes a session to a remote host — 2026-08-17

`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
never touched.
[--dry-run] [--overwrite]` 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, or relaunches the
session from the existing remote file when tmux has exited. Every
push is a fork; the local original is never touched.

- **`path-cli`** (0.19.0):
- The local host does all toolpath work: it projects the
Expand All @@ -29,6 +30,11 @@ never touched.
re-pushing unchanged content targets the same remote file across
invocations and input shapes. The tmux session is `path-<short8>`
of the source session id.
- The ship step writes only when the target file is absent or
smaller than the local serialization (a partial earlier ship). An
existing file at full size or larger is kept with a notice and
the session launches on it, so remote-side progress survives a
re-push. `--overwrite` replaces the file unconditionally.
- `-C` names the remote directory (absolute, physical; a symlinked
value errors and names the physical path). The default is the
local cwd with the local home prefix replaced by the remote
Expand Down
12 changes: 12 additions & 0 deletions crates/path-cli/src/cmd_resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ pub struct ResumeArgs {
#[arg(long, requires = "remote")]
pub dry_run: bool,

/// With --remote: replace an existing remote session file with
/// this push's content instead of keeping it. Without this flag
/// an existing file is kept (remote-side progress survives) and
/// the session launches on it.
#[arg(long, requires = "remote")]
pub overwrite: bool,

/// Skip the cache entirely when fetching from Pathbase: don't read
/// an existing entry, don't write the fetched body. Useful for
/// ephemeral environments where you don't want the cache to grow.
Expand Down Expand Up @@ -655,6 +662,7 @@ mod tests {
url: None,
remote: None,
dry_run: false,
overwrite: false,
};

let recorder = RecordingExec::default();
Expand Down Expand Up @@ -793,6 +801,7 @@ mod tests {
url: None,
remote: None,
dry_run: false,
overwrite: false,
};
let (g, harness) = resolve_input(&args).unwrap();
let _path = ensure_path_with_agent(&g).unwrap();
Expand Down Expand Up @@ -829,6 +838,7 @@ mod tests {
url: None,
remote: None,
dry_run: false,
overwrite: false,
};
let (g, harness) = resolve_input(&args).unwrap();
let _ = ensure_path_with_agent(&g).unwrap();
Expand Down Expand Up @@ -892,6 +902,7 @@ mod tests {
url: None,
remote: None,
dry_run: false,
overwrite: false,
};
let result = resolve_input(&args);

Expand Down Expand Up @@ -922,6 +933,7 @@ mod tests {
url: None,
remote: None,
dry_run: false,
overwrite: false,
};
let err = resolve_input(&args).unwrap_err();
let s = err.to_string();
Expand Down
148 changes: 139 additions & 9 deletions crates/path-cli/src/cmd_resume/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,15 @@ pub fn run_remote(
let slug_dir = path_to_string(&resolver.project_dir(&project_path)?)?;
let target = path_to_string(&resolver.conversation_file(&project_path, &remote_id)?)?;

let ship_cmd = ship_command(&slug_dir, &target);
let mut conv = conv;
conv.set_session_id_and_cwd(&remote_id, &project_path);
let jsonl = crate::projection::serialize_jsonl(&conv)?;

let ship_cmd = if args.overwrite {
ship_overwrite_command(&slug_dir, &target)
} else {
ship_command(&slug_dir, &target, jsonl.len())
};
let launch_cmd = launch_command(&tmux_name, &project_path, &claude, &remote_id);

if args.dry_run {
Expand All @@ -180,11 +188,6 @@ pub fn run_remote(
return Ok(());
}

let mut conv = conv;
conv.set_session_id_and_cwd(&remote_id, &project_path);
let jsonl = crate::projection::serialize_jsonl(&conv)?;

eprintln!("Shipping session {remote_id} to {remote}:{target}");
let shipped = ssh.run(&ship_cmd, Some(jsonl.as_bytes()))?;
if !shipped.status.success() {
bail!(
Expand All @@ -194,6 +197,22 @@ pub fn run_remote(
String::from_utf8_lossy(&shipped.stderr).trim_end()
);
}
if args.overwrite {
eprintln!("Shipped session {remote_id} to {remote}:{target} (--overwrite)");
} else {
match parse_ship(&shipped)? {
ShipOutcome::Shipped => {
eprintln!("Shipped session {remote_id} to {remote}:{target}");
}
ShipOutcome::Kept => {
eprintln!(
"Remote session file {target} on {remote} carries this push's \
content or later progress; keeping it and launching without \
a re-ship. Pass --overwrite to replace it."
);
}
}
}

eprintln!("Launching {tmux_name} in {project_path}");
let launched = ssh.run(&launch_cmd, None)?;
Expand Down Expand Up @@ -407,14 +426,61 @@ fn validate_captured_path(value: &str, what: &str, output: &Output) -> Result<St

// ── Remote commands ──────────────────────────────────────────────────

pub(crate) fn ship_command(slug_dir: &str, target: &str) -> String {
const TAG_SHIP: &str = "TP_SHIP";
const SHIP_SHIPPED: &str = "shipped";
const SHIP_KEPT: &str = "kept";

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ShipOutcome {
Shipped,
Kept,
}

/// Conditional ship: write only when the target is absent or smaller
/// than `local_len` bytes (a partial earlier ship). A target at full
/// size or larger keeps its bytes, so remote-side progress survives a
/// re-push. Both branches drain stdin and report one
/// `TP_SHIP=shipped` or `TP_SHIP=kept` line.
pub(crate) fn ship_command(slug_dir: &str, target: &str, local_len: usize) -> String {
format!(
"umask 077; if [ ! -e {t} ] || [ $(wc -c < {t}) -lt {local_len} ]; then \
mkdir -p {d} && cat > {t} && printf '{tag}={shipped}\\n'; \
else cat > /dev/null; printf '{tag}={kept}\\n'; fi",
Comment on lines +445 to +448

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.

ew

d = sh_quote(slug_dir),
t = sh_quote(target),
tag = TAG_SHIP,
shipped = SHIP_SHIPPED,
kept = SHIP_KEPT,
)
}

/// Unconditional ship for `--overwrite`: replace the target with the
/// bytes on stdin.
pub(crate) fn ship_overwrite_command(slug_dir: &str, target: &str) -> String {
format!(
"umask 077; mkdir -p {} && cat > {}",
sh_quote(slug_dir),
sh_quote(target)
)
}

/// Parse the one `TP_SHIP=` line a conditional ship prints. Anything
/// else errors with the output verbatim.
pub(crate) fn parse_ship(output: &Output) -> Result<ShipOutcome> {
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.trim();
if line == format!("{TAG_SHIP}={SHIP_SHIPPED}") {
Ok(ShipOutcome::Shipped)
} else if line == format!("{TAG_SHIP}={SHIP_KEPT}") {
Ok(ShipOutcome::Kept)
} else {
bail!(
"unexpected ship output from the remote; output was:\n{}",
preflight_transcript(output)
);
}
}

pub(crate) fn launch_command(
tmux_name: &str,
cwd: &str,
Expand Down Expand Up @@ -794,8 +860,8 @@ mod tests {
// ── remote command construction ──────────────────────────────────

#[test]
fn ship_command_makes_dir_and_writes_under_umask() {
let cmd = ship_command(
fn ship_overwrite_command_makes_dir_and_writes_under_umask() {
let cmd = ship_overwrite_command(
"/home/e/.claude/projects/-home-e-proj",
"/home/e/.claude/projects/-home-e-proj/abc.jsonl",
);
Expand All @@ -814,6 +880,70 @@ mod tests {
);
}

#[test]
fn ship_command_guards_the_write_and_quotes_paths() {
let cmd = ship_command("/d/it's", "/d/it's/abc.jsonl", 1234);
assert!(cmd.starts_with("umask 077;"), "cmd: {cmd}");
assert!(cmd.contains("! -e '/d/it'\\''s/abc.jsonl'"), "cmd: {cmd}");
assert!(cmd.contains("-lt 1234"), "cmd: {cmd}");
assert!(cmd.contains("TP_SHIP=shipped"), "cmd: {cmd}");
assert!(cmd.contains("TP_SHIP=kept"), "cmd: {cmd}");
}

#[test]
fn ship_command_semantics_through_sh() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let slug = dir.path().join("slug");
let target = slug.join("s.jsonl");
let local: &[u8] = b"line1\nline2\n";
let cmd = ship_command(
slug.to_str().unwrap(),
target.to_str().unwrap(),
local.len(),
);

let run = |input: &[u8]| {
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(input).unwrap();
child.wait_with_output().unwrap()
};

// Absent target: shipped.
let out = run(local);
assert_eq!(parse_ship(&out).unwrap(), ShipOutcome::Shipped);
assert_eq!(std::fs::read(&target).unwrap(), local);

// Target grown past the local length: kept, bytes intact.
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&target)
.unwrap();
f.write_all(b"line3\n").unwrap();
drop(f);
let out = run(local);
assert_eq!(parse_ship(&out).unwrap(), ShipOutcome::Kept);
assert_eq!(std::fs::read(&target).unwrap(), b"line1\nline2\nline3\n");

// Truncated target (a partial earlier ship): replaced.
std::fs::write(&target, b"line1\n").unwrap();
let out = run(local);
assert_eq!(parse_ship(&out).unwrap(), ShipOutcome::Shipped);
assert_eq!(std::fs::read(&target).unwrap(), local);
}

#[test]
fn parse_ship_rejects_unexpected_output() {
let err = parse_ship(&output_with_stdout("cat: disk full\n")).unwrap_err();
assert!(err.to_string().contains("disk full"), "actual: {err}");
}

#[test]
fn launch_command_nests_quoting_for_tmux() {
let cmd = launch_command("path-abcd1234", "/home/e/proj", "/usr/bin/claude", "id-1");
Expand Down
1 change: 1 addition & 0 deletions crates/path-cli/tests/resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ fn cache_id_input_loads_and_projects() {
harness: Some(Harness::Claude),
remote: None,
dry_run: false,
overwrite: false,
no_cache: false,
force: false,
url: None,
Expand Down
Loading
Loading