diff --git a/CHANGELOG.md b/CHANGELOG.md
index 779cadb7..8cd7e75e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,59 @@
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.
+
+- **`path-cli`** (0.19.0):
+ - The local host does all toolpath work: it projects the
+ conversation in memory, stamps it (`Conversation::set_session_id_and_cwd`)
+ with a minted session id and the remote project directory, and
+ ships the JSONL over ssh's stdin. The remote never runs `path`.
+ - Transport is the user's `ssh` binary from the search path, so
+ ProxyJump, Match blocks, aliases, and host keys work. Remote
+ scope is POSIX sh remotes with claude and tmux installed.
+ - One batched, read-only preflight call captures `$HOME`, the
+ absolute claude path, tmux presence, the physical project path
+ (`pwd -P`), and tmux session liveness. Every captured value must
+ be a single-line absolute path before it becomes a path
+ component, so a login banner errors verbatim instead of turning
+ into a filename.
+ - The remote session id is a UUID formatted from the SHA-256 of a
+ key-sorted canonical serialization of the parsed document, so
+ re-pushing unchanged content targets the same remote file across
+ invocations and input shapes. The tmux session is `path-`
+ of the source session id.
+ - `-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
+ `$HOME`.
+ - Every remote argument passes a POSIX single-quote escaping
+ helper; the session file lands 0600 via `umask 077`. Credentials
+ are never read, copied, or written.
+ - `--dry-run` runs preflight, prints the exact remote commands and
+ the target file path, and changes nothing on the remote.
+- **`toolpath-cli`** (0.19.0): lockstep bump of the deprecated shim.
+
+## `toolpath-claude`: Conversation::set_session_id_and_cwd — 2026-08-17
+
+- **`toolpath-claude`** (0.12.3): `Conversation::set_session_id_and_cwd(session_id,
+ cwd)` rewrites a conversation in place for a new session ID and
+ working directory. It sets the conversation-level `session_id`, sets
+ `session_id` on every entry, sets `project_path`, replaces `cwd`
+ where an entry has one, 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, including the physical-path case (the
+ resolver slugs the string it is given, so callers pass the physical
+ cwd).
+
## `path config edit` — 2026-08-14
- **`path-cli`** (0.18.0): new `path config` porcelain command, starting
diff --git a/Cargo.lock b/Cargo.lock
index 4be9d924..9d6b524f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2487,7 +2487,7 @@ dependencies = [
[[package]]
name = "path-cli"
-version = "0.18.0"
+version = "0.19.0"
dependencies = [
"anyhow",
"assert_cmd",
@@ -4226,7 +4226,7 @@ dependencies = [
[[package]]
name = "toolpath-claude"
-version = "0.12.2"
+version = "0.12.3"
dependencies = [
"anyhow",
"chrono",
diff --git a/Cargo.toml b/Cargo.toml
index b57ad62b..eadfb69e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,7 +27,7 @@ license = "Apache-2.0"
toolpath = { version = "0.7.0", path = "crates/toolpath" }
toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" }
toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" }
-toolpath-claude = { version = "0.12.2", path = "crates/toolpath-claude", default-features = false }
+toolpath-claude = { version = "0.12.3", path = "crates/toolpath-claude", default-features = false }
toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false }
toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" }
toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" }
@@ -37,7 +37,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" }
toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" }
toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" }
toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" }
-path-cli = { version = "0.18.0", path = "crates/path-cli" }
+path-cli = { version = "0.19.0", path = "crates/path-cli" }
pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" }
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] }
diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml
index 27bda67b..4a2db9f4 100644
--- a/crates/path-cli/Cargo.toml
+++ b/crates/path-cli/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "path-cli"
-version = "0.18.0"
+version = "0.19.0"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs
index b95a67f0..5ae81ba2 100644
--- a/crates/path-cli/src/cmd_export.rs
+++ b/crates/path-cli/src/cmd_export.rs
@@ -24,6 +24,8 @@ use std::path::PathBuf;
#[cfg(not(target_os = "emscripten"))]
use crate::cache::cache_ref;
+#[cfg(not(target_os = "emscripten"))]
+use crate::projection::{build_claude_conversation, serialize_jsonl};
use crate::remote::RepoSpec;
#[derive(Subcommand, Debug)]
@@ -694,32 +696,6 @@ fn load_path_doc(input: &str) -> Result {
})
}
-#[cfg(not(target_os = "emscripten"))]
-fn build_claude_conversation(path: &toolpath::v1::Path) -> Result {
- use toolpath_convo::ConversationProjector;
- let view = toolpath_convo::extract_conversation(path);
- let projector = toolpath_claude::ClaudeProjector;
- projector
- .project(&view)
- .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))
-}
-
-#[cfg(not(target_os = "emscripten"))]
-fn serialize_jsonl(conv: &toolpath_claude::Conversation) -> Result {
- let mut lines = Vec::with_capacity(conv.preamble.len() + conv.entries.len());
- for raw in &conv.preamble {
- lines.push(serde_json::to_string(raw)?);
- }
- for entry in &conv.entries {
- lines.push(serde_json::to_string(entry)?);
- }
- // Trailing newline matters: Claude Code appends to this file on resume,
- // and without it the first appended entry lands on the last line.
- let mut out = lines.join("\n");
- out.push('\n');
- Ok(out)
-}
-
#[cfg(not(target_os = "emscripten"))]
fn write_into_claude_project(
conv: &toolpath_claude::Conversation,
diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs
index 4b0b6e3d..64f3dd28 100644
--- a/crates/path-cli/src/cmd_resume.rs
+++ b/crates/path-cli/src/cmd_resume.rs
@@ -40,6 +40,8 @@
#![cfg(not(target_os = "emscripten"))]
+pub mod remote;
+
use anyhow::{Context, Result};
use clap::Args;
use std::path::PathBuf;
@@ -57,6 +59,9 @@ pub struct ResumeArgs {
/// Working directory to run the resumed harness from. Defaults to
/// the current shell cwd. The on-disk projection is keyed on this
/// directory and the harness will be exec'd with cwd set to it.
+ /// With --remote it is a directory on the remote host (absolute,
+ /// physical); the default is the local cwd with the local home
+ /// prefix replaced by the remote $HOME.
#[arg(short = 'C', long)]
pub cwd: Option,
@@ -64,6 +69,19 @@ pub struct ResumeArgs {
#[arg(long, value_enum)]
pub harness: Option,
+ /// Push the session to a remote host over ssh and attach to it
+ /// under tmux there. The value is an ssh destination (user@host or
+ /// an ssh config alias), passed to ssh verbatim. Claude only; the
+ /// remote needs sshd, claude, and tmux. Re-running the same command
+ /// reattaches to the live tmux session.
+ #[arg(long)]
+ pub remote: Option,
+
+ /// With --remote: run preflight, print the exact remote commands
+ /// and the target file path, and change nothing on the remote.
+ #[arg(long, requires = "remote")]
+ pub dry_run: 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.
@@ -89,6 +107,26 @@ pub fn run(args: ResumeArgs) -> Result<()> {
/// Internal entry point that the integration tests call with a
/// `RecordingExec` strategy. Production callers use [`run`].
pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> {
+ if args.remote.is_some() {
+ use std::io::IsTerminal;
+ let stdin_is_tty = std::io::stdin().is_terminal();
+ let search_path: Vec = 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()?;
+ return remote::run_remote(
+ &args,
+ exec,
+ local_home.as_deref(),
+ &local_cwd,
+ &search_path,
+ stdin_is_tty,
+ );
+ }
+
let (graph, source_harness) = resolve_input(&args)?;
let path = ensure_path_with_agent(&graph)?;
@@ -615,6 +653,8 @@ mod tests {
no_cache: false,
force: false,
url: None,
+ remote: None,
+ dry_run: false,
};
let recorder = RecordingExec::default();
@@ -751,6 +791,8 @@ mod tests {
no_cache: false,
force: false,
url: None,
+ remote: None,
+ dry_run: false,
};
let (g, harness) = resolve_input(&args).unwrap();
let _path = ensure_path_with_agent(&g).unwrap();
@@ -785,6 +827,8 @@ mod tests {
no_cache: true, // skip cache write in tests
force: false,
url: None,
+ remote: None,
+ dry_run: false,
};
let (g, harness) = resolve_input(&args).unwrap();
let _ = ensure_path_with_agent(&g).unwrap();
@@ -846,6 +890,8 @@ mod tests {
no_cache: false,
force: false,
url: None,
+ remote: None,
+ dry_run: false,
};
let result = resolve_input(&args);
@@ -874,6 +920,8 @@ mod tests {
no_cache: false,
force: false,
url: None,
+ remote: None,
+ dry_run: 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
new file mode 100644
index 00000000..fbdd4ca7
--- /dev/null
+++ b/crates/path-cli/src/cmd_resume/remote.rs
@@ -0,0 +1,826 @@
+//! `path resume --remote`: push a Claude session to a remote host
+//! over ssh and attach to it under tmux.
+//!
+//! The local host does all toolpath work: it resolves the input,
+//! projects the conversation in memory, stamps it with a minted
+//! session id and the remote project directory, and ships the JSONL
+//! over ssh's stdin. The remote does not run `path`.
+//!
+//! Every path is a pure function of (document, absolute remote project
+//! path, remote home). Each preflight fact can veto with a clear
+//! error, and none rewrites an input. Preflight is read-only: the
+//! first remote write is the ship step.
+//!
+//! Transport is the user's `ssh` binary from the search path. Remote
+//! scope is POSIX sh remotes; every remote argument passes
+//! `sh_quote`.
+
+use anyhow::{Context, Result, bail};
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output, Stdio};
+
+use super::{ExecStrategy, ResumeArgs, ensure_path_with_agent, resolve_input};
+use crate::harness::Harness;
+
+/// Entry point for `path resume --remote`.
+pub fn run_remote(
+ args: &ResumeArgs,
+ exec: &dyn ExecStrategy,
+ local_home: Option<&Path>,
+ local_cwd: &Path,
+ search_path: &[PathBuf],
+ stdin_is_tty: bool,
+) -> Result<()> {
+ let remote = args
+ .remote
+ .as_deref()
+ .expect("run_remote requires --remote");
+
+ // ssh parses a leading-dash destination as an option.
+ if remote.is_empty() || remote.starts_with('-') {
+ bail!("--remote must be an ssh destination such as user@host (got {remote:?})");
+ }
+
+ if !stdin_is_tty && !args.dry_run {
+ bail!("`path resume --remote` needs an interactive terminal: stdin is not a TTY");
+ }
+ if let Some(h) = args.harness
+ && h != Harness::Claude
+ {
+ bail!(
+ "remote resume supports claude only (got --harness {})",
+ h.name()
+ );
+ }
+
+ let (graph, source_harness) = resolve_input(args)?;
+ let path = ensure_path_with_agent(&graph)?;
+ if args.harness.is_none() && source_harness != Some(Harness::Claude) {
+ bail!(
+ "remote resume supports claude only; the document's source is {}. \
+ Pass `--harness claude` to force a Claude projection.",
+ source_harness.map(|h| h.name()).unwrap_or("unknown")
+ );
+ }
+
+ let conv = crate::projection::build_claude_conversation(path)?;
+ if conv.session_id.is_empty() {
+ bail!("projected session has no id");
+ }
+ let tmux_name = tmux_session_name(&conv.session_id);
+
+ let dir = remote_dir_spec(args.cwd.as_deref(), local_cwd, local_home)?;
+
+ let ssh = find_binary("ssh", search_path)
+ .ok_or_else(|| anyhow::anyhow!("`ssh` not found on PATH"))?;
+ let ssh = SshRunner {
+ binary: ssh,
+ remote,
+ };
+
+ // One batched, read-only preflight call. The reattach probe rides
+ // along because it is read-only too.
+ let script = preflight_script(&dir, &tmux_name);
+ let output = ssh.run(&script, None)?;
+ if !output.status.success() {
+ bail!(
+ "ssh to {} failed ({}):\n{}",
+ remote,
+ output.status,
+ String::from_utf8_lossy(&output.stderr).trim_end()
+ );
+ }
+ let pf = parse_preflight(&output)?;
+
+ let home = validate_captured_path(&pf.home, "remote $HOME", &output)?;
+ if pf.claude.is_empty() {
+ let probed: Vec = CLAUDE_PROBE_LOCATIONS
+ .iter()
+ .map(|p| format!("~/{p}"))
+ .collect();
+ bail!(
+ "claude not found on {remote}; probed PATH, {}",
+ probed.join(", ")
+ );
+ }
+ let claude = validate_captured_path(&pf.claude, "remote claude path", &output)?;
+ if !pf.tmux_ok {
+ bail!("tmux not found on {remote}");
+ }
+ let project_path = resolved_project_path(&dir, &home);
+ validate_remote_cwd(&project_path)?;
+ match pf.pwd.as_deref() {
+ None => bail!(
+ "project directory {project_path} does not exist on {remote}; \
+ create it or pass -C"
+ ),
+ Some(physical) if physical != project_path => bail!(
+ "project directory {project_path} is not physical on {remote} \
+ (it resolves to {physical}); pass the physical path: -C {physical}"
+ ),
+ Some(_) => {}
+ }
+
+ let attach_cmd = attach_command(&tmux_name);
+ let attach_args = vec!["-t".to_string(), remote.to_string(), attach_cmd.clone()];
+
+ if pf.session_live {
+ eprintln!(
+ "Remote tmux session {tmux_name} is live on {remote}. \
+ Attaching without re-shipping: the session keeps the \
+ content from its original push."
+ );
+ if args.dry_run {
+ eprintln!(
+ " attach: {} -t {} {}",
+ ssh.binary.display(),
+ remote,
+ sh_quote(&attach_cmd)
+ );
+ eprintln!("Dry run: nothing was written or launched.");
+ return Ok(());
+ }
+ return exec.exec(&ssh.binary.to_string_lossy(), &attach_args, local_cwd);
+ }
+
+ // Canonical means key-sorted: serde_json::Value's map is a
+ // BTreeMap, where a direct Graph::to_json would serialize HashMap
+ // fields in per-instance random order and break id stability.
+ let canonical_json = serde_json::to_value(&graph)
+ .and_then(|v| serde_json::to_string(&v))
+ .context("serialize document")?;
+ let remote_id = mint_remote_id(&canonical_json);
+ if remote_id == conv.session_id {
+ bail!("minted remote session id equals the source session id");
+ }
+ if !is_uuid_shaped(&remote_id) {
+ bail!("minted remote session id is not UUID-shaped: {remote_id}");
+ }
+
+ // Slug rules live in toolpath-claude.
+ let resolver = toolpath_claude::PathResolver::new().with_home(home.as_str());
+ 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 launch_cmd = launch_command(&tmux_name, &project_path, &claude, &remote_id);
+
+ if args.dry_run {
+ eprintln!("Remote resume plan for {remote}:");
+ eprintln!(" remote home: {home}");
+ eprintln!(" claude: {claude}");
+ eprintln!(" project dir: {project_path}");
+ eprintln!(" session id: {remote_id}");
+ eprintln!(" session file: {target}");
+ eprintln!(" tmux session: {tmux_name}");
+ eprintln!(" ship: ssh {} {}", remote, sh_quote(&ship_cmd));
+ eprintln!(" launch: ssh {} {}", remote, sh_quote(&launch_cmd));
+ eprintln!(" attach: ssh -t {} {}", remote, sh_quote(&attach_cmd));
+ eprintln!("Dry run: nothing was written or launched.");
+ 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!(
+ "shipping the session to {} failed ({}):\n{}",
+ remote,
+ shipped.status,
+ String::from_utf8_lossy(&shipped.stderr).trim_end()
+ );
+ }
+
+ eprintln!("Launching {tmux_name} in {project_path}");
+ let launched = ssh.run(&launch_cmd, None)?;
+ if !launched.status.success() {
+ bail!(
+ "launching tmux session {} on {} failed ({}):\n{}",
+ tmux_name,
+ remote,
+ launched.status,
+ String::from_utf8_lossy(&launched.stderr).trim_end()
+ );
+ }
+
+ exec.exec(&ssh.binary.to_string_lossy(), &attach_args, local_cwd)
+}
+
+// ── Remote directory ─────────────────────────────────────────────────
+
+/// The remote project directory before the remote `$HOME` is known.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum RemoteDir {
+ /// `-C` value, verbatim.
+ Explicit(String),
+ /// Local cwd relative to the local home; empty means `$HOME` itself.
+ HomeRelative(String),
+}
+
+fn remote_dir_spec(
+ cwd: Option<&Path>,
+ local_cwd: &Path,
+ local_home: Option<&Path>,
+) -> Result {
+ if let Some(p) = cwd {
+ let s = p
+ .to_str()
+ .context("-C must be valid UTF-8")?
+ .trim_end_matches('/');
+ let s = if s.is_empty() { "/" } else { s };
+ validate_remote_cwd(s)?;
+ return Ok(RemoteDir::Explicit(s.to_string()));
+ }
+ let local_home =
+ local_home.context("cannot determine the local home directory; pass -C ")?;
+ match home_swap_suffix(local_cwd, local_home) {
+ Some(suffix) => Ok(RemoteDir::HomeRelative(suffix)),
+ None => bail!(
+ "the local cwd {} is not under the local home {}; pass -C ",
+ local_cwd.display(),
+ local_home.display()
+ ),
+ }
+}
+
+/// Local cwd relative to the local home, as a `/`-joined suffix.
+/// `None` when the cwd is not under the home.
+pub(crate) fn home_swap_suffix(local_cwd: &Path, local_home: &Path) -> Option {
+ let rel = local_cwd.strip_prefix(local_home).ok()?;
+ Some(rel.to_str()?.to_string())
+}
+
+pub(crate) fn resolved_project_path(dir: &RemoteDir, remote_home: &str) -> String {
+ match dir {
+ RemoteDir::Explicit(p) => p.clone(),
+ RemoteDir::HomeRelative(suffix) if suffix.is_empty() => remote_home.to_string(),
+ RemoteDir::HomeRelative(suffix) => {
+ format!("{}/{}", remote_home.trim_end_matches('/'), suffix)
+ }
+ }
+}
+
+pub(crate) fn validate_remote_cwd(s: &str) -> Result<()> {
+ if !s.starts_with('/') {
+ bail!("the remote project directory must be absolute (got {s})");
+ }
+ if s.contains('\n') {
+ bail!("the remote project directory must be a single line");
+ }
+ if s.split('/').any(|c| c == "..") {
+ bail!("the remote project directory must not contain `..` (got {s})");
+ }
+ Ok(())
+}
+
+// ── Preflight ────────────────────────────────────────────────────────
+
+/// Locations probed for `claude` when `command -v` finds nothing,
+/// relative to the remote home. An ssh exec channel runs a non-login
+/// shell whose PATH lacks the user's profile additions, so common
+/// install locations get a direct probe. The preflight script and
+/// the not-found error both derive from this list.
+const CLAUDE_PROBE_LOCATIONS: [&str; 3] = [
+ ".local/bin/claude",
+ ".claude/local/claude",
+ ".npm-global/bin/claude",
+];
+
+// Tags for the fact lines the remote scripts print, one
+// `=` line per fact. `TP_` abbreviates toolpath and keeps
+// the tags distinct from any real remote output.
+const TAG_HOME: &str = "TP_HOME";
+const TAG_CLAUDE: &str = "TP_CLAUDE";
+const TAG_TMUX: &str = "TP_TMUX";
+const TAG_PWD: &str = "TP_PWD";
+const TAG_SESSION: &str = "TP_SESSION";
+/// Emit and parse order of the preflight fact lines. The script
+/// prints one `=` line per entry; the parser reads them
+/// back in this order.
+const PREFLIGHT_TAGS: [&str; 5] = [TAG_HOME, TAG_CLAUDE, TAG_TMUX, TAG_PWD, TAG_SESSION];
+
+#[derive(Debug)]
+pub(crate) struct PreflightFacts {
+ pub(crate) home: String,
+ pub(crate) claude: String,
+ pub(crate) tmux_ok: bool,
+ /// Physical path from `pwd -P`, `None` when the dir is missing.
+ pub(crate) pwd: Option,
+ pub(crate) session_live: bool,
+}
+
+/// One POSIX-sh script gathering every preflight fact as `TP_*=` lines.
+/// Read-only: it makes no change on the remote.
+pub(crate) fn preflight_script(dir: &RemoteDir, tmux_name: &str) -> String {
+ let dir_expr = match dir {
+ RemoteDir::Explicit(p) => sh_quote(p),
+ RemoteDir::HomeRelative(suffix) if suffix.is_empty() => "\"$HOME\"".to_string(),
+ RemoteDir::HomeRelative(suffix) => format!("\"$HOME\"{}", sh_quote(&format!("/{suffix}"))),
+ };
+ let probes: String = CLAUDE_PROBE_LOCATIONS
+ .iter()
+ .map(|p| format!("if [ -z \"$c\" ] && [ -x \"$HOME/{p}\" ]; then c=\"$HOME/{p}\"; fi\n"))
+ .collect();
+ format!(
+ r#"set -u
+printf '{home}=%s\n' "$HOME"
+c=''
+if command -v claude >/dev/null 2>&1; then c=$(command -v claude); fi
+{probes}printf '{claude}=%s\n' "$c"
+if command -v tmux >/dev/null 2>&1; then t=ok; else t=missing; fi
+printf '{tmux}=%s\n' "$t"
+if cd {dir_expr} 2>/dev/null; then p=$(pwd -P); else p=''; fi
+printf '{pwd}=%s\n' "$p"
+if [ "$t" = ok ] && tmux has-session -t {name} 2>/dev/null; then s=live; else s=none; fi
+printf '{session}=%s\n' "$s"
+"#,
+ home = TAG_HOME,
+ claude = TAG_CLAUDE,
+ tmux = TAG_TMUX,
+ pwd = TAG_PWD,
+ session = TAG_SESSION,
+ probes = probes,
+ dir_expr = dir_expr,
+ // A bare `-t ` matches any session whose name starts
+ // with , so a session named `path-x-foo` would read as
+ // a live `path-x`. The `=` prefix requires the exact name.
+ name = sh_quote(&format!("={tmux_name}")),
+ )
+}
+
+/// Parse the five `TP_*=` lines. Anything else (a registration notice,
+/// a MOTD on stdout, a partial run) errors with the output verbatim,
+/// so a banner cannot become a path component.
+pub(crate) fn parse_preflight(output: &Output) -> Result {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let lines: Vec<&str> = stdout.lines().collect();
+ let tags: Vec = PREFLIGHT_TAGS.iter().map(|t| format!("{t}=")).collect();
+ if lines.len() != tags.len()
+ || lines
+ .iter()
+ .zip(&tags)
+ .any(|(l, t)| !l.starts_with(t.as_str()))
+ {
+ bail!(
+ "unexpected preflight output from the remote (a login banner or notice?); \
+ output was:\n{}",
+ preflight_transcript(output)
+ );
+ }
+ let val = |i: usize| lines[i][tags[i].len()..].to_string();
+ let pwd = val(3);
+ Ok(PreflightFacts {
+ home: val(0),
+ claude: val(1),
+ tmux_ok: val(2) == "ok",
+ pwd: if pwd.is_empty() { None } else { Some(pwd) },
+ session_live: val(4) == "live",
+ })
+}
+
+fn preflight_transcript(output: &Output) -> String {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let mut s = stdout.trim_end().to_string();
+ if !stderr.trim().is_empty() {
+ s.push_str("\n(stderr) ");
+ s.push_str(stderr.trim_end());
+ }
+ s
+}
+
+/// A value captured from the remote may only become a path component if
+/// it is a non-empty single line starting with `/`.
+fn validate_captured_path(value: &str, what: &str, output: &Output) -> Result {
+ if value.is_empty() || !value.starts_with('/') || value.contains('\n') {
+ bail!(
+ "{what} is not an absolute single-line path; preflight output was:\n{}",
+ preflight_transcript(output)
+ );
+ }
+ Ok(value.to_string())
+}
+
+// ── Remote commands ──────────────────────────────────────────────────
+
+pub(crate) fn ship_command(slug_dir: &str, target: &str) -> String {
+ format!(
+ "umask 077; mkdir -p {} && cat > {}",
+ sh_quote(slug_dir),
+ sh_quote(target)
+ )
+}
+
+pub(crate) fn launch_command(
+ tmux_name: &str,
+ cwd: &str,
+ claude_path: &str,
+ session_id: &str,
+) -> String {
+ // The inner string is the command tmux hands to `sh -c`, so it is
+ // quoted twice: once for that inner shell, once for the remote
+ // shell that parses the whole tmux invocation.
+ let inner = format!(
+ "env LANG=C.UTF-8 {} -r {}",
+ sh_quote(claude_path),
+ sh_quote(session_id)
+ );
+ format!(
+ "tmux new-session -d -s {} -c {} {}",
+ sh_quote(tmux_name),
+ sh_quote(cwd),
+ sh_quote(&inner)
+ )
+}
+
+/// The `=` target prefix pins tmux to an exact session-name match;
+/// `-d` detaches any stale client. Fresh launch and reattach both end
+/// in this command.
+pub(crate) fn attach_command(tmux_name: &str) -> String {
+ format!(
+ "tmux attach-session -d -t {}",
+ sh_quote(&format!("={tmux_name}"))
+ )
+}
+
+// ── Small pure helpers ───────────────────────────────────────────────
+
+/// POSIX single-quote escaping: the only special character inside
+/// single quotes is the single quote itself.
+pub(crate) fn sh_quote(s: &str) -> String {
+ format!("'{}'", s.replace('\'', "'\\''"))
+}
+
+/// `path-`: 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 {
+ let short: String = source_session_id
+ .chars()
+ .take(8)
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
+ c
+ } else {
+ '-'
+ }
+ })
+ .collect();
+ format!("path-{short}")
+}
+
+/// A UUID formatted from the SHA-256 of the document JSON.
+/// `Builder::from_random_bytes` forces the version-4 and variant
+/// 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 {
+ use sha2::{Digest, Sha256};
+ let digest = Sha256::digest(doc_json.as_bytes());
+ let mut b = [0u8; 16];
+ b.copy_from_slice(&digest[..16]);
+ uuid::Builder::from_random_bytes(b).into_uuid().to_string()
+}
+
+pub(crate) fn is_uuid_shaped(s: &str) -> bool {
+ let parts: Vec<&str> = s.split('-').collect();
+ parts.len() == 5
+ && [8, 4, 4, 4, 12]
+ .iter()
+ .zip(&parts)
+ .all(|(len, p)| p.len() == *len && p.chars().all(|c| c.is_ascii_hexdigit()))
+}
+
+fn find_binary(name: &str, search_path: &[PathBuf]) -> Option {
+ search_path
+ .iter()
+ .map(|d| d.join(name))
+ .find(|c| c.is_file())
+}
+
+fn path_to_string(p: &Path) -> Result {
+ Ok(p.to_str()
+ .context("remote path is not valid UTF-8")?
+ .to_string())
+}
+
+// ── ssh transport ────────────────────────────────────────────────────
+
+struct SshRunner<'a> {
+ binary: PathBuf,
+ remote: &'a str,
+}
+
+impl SshRunner<'_> {
+ /// Run `ssh ` with stdin fed from `stdin` (or
+ /// closed), capturing stdout and stderr.
+ fn run(&self, command: &str, stdin: Option<&[u8]>) -> Result