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
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.

- **`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-<short8>`
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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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"] }
Expand Down
2 changes: 1 addition & 1 deletion crates/path-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
28 changes: 2 additions & 26 deletions crates/path-cli/src/cmd_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -694,32 +696,6 @@ fn load_path_doc(input: &str) -> Result<toolpath::v1::Path> {
})
}

#[cfg(not(target_os = "emscripten"))]
fn build_claude_conversation(path: &toolpath::v1::Path) -> Result<toolpath_claude::Conversation> {
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<String> {
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,
Expand Down
48 changes: 48 additions & 0 deletions crates/path-cli/src/cmd_resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@

#![cfg(not(target_os = "emscripten"))]

pub mod remote;

use anyhow::{Context, Result};
use clap::Args;
use std::path::PathBuf;
Expand All @@ -57,13 +59,29 @@ 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<PathBuf>,

/// Pin the resume target. Skips the interactive picker.
#[arg(long, value_enum)]
pub harness: Option<Harness>,

/// 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<String>,

/// 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.
Expand All @@ -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<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()?;
Comment on lines +113 to +119

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.

Replace with Config after that refactor lands.

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)?;

Expand Down Expand Up @@ -615,6 +653,8 @@ mod tests {
no_cache: false,
force: false,
url: None,
remote: None,
dry_run: false,
};

let recorder = RecordingExec::default();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -846,6 +890,8 @@ mod tests {
no_cache: false,
force: false,
url: None,
remote: None,
dry_run: false,
};
let result = resolve_input(&args);

Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading