From 32b1353573f4795a40c1c38db24f8bf669dbb1d6 Mon Sep 17 00:00:00 2001
From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com>
Date: Tue, 18 Aug 2026 17:56:30 -0400
Subject: [PATCH] feat(cli): path resume --remote keeps an existing remote
session file
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.
path-cli (0.18.0):
- ship_command guards the write remotely in the same exec channel as
the write itself: no second round trip, no check-to-write race.
Both branches drain stdin and report one TP_SHIP=shipped or
TP_SHIP=kept line, parsed strictly.
- --overwrite replaces the target unconditionally. It requires
--remote and is not spelled --force: that flag means "re-fetch from
Pathbase, overwrite the cache entry".
- Known edge, accepted: a target at exactly the local size with
different bytes reads as kept; --overwrite is the recovery.
- Unit, real-sh, and integration tests cover shipped, kept, and
overwrite, including that a kept file keeps its appended entries.
---
CHANGELOG.md | 14 ++-
crates/path-cli/src/cmd_resume.rs | 12 ++
crates/path-cli/src/cmd_resume/remote.rs | 148 +++++++++++++++++++++--
crates/path-cli/tests/resume.rs | 1 +
crates/path-cli/tests/resume_remote.rs | 123 ++++++++++++++++++-
crates/path-cli/tests/support/mod.rs | 2 +
6 files changed, 281 insertions(+), 19 deletions(-)
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,