From 1eeddb4d221430cdb0138356d063203a93880db0 Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:53:36 -0400 Subject: [PATCH 1/3] feat(claude): Conversation::rename_session sets the session ID `rename_session(id)` sets the session ID everywhere the format carries it: `session_id`, every entry's `sessionId`, and every `sessionId` key in preamble lines at any depth. Claude Code copies the ID into `worktreeSession.sessionId` on `worktree-state` lines. Message content and tool results are not touched. toolpath-claude 0.13.2. --- CHANGELOG.md | 7 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- crates/toolpath-claude/Cargo.toml | 2 +- crates/toolpath-claude/src/types.rs | 77 +++++++++++++++++++++++++++++ site/_data/crates.json | 2 +- 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af90a98..26395197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to the Toolpath workspace are documented here. +## 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 diff --git a/Cargo.lock b/Cargo.lock index 273a7f12..91a42ab5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4226,7 +4226,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.13.1" +version = "0.13.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 39350a5d..8b5c78e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index e49e3345..26f89db8 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.13.1" +version = "0.13.2" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/types.rs b/crates/toolpath-claude/src/types.rs index b8f01106..8af2f7c5 100644 --- a/crates/toolpath-claude/src/types.rs +++ b/crates/toolpath-claude/src/types.rs @@ -403,6 +403,28 @@ pub struct Conversation { pub preamble: Vec, } +/// Sets every string-valued `sessionId` key in `value`, at any depth, +/// to `id`. +fn set_session_id_keys(value: &mut serde_json::Value, id: &str) { + match value { + serde_json::Value::Object(map) => { + for (key, child) in map.iter_mut() { + if key == "sessionId" && child.is_string() { + *child = serde_json::Value::String(id.to_string()); + } else { + set_session_id_keys(child, id); + } + } + } + serde_json::Value::Array(items) => { + for child in items { + set_session_id_keys(child, id); + } + } + _ => {} + } +} + impl Conversation { pub fn new(session_id: String) -> Self { Self { @@ -433,6 +455,25 @@ impl Conversation { self.entries.push(entry); } + /// 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. Claude Code copies the ID into `worktreeSession.sessionId` + /// on `worktree-state` lines. + pub fn rename_session(&mut self, id: &str) { + self.session_id = id.to_string(); + for slot in self + .entries + .iter_mut() + .filter_map(|e| e.session_id.as_mut()) + { + *slot = id.to_string(); + } + for raw in &mut self.preamble { + set_session_id_keys(raw, id); + } + } + /// Sets the directory everywhere the format carries it: /// `project_path`, every entry's `cwd` that is present, and a /// top-level `cwd` on a preamble line. @@ -567,6 +608,42 @@ mod tests { serde_json::from_str(json).unwrap() } + #[test] + fn rename_session_sets_every_session_id_key() { + let mut convo = Conversation::new("old".to_string()); + convo.preamble.push(serde_json::json!({ + "type": "permission-mode", "permissionMode": "default", "sessionId": "old" + })); + convo.preamble.push(serde_json::json!({ + "type": "worktree-state", "sessionId": "old", + "worktreeSession": {"sessionId": "old", "worktreePath": "/wt"} + })); + convo + .preamble + .push(serde_json::json!({"type": "odd", "sessionId": 7})); + convo.add_entry(entry( + r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"old","message":{"role":"user","content":"hi"}}"#, + )); + convo.add_entry(entry( + r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"old","message":{"role":"assistant","content":"yo"}}"#, + )); + + convo.rename_session("new"); + + assert_eq!(convo.session_id, "new"); + assert!( + convo + .entries + .iter() + .all(|e| e.session_id.as_deref() == Some("new")) + ); + assert_eq!(convo.preamble[0]["sessionId"], "new"); + assert_eq!(convo.preamble[1]["sessionId"], "new"); + assert_eq!(convo.preamble[1]["worktreeSession"]["sessionId"], "new"); + assert_eq!(convo.preamble[1]["worktreeSession"]["worktreePath"], "/wt"); + assert_eq!(convo.preamble[2]["sessionId"], 7); + } + #[test] fn reroot_sets_project_path_and_every_present_cwd() { let mut convo = Conversation::new("s".to_string()); diff --git a/site/_data/crates.json b/site/_data/crates.json index 3dadd72c..bc16035a 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.13.1", + "version": "0.13.2", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude", From 74d59e41b7dc8074cda8c7b23c24ac460d0c7cb0 Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:05:12 -0400 Subject: [PATCH 2/3] feat(cli): p export claude takes --derive-session-id `--derive-session-id` 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. Key order and whitespace in the input do not change the ID, and neither does `--cwd`: the ID hashes the input document, not the projection. 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. The rename is `Conversation::rename_session`: the conversation's session ID, every entry's `sessionId`, and every `sessionId` key in every preamble line, nested keys included. The `--output` message names the session ID, so a caller learns the derived ID without parsing the file. The flag joins `--cwd` in `RemoteSessionArgs` behind the `resume-remote` feature. `load_path_doc` splits into `read_doc_json` and `parse_path_doc`; the derivation hashes the document text, not a type round-trip. path-cli 0.20.0; toolpath-cli 0.20.0 (lockstep bump of the shim). --- CHANGELOG.md | 12 ++ CLAUDE.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/path-cli/Cargo.toml | 4 +- crates/path-cli/src/cmd_export.rs | 30 +++- .../path-cli/src/cmd_export/remote_session.rs | 150 ++++++++++++++++-- crates/path-cli/tests/integration.rs | 6 +- crates/toolpath-cli/Cargo.toml | 4 +- site/_data/crates.json | 4 +- 10 files changed, 190 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26395197..000a2f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 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. +- **`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 diff --git a/CLAUDE.md b/CLAUDE.md index 3f74aec0..6620aa34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 91a42ab5..65f6936d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2487,7 +2487,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.19.0" +version = "0.20.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 8b5c78e8..dd8f2007 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index d71d90e8..482ca974 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -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" @@ -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] diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 13549530..375174b1 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -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); } @@ -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); @@ -709,10 +719,18 @@ fn run_claude( #[cfg(not(target_os = "emscripten"))] fn load_path_doc(input: &str) -> Result { + parse_path_doc(&read_doc_json(input)?) +} + +#[cfg(not(target_os = "emscripten"))] +fn read_doc_json(input: &str) -> Result { 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 { + 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!( @@ -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 `://` 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")); diff --git a/crates/path-cli/src/cmd_export/remote_session.rs b/crates/path-cli/src/cmd_export/remote_session.rs index fcfb76ef..8643d8a8 100644 --- a/crates/path-cli/src/cmd_export/remote_session.rs +++ b/crates/path-cli/src/cmd_export/remote_session.rs @@ -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. @@ -20,6 +29,22 @@ pub struct RemoteSessionArgs { pub(super) cwd: Option, } +/// 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 { + 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 @@ -45,7 +70,7 @@ fn parse_cwd_arg(raw: &str) -> Result { 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}; @@ -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 { + fn export_claude_lines( + doc: &toolpath::v1::Graph, + derive_session_id: bool, + cwd: Option<&str>, + ) -> Vec { let temp = tempfile::tempdir().unwrap(); let input_path = temp.path().join("input.json"); let output_path = temp.path().join("out.jsonl"); @@ -104,6 +133,7 @@ mod tests { Some(output_path.clone()), false, RemoteSessionArgs { + derive_session_id, cwd: cwd.map(str::to_string), }, ) @@ -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()); @@ -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") @@ -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()); + } } diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index 750521e0..a54dbd0b 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -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 ")); } @@ -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()); } diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index 31b0dd21..8bcdff2d 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.19.0" +version = "0.20.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,7 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.19.0" } +path-cli = { path = "../path-cli", version = "0.20.0" } anyhow = "1.0" [workspace] diff --git a/site/_data/crates.json b/site/_data/crates.json index bc16035a..c72580a4 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.19.0", + "version": "0.20.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", @@ -121,7 +121,7 @@ }, { "name": "toolpath-cli", - "version": "0.19.0", + "version": "0.20.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli", From c369afa9c1289873924099ec47c6c2fec056b1db Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:05:12 -0400 Subject: [PATCH 3/3] feat(scripts): resume-remote.sh exports with --derive-session-id Step 5 exports with `--cwd --derive-session-id` and reads the remote session ID back from the JSONL: `jq -r '.sessionId'` over every line, then `sort -u`, which yields one line only when every line agrees. The tmux session name is keyed on that ID. The shell `mint_uuid`, the `sha256sum` precondition, and the sed rewrite of `sessionId` are removed. --- CHANGELOG.md | 3 ++- scripts/resume-remote.sh | 56 +++++++++++++--------------------------- 2 files changed, 20 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 000a2f94..01e6ede8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ All notable changes to the Toolpath workspace are documented here. 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. + 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 diff --git a/scripts/resume-remote.sh b/scripts/resume-remote.sh index e985c0b1..e849b353 100755 --- a/scripts/resume-remote.sh +++ b/scripts/resume-remote.sh @@ -43,7 +43,7 @@ # Preconditions. Each one is checked before the first remote write. A # failed check exits 1 with a message. # Local: -# - cargo, git, ssh, jq, sha256sum are on PATH. rsync is on PATH +# - cargo, git, ssh, jq are on PATH. rsync is on PATH # unless --no-sync. scp is on PATH with --setup. # - stdin is a terminal unless --dry-run (tmux attach needs one). # - matches [A-Za-z0-9][A-Za-z0-9@._-]*. @@ -70,14 +70,15 @@ # target/debug/path and does not touch any installed `path`. # 2. Resolve the session. `path p import claude --no-cache` writes # the document to $TMPDIR/path-resume-remote/. -# [shell] Mint the remote session id from the key-sorted document -# (jq -S | sha256sum, formatted as a v4 UUID). # 3. Optional VM creation (--create). # 4. [shell] Call 1: remote home, claude path, tmux presence. Derive # from the remote home unless -C is given. -# 5. `path p export claude --cwd ` projects the document -# to JSONL rooted at the remote project directory. -# [shell] Rewrite the sessionId keys to the minted ID (sed). +# 5. `path p export claude --cwd --derive-session-id` +# projects the document to JSONL rooted at the remote project +# directory under an ID derived from the document (the same +# document yields the same ID on every run). [shell] Check the +# JSONL carries the remote cwd and one session ID; that ID is the +# remote session ID. # [shell] Compute the remote Claude project slug (/, _, and . # become -). # 6. [shell] Call 2: the physical project dir, whether the tmux @@ -180,15 +181,6 @@ check_plain_path() { esac } -# mint_uuid: stdin is the document bytes; stdout is a v4-shaped UUID -# built from the first 128 bits of their SHA-256. -mint_uuid() { - local h - h="$(sha256sum | cut -c1-32)" - printf '%s-%s-4%s-%x%s-%s\n' "${h:0:8}" "${h:8:4}" "${h:13:3}" \ - $(( (16#${h:16:1} & 3) | 8 )) "${h:17:3}" "${h:20:12}" -} - # remote_facts