Skip to content
Draft
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

All notable changes to the Toolpath workspace are documented here.

## path-cli 0.20.0 — 2026-08-27

- **`path-cli`** (0.20.0): `p export claude` takes `--derive-session-id`
behind the `resume-remote` cargo feature. The flag renames the
session to an ID derived from the input document: a v4-shaped UUID
from the first 128 bits of the SHA-256 of the key-sorted compact
JSON. The same document yields the same ID on every run, so a second
export of it into the same project is refused instead of duplicated.
`--cwd` does not change the ID. The `--output` message names the
session ID. `scripts/resume-remote.sh` exports with the flag and
reads the remote session ID back from the JSONL.
- **`toolpath-cli`** (0.20.0): lockstep bump of the deprecated shim.

## toolpath-claude 0.13.2 — 2026-08-27

- **`toolpath-claude`** (0.13.2): `Conversation::rename_session(id)` sets
the session ID everywhere the format carries it: `session_id`, every
entry's `sessionId` that is present, and every string-valued
`sessionId` key in preamble lines at any depth.

## path-cli 0.19.0 — 2026-08-27

- **`path-cli`** (0.19.0): new cargo feature `resume-remote`, off by
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`); provider crates also h

- `toolpath-claude` has a `watcher` feature (default: on) gating `notify`/`tokio` dependencies for filesystem watching
- `toolpath-gemini` has a `watcher` feature (default: on) gating the polling-based `ConversationWatcher` module
- `path-cli` has `embedded-picker` (default: on; the skim picker) and `resume-remote` (default: off) gating the `p export claude` flag `--cwd` and the code behind it (`crates/path-cli/src/cmd_export/remote_session.rs`); `scripts/resume-remote.sh` builds with it. The gate is `all(feature = "resume-remote", not(target_os = "emscripten"))`: the feature has no effect on the wasm build. Test both states: `cargo test -p path-cli` and `cargo test -p path-cli --features resume-remote`.
- `path-cli` has `embedded-picker` (default: on; the skim picker) and `resume-remote` (default: off) gating the `p export claude` flags `--derive-session-id` and `--cwd` and the code behind them (`crates/path-cli/src/cmd_export/remote_session.rs`); `scripts/resume-remote.sh` builds with it. The gate is `all(feature = "resume-remote", not(target_os = "emscripten"))`: the feature has no effect on the wasm build. Test both states: `cargo test -p path-cli` and `cargo test -p path-cli --features resume-remote`.

## Desktop app

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.1", 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.13.1", path = "crates/toolpath-claude", default-features = false }
toolpath-claude = { version = "0.13.2", 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.19.0", path = "crates/path-cli" }
path-cli = { version = "0.20.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
4 changes: 2 additions & 2 deletions 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.19.0"
version = "0.20.0"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
Expand Down Expand Up @@ -87,7 +87,7 @@ vendored-openssl = ["git2/vendored-openssl"]
# (`--no-default-features`) for the minimal build.
embedded-picker = ["dep:skim", "dep:regex"]
# Remote resume (scripts/resume-remote.sh): experimental, off by
# default. Gates `p export claude --cwd`.
# default. Gates `p export claude --derive-session-id` and `--cwd`.
resume-remote = []

[dev-dependencies]
Expand Down
30 changes: 24 additions & 6 deletions crates/path-cli/src/cmd_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -665,11 +665,16 @@ fn run_claude(

#[cfg(not(target_os = "emscripten"))]
{
let path = load_path_doc(&input)?;
let document_json = read_doc_json(&input)?;
let path = parse_path_doc(&document_json)?;
let conversation = build_claude_conversation(&path)?;
#[cfg(feature = "resume-remote")]
let conversation = {
let mut conversation = conversation;
if remote.derive_session_id {
let id = remote_session::session_id_from_document_hash(&document_json)?;
conversation.rename_session(&id);
}
if let Some(dir) = &remote.cwd {
conversation.reroot(dir);
}
Expand All @@ -695,7 +700,12 @@ fn run_claude(
(None, Some(out_path)) => {
std::fs::write(&out_path, &jsonl)
.with_context(|| format!("write {}", out_path.display()))?;
eprintln!("Wrote {} bytes to {}", jsonl.len(), out_path.display());
eprintln!(
"Wrote session {} ({} bytes) to {}",
conversation.session_id,
jsonl.len(),
out_path.display()
);
}
(None, None) => {
println!("{}", jsonl);
Expand All @@ -709,10 +719,18 @@ fn run_claude(

#[cfg(not(target_os = "emscripten"))]
fn load_path_doc(input: &str) -> Result<toolpath::v1::Path> {
parse_path_doc(&read_doc_json(input)?)
}

#[cfg(not(target_os = "emscripten"))]
fn read_doc_json(input: &str) -> Result<String> {
let file = cache_ref(input)?;
let json = std::fs::read_to_string(&file)
.with_context(|| format!("Failed to read {}", file.display()))?;
let doc = toolpath::v1::Graph::from_json(&json)
std::fs::read_to_string(&file).with_context(|| format!("Failed to read {}", file.display()))
}

#[cfg(not(target_os = "emscripten"))]
fn parse_path_doc(json: &str) -> Result<toolpath::v1::Path> {
let doc = toolpath::v1::Graph::from_json(json)
.map_err(|e| anyhow::anyhow!("Failed to parse toolpath document: {}", e))?;
doc.into_single_path().ok_or_else(|| {
anyhow::anyhow!(
Expand Down Expand Up @@ -3176,7 +3194,7 @@ mod tests {
/// step using the given `artifact_key` (e.g. `"claude-code://my-session"`).
/// The projectors read `view.id` from the first `<provider>://<id>` artifact
/// key they see, so this gives them a non-empty session id to work with.
fn make_convo_path(artifact_key: &str) -> toolpath::v1::Path {
pub(super) fn make_convo_path(artifact_key: &str) -> toolpath::v1::Path {
let mut extra = HashMap::new();
extra.insert("role".to_string(), serde_json::json!("user"));
extra.insert("text".to_string(), serde_json::json!("hello"));
Expand Down
150 changes: 141 additions & 9 deletions crates/path-cli/src/cmd_export/remote_session.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
//! `p export claude --cwd`: the session's cwd on the host that resumes
//! it.
//! `p export claude --derive-session-id` and `--cwd`: the session's ID
//! and cwd on the host that resumes it.

use anyhow::Result;
use anyhow::{Context, Result};

/// The `p export claude` flags that rewrite the projected session
/// before it is written.
#[derive(clap::Args, Debug, Default)]
#[command(next_help_heading = "Remote session")]
pub struct RemoteSessionArgs {
/// Rename the session to an ID derived from the document: a
/// v4-shaped UUID from the first 128 bits of the SHA-256 of the
/// key-sorted compact JSON. The same document yields the same ID
/// on every run, so a second export of it into the same project
/// is refused instead of duplicated. --cwd does not change the
/// ID.
#[arg(long)]
pub(super) derive_session_id: bool,

/// Root the session at this directory: it becomes the `cwd` of
/// every line that carries one. Absolute POSIX path in
/// normalized form; it does not have to exist on this machine.
Expand All @@ -20,6 +29,22 @@ pub struct RemoteSessionArgs {
pub(super) cwd: Option<String>,
}

/// The session ID derived from the document `json`: a v4-shaped UUID
/// from the first 128 bits of the SHA-256 of the key-sorted compact
/// JSON. Key order and whitespace in `json` do not change the ID.
pub(super) fn session_id_from_document_hash(json: &str) -> Result<String> {
use sha2::{Digest, Sha256};
let document: serde_json::Value =
serde_json::from_str(json).context("Failed to parse toolpath document")?;
let canonical = serde_json::to_string(&document).context("serialize document")?;
let digest = Sha256::digest(canonical.as_bytes());
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest[..16]);
Ok(uuid::Builder::from_random_bytes(bytes)
.into_uuid()
.to_string())
}

/// Claude Code keys a session on the exact `cwd` string, so the value
/// must be an absolute POSIX path in normalized form: no `.`, `..`, or
/// empty component. One trailing `/` is dropped. The directory may be
Expand All @@ -45,7 +70,7 @@ fn parse_cwd_arg(raw: &str) -> Result<String> {
mod tests {
use super::*;
use crate::cmd_export::run_claude;
use crate::cmd_export::tests::make_path_doc;
use crate::cmd_export::tests::{make_convo_path, make_path_doc};
use std::collections::HashMap;
use toolpath::v1::{ArtifactChange, Step, StepIdentity, StructuralChange};

Expand Down Expand Up @@ -93,7 +118,11 @@ mod tests {
}

/// Runs `p export claude --output` on `doc` and parses the lines.
fn export_claude_lines(doc: &toolpath::v1::Graph, cwd: Option<&str>) -> Vec<serde_json::Value> {
fn export_claude_lines(
doc: &toolpath::v1::Graph,
derive_session_id: bool,
cwd: Option<&str>,
) -> Vec<serde_json::Value> {
let temp = tempfile::tempdir().unwrap();
let input_path = temp.path().join("input.json");
let output_path = temp.path().join("out.jsonl");
Expand All @@ -104,6 +133,7 @@ mod tests {
Some(output_path.clone()),
false,
RemoteSessionArgs {
derive_session_id,
cwd: cwd.map(str::to_string),
},
)
Expand All @@ -122,12 +152,12 @@ mod tests {
#[test]
fn cwd_flag_rewrites_every_cwd() {
let doc = make_path_doc_with_cwd("/old/project");
let plain = export_claude_lines(&doc, None);
let plain = export_claude_lines(&doc, false, None);
let old = values_of(&plain, "cwd");
assert!(!old.is_empty());
assert!(old.iter().all(|c| *c == "/old/project"));

let rooted = export_claude_lines(&doc, Some("/new/dir"));
let rooted = export_claude_lines(&doc, false, Some("/new/dir"));
assert_eq!(rooted.len(), plain.len());
let new = values_of(&rooted, "cwd");
assert_eq!(new.len(), old.len());
Expand All @@ -142,8 +172,8 @@ mod tests {
#[test]
fn cwd_flag_leaves_session_ids_alone() {
let doc = make_path_doc_with_cwd("/old/project");
let plain = export_claude_lines(&doc, None);
let rooted = export_claude_lines(&doc, Some("/new/dir"));
let plain = export_claude_lines(&doc, false, None);
let rooted = export_claude_lines(&doc, false, Some("/new/dir"));
assert_eq!(
values_of(&plain, "sessionId"),
values_of(&rooted, "sessionId")
Expand All @@ -158,4 +188,106 @@ mod tests {
assert_eq!(parse_cwd_arg("/a/b/").unwrap(), "/a/b");
assert_eq!(parse_cwd_arg("/").unwrap(), "/");
}

/// A fixed document and the ID `session_id_from_document_hash`
/// returns for it. `DOC_REORDERED` is the same document with other
/// key order and whitespace.
const DOC: &str = r#"{"a":1,"b":{"c":[1,2],"d":"x"}}"#;
const DOC_REORDERED: &str = "{ \"b\": {\"d\": \"x\", \"c\": [1, 2]}, \"a\": 1 }";
const DOC_DERIVED_ID: &str = "402a3ca5-2530-407e-9029-f96879adff54";

#[test]
fn session_id_from_document_hash_is_a_v4_uuid_of_the_key_sorted_document() {
let id = session_id_from_document_hash(DOC).unwrap();
assert_eq!(id, DOC_DERIVED_ID);
assert_eq!(
session_id_from_document_hash(DOC_REORDERED).unwrap(),
DOC_DERIVED_ID
);
assert_ne!(
session_id_from_document_hash(r#"{"a":2}"#).unwrap(),
DOC_DERIVED_ID
);
let uuid = uuid::Uuid::parse_str(&id).unwrap();
assert_eq!(uuid.get_version_num(), 4);
assert_eq!(uuid.get_variant(), uuid::Variant::RFC4122);
assert!(session_id_from_document_hash("not json").is_err());
}

#[test]
fn derive_session_id_flag_stamps_the_derived_id() {
let doc = make_path_doc();
let plain = export_claude_lines(&doc, false, None);
let source_ids = values_of(&plain, "sessionId");
assert_eq!(
source_ids.len(),
plain.len(),
"every line carries a sessionId"
);

let expected =
session_id_from_document_hash(&serde_json::to_string(&doc).unwrap()).unwrap();
assert!(!source_ids.contains(&expected.as_str()));
let derived = export_claude_lines(&doc, true, None);
assert_eq!(derived.len(), plain.len());
let ids = values_of(&derived, "sessionId");
assert_eq!(ids.len(), source_ids.len());
assert!(ids.iter().all(|s| *s == expected));
}

#[test]
fn cwd_flag_does_not_change_the_derived_id() {
let doc = make_path_doc_with_cwd("/old/project");
let derived = export_claude_lines(&doc, true, None);
let rerooted = export_claude_lines(&doc, true, Some("/new/dir"));
assert_eq!(
values_of(&derived, "sessionId"),
values_of(&rerooted, "sessionId")
);
}

#[test]
fn derived_export_names_the_project_file() {
let temp = tempfile::tempdir().unwrap();
let fake_home = temp.path().join("home");
std::fs::create_dir_all(&fake_home).unwrap();
let cwd = temp.path().join("proj");
std::fs::create_dir_all(&cwd).unwrap();

let path = make_convo_path("claude-code://claude-derived-file-test-session");
let input_path = temp.path().join("input.json");
let doc = toolpath::v1::Graph::from_path(path);
std::fs::write(&input_path, serde_json::to_string(&doc).unwrap()).unwrap();
let input = input_path.to_string_lossy().to_string();
let derived = RemoteSessionArgs {
derive_session_id: true,
cwd: None,
};

let _g = crate::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prior_home = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", &fake_home);
}
let result = run_claude(input, Some(cwd.clone()), None, false, derived);
unsafe {
match prior_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}

result.expect("derived export should succeed");
let expected =
session_id_from_document_hash(&std::fs::read_to_string(&input_path).unwrap()).unwrap();
let canon = std::fs::canonicalize(&cwd).unwrap();
let file = toolpath_claude::PathResolver::new()
.with_home(&fake_home)
.project_dir(canon.to_str().unwrap())
.unwrap()
.join(format!("{expected}.jsonl"));
assert!(file.is_file(), "{}", file.display());
}
}
6 changes: 4 additions & 2 deletions crates/path-cli/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,12 +555,13 @@ fn export_help_lists_claude_and_pathbase() {

#[cfg(feature = "resume-remote")]
#[test]
fn export_claude_help_lists_cwd_under_remote_session() {
fn export_claude_help_lists_the_remote_session_flags() {
cmd()
.args(["p", "export", "claude", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("Remote session:"))
.stdout(predicate::str::contains("--derive-session-id"))
.stdout(predicate::str::contains("--cwd <DIR>"));
}

Expand Down Expand Up @@ -592,12 +593,13 @@ fn export_claude_rejects_an_unnormalized_cwd() {

#[cfg(not(feature = "resume-remote"))]
#[test]
fn export_claude_help_omits_cwd() {
fn export_claude_help_omits_the_remote_session_flags() {
cmd()
.args(["p", "export", "claude", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("Remote session").not())
.stdout(predicate::str::contains("--derive-session-id").not())
.stdout(predicate::str::contains("--cwd").not());
}

Expand Down
Loading