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
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 its RFC 8785 (JCS) form.
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
22 changes: 20 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
7 changes: 4 additions & 3 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 @@ -36,6 +36,7 @@ tempfile = { workspace = true }
rand = "0.9"
jsonschema = { version = "0.46", default-features = false }
sha2 = "0.10"
serde_json_canonicalizer = { version = "0.3", optional = true }
hex = "0.4"
jaq-core = "3.1.0"
jaq-std = "3.0.1"
Expand Down Expand Up @@ -87,8 +88,8 @@ 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`.
resume-remote = []
# default. Gates `p export claude --derive-session-id` and `--cwd`.
resume-remote = ["dep:serde_json_canonicalizer"]

[dev-dependencies]
assert_cmd = "2"
Expand Down
122 changes: 112 additions & 10 deletions crates/path-cli/src/cmd_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -647,11 +647,16 @@ fn run_claude(args: ClaudeArgs) -> Result<()> {

#[cfg(not(target_os = "emscripten"))]
{
let path = load_path_doc(&args.input)?;
let document_json = read_doc_json(&args.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 args.remote.derive_session_id {
let id = remote_session::session_id_from_document_hash(&document_json)?;
conversation.rename_session(&id);
}
if let Some(dir) = &args.remote.cwd {
conversation.reroot(dir);
}
Expand All @@ -677,7 +682,12 @@ fn run_claude(args: ClaudeArgs) -> Result<()> {
(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 @@ -691,10 +701,18 @@ fn run_claude(args: ClaudeArgs) -> Result<()> {

#[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 @@ -3503,7 +3521,7 @@ mod tests {
#[cfg(feature = "resume-remote")]
mod resume_remote {
use super::*;
use crate::cmd_export::remote_session::RemoteSessionArgs;
use crate::cmd_export::remote_session::{RemoteSessionArgs, session_id_from_document_hash};

/// `make_path_doc` with `cwd` recorded on every step, plus one
/// headerless line that carries a `cwd`.
Expand Down Expand Up @@ -3551,6 +3569,7 @@ mod tests {
/// Runs `p export claude --output` on `doc` and parses the lines.
fn export_claude_lines(
doc: &toolpath::v1::Graph,
derive_session_id: bool,
cwd: Option<&str>,
) -> Vec<serde_json::Value> {
let temp = tempfile::tempdir().unwrap();
Expand All @@ -3561,6 +3580,7 @@ mod tests {
input: input_path.to_string_lossy().to_string(),
output: Some(output_path.clone()),
remote: RemoteSessionArgs {
derive_session_id,
cwd: cwd.map(str::to_string),
},
..Default::default()
Expand All @@ -3580,12 +3600,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 @@ -3600,12 +3620,94 @@ 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")
);
}

#[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 args = ClaudeArgs {
input: input_path.to_string_lossy().to_string(),
project: Some(cwd.clone()),
remote: RemoteSessionArgs {
derive_session_id: true,
cwd: None,
},
..Default::default()
};

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(args);
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());
}
}
}
Loading
Loading