diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cd7e75e..7f3cd4c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 --remote [-C ]
-[--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
@@ -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-`
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
diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs
index 64f3dd28..ec5fc0ca 100644
--- a/crates/path-cli/src/cmd_resume.rs
+++ b/crates/path-cli/src/cmd_resume.rs
@@ -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.
@@ -655,6 +662,7 @@ mod tests {
url: None,
remote: None,
dry_run: false,
+ overwrite: false,
};
let recorder = RecordingExec::default();
@@ -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();
@@ -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();
@@ -892,6 +902,7 @@ mod tests {
url: None,
remote: None,
dry_run: false,
+ overwrite: false,
};
let result = resolve_input(&args);
@@ -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();
diff --git a/crates/path-cli/src/cmd_resume/remote.rs b/crates/path-cli/src/cmd_resume/remote.rs
index fbdd4ca7..a73a4659 100644
--- a/crates/path-cli/src/cmd_resume/remote.rs
+++ b/crates/path-cli/src/cmd_resume/remote.rs
@@ -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 {
@@ -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!(
@@ -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)?;
@@ -407,7 +426,37 @@ fn validate_captured_path(value: &str, what: &str, output: &Output) -> Result 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",
+ 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),
@@ -415,6 +464,23 @@ pub(crate) fn ship_command(slug_dir: &str, target: &str) -> String {
)
}
+/// 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 {
+ 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,
@@ -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",
);
@@ -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");
diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs
index 3acbc83e..205bdd29 100644
--- a/crates/path-cli/tests/resume.rs
+++ b/crates/path-cli/tests/resume.rs
@@ -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,
diff --git a/crates/path-cli/tests/resume_remote.rs b/crates/path-cli/tests/resume_remote.rs
index d62abe9e..2f064dc9 100644
--- a/crates/path-cli/tests/resume_remote.rs
+++ b/crates/path-cli/tests/resume_remote.rs
@@ -98,6 +98,7 @@ fn same_document_mints_the_same_id_across_serializations() {
let home = TestHome::new();
let shim = SshShim::new();
shim.respond(0, &preflight_ok(PROJECT, "none"));
+ shim.respond(1, "TP_SHIP=shipped\n");
let recorder = RecordingExec::default();
run_remote(
&args_remote(doc.to_str().unwrap(), REMOTE, Some(PROJECT), false),
@@ -125,6 +126,7 @@ fn fresh_push_ships_launches_and_attaches() {
let doc_file = claude_doc(docs.path());
let shim = SshShim::new();
shim.respond(0, &preflight_ok(PROJECT, "none"));
+ shim.respond(1, "TP_SHIP=shipped\n");
let recorder = RecordingExec::default();
run_remote(
@@ -154,17 +156,29 @@ fn fresh_push_ships_launches_and_attaches() {
let resolver = toolpath_claude::PathResolver::new().with_home(REMOTE_HOME);
let slug_dir = resolver.project_dir(PROJECT).unwrap();
let target = resolver.conversation_file(PROJECT, &remote_id).unwrap();
+ let expected = expected_shipped_jsonl(&doc_file, &remote_id, PROJECT);
let ship = shim.argv(1);
assert_eq!(ship[0], REMOTE);
- assert_eq!(
- ship[1],
- format!(
- "umask 077; mkdir -p '{}' && cat > '{}'",
+ assert!(ship[1].starts_with("umask 077;"), "ship: {}", ship[1]);
+ assert!(
+ ship[1].contains(&format!("! -e '{}'", target.display())),
+ "ship: {}",
+ ship[1]
+ );
+ assert!(
+ ship[1].contains(&format!("-lt {}", expected.len())),
+ "ship: {}",
+ ship[1]
+ );
+ assert!(
+ ship[1].contains(&format!(
+ "mkdir -p '{}' && cat > '{}'",
slug_dir.display(),
target.display()
- )
+ )),
+ "ship: {}",
+ ship[1]
);
- let expected = expected_shipped_jsonl(&doc_file, &remote_id, PROJECT);
assert_eq!(
String::from_utf8(shim.stdin_bytes(1)).unwrap(),
expected,
@@ -223,6 +237,102 @@ fn live_session_reattaches_without_ship_or_launch() {
assert!(cap.args[2].contains("attach-session"));
}
+// ── Existing remote session file ────────────────────────────────────
+
+#[test]
+fn existing_remote_file_is_kept_and_launched() {
+ let home = TestHome::new();
+ let docs = tempfile::tempdir().unwrap();
+ let doc_file = claude_doc(docs.path());
+ let shim = SshShim::new();
+ shim.respond(0, &preflight_ok(PROJECT, "none"));
+ shim.respond(1, "TP_SHIP=kept\n");
+
+ let recorder = RecordingExec::default();
+ run_remote(
+ &args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false),
+ &recorder,
+ Some(&home.home_dir()),
+ &home.home_dir(),
+ &shim.search_path(),
+ true,
+ )
+ .unwrap();
+
+ assert_eq!(shim.calls(), 3, "preflight, conditional ship, launch");
+ assert!(shim.argv(2)[1].contains("tmux new-session"));
+ let cap = recorder.captured();
+ assert!(cap.args[2].contains("attach-session"));
+}
+
+#[test]
+fn overwrite_ships_unconditionally() {
+ let home = TestHome::new();
+ let docs = tempfile::tempdir().unwrap();
+ let doc_file = claude_doc(docs.path());
+ let shim = SshShim::new();
+ shim.respond(0, &preflight_ok(PROJECT, "none"));
+
+ let mut args = args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false);
+ args.overwrite = true;
+ let recorder = RecordingExec::default();
+ run_remote(
+ &args,
+ &recorder,
+ Some(&home.home_dir()),
+ &home.home_dir(),
+ &shim.search_path(),
+ true,
+ )
+ .unwrap();
+
+ assert_eq!(shim.calls(), 3, "preflight, ship, launch");
+ let raw_json = std::fs::read_to_string(&doc_file).unwrap();
+ let graph = toolpath::v1::Graph::from_json(&raw_json).unwrap();
+ let canonical = serde_json::to_string(&serde_json::to_value(&graph).unwrap()).unwrap();
+ let remote_id = mint_remote_id(&canonical);
+ let resolver = toolpath_claude::PathResolver::new().with_home(REMOTE_HOME);
+ let slug_dir = resolver.project_dir(PROJECT).unwrap();
+ let target = resolver.conversation_file(PROJECT, &remote_id).unwrap();
+ assert_eq!(
+ shim.argv(1)[1],
+ format!(
+ "umask 077; mkdir -p '{}' && cat > '{}'",
+ slug_dir.display(),
+ target.display()
+ )
+ );
+ assert_eq!(
+ String::from_utf8(shim.stdin_bytes(1)).unwrap(),
+ expected_shipped_jsonl(&doc_file, &remote_id, PROJECT)
+ );
+}
+
+#[test]
+fn unexpected_ship_output_errors_without_launch() {
+ let home = TestHome::new();
+ let docs = tempfile::tempdir().unwrap();
+ let doc_file = claude_doc(docs.path());
+ let shim = SshShim::new();
+ shim.respond(0, &preflight_ok(PROJECT, "none"));
+ shim.respond(1, "cat: write error\n");
+
+ let recorder = RecordingExec::default();
+ let err = run_remote(
+ &args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false),
+ &recorder,
+ Some(&home.home_dir()),
+ &home.home_dir(),
+ &shim.search_path(),
+ true,
+ )
+ .unwrap_err();
+
+ assert!(err.to_string().contains("ship output"), "actual: {err}");
+ assert_eq!(shim.calls(), 2, "preflight and ship only; no launch");
+ assert!(recorder.captured().binary.is_empty(), "no attach");
+}
+
// ── Dry run ─────────────────────────────────────────────────────────
#[test]
@@ -530,6 +640,7 @@ fn re_running_the_same_push_mints_the_same_id_and_target() {
for _ in 0..2 {
let shim = SshShim::new();
shim.respond(0, &preflight_ok(PROJECT, "none"));
+ shim.respond(1, "TP_SHIP=shipped\n");
run_remote(
&args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false),
&RecordingExec::default(),
diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs
index 681fb004..aa010a2c 100644
--- a/crates/path-cli/tests/support/mod.rs
+++ b/crates/path-cli/tests/support/mod.rs
@@ -186,6 +186,7 @@ pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs
harness: Some(harness),
remote: None,
dry_run: false,
+ overwrite: false,
no_cache: false,
force: false,
url: None,
@@ -290,6 +291,7 @@ pub fn args_remote(input: &str, remote: &str, cwd: Option<&str>, dry_run: bool)
harness: None,
remote: Some(remote.to_string()),
dry_run,
+ overwrite: false,
no_cache: false,
force: false,
url: None,