diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4eecd9e2..58a29f73 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,43 @@
All notable changes to the Toolpath workspace are documented here.
+## Bulk share with `path share --all` — 2026-08-26
+
+- **`path-cli`** (0.19.0): `path share --all` uploads every session
+ across the installed harnesses instead of opening the picker.
+ `--project-under
` restricts it to sessions whose project
+ directory is under that subtree (same matching as `p cache sync`);
+ `--harness` narrows to one harness. Before uploading it prints one
+ line per project directory — session count, per-harness breakdown,
+ and the configured remote where a `[[project]]` rule resolves — and
+ asks for confirmation (`--yes` skips the prompt, `--dry-run` stops
+ after the summary). Destinations follow the single-session rules:
+ `--repo` for everything, else each directory's configured remote,
+ else `/pathstash`. Requires login; `--anon` is rejected. Uploads
+ are sequential, failures warn and continue, and the run exits
+ non-zero if any failed.
+- **`path-cli`**: authed uploads from `share` are recorded in the sync
+ manifest (`uploads` on the artifact's record: graph id, URL — which
+ names the server and repo — and the source fingerprint at upload
+ time). `share --all` skips
+ sessions already uploaded to their destination and, since there is no
+ graph update endpoint, also skips ones that changed since — the
+ summary heading reports both counts. Single-session `share` on an
+ unchanged session prints the existing URL instead of uploading a
+ duplicate. `--force` uploads regardless. Sync and import rewrite the
+ fingerprint but keep the upload history; anonymous uploads are not
+ recorded.
+- **`toolpath-claude`** (0.13.1): `ConversationMetadata.cwd` — the
+ working directory the session recorded, captured during the metadata
+ pass. `project_path` is decoded from the directory slug, which is
+ lossy (`_` and `.` come back as `/`); `cwd` is the real path.
+- **`toolpath-pi`** (0.6.2): `SessionMeta.cwd` from the session header,
+ for the same reason — the session-dir name turns every `-` into `/`.
+- **`path-cli`**: `share` (the picker and `--all`) groups, displays,
+ and resolves configured remotes for claude and pi sessions by their
+ recorded cwd rather than the slug-decoded project, so `[[project]]`
+ rules on directories containing `_`, `.`, or `-` match them.
+
## toolpath-claude 0.13.0 — 2026-08-23
- **Breaking:** `Conversation.segment_ids` replaces `Conversation.session_ids`.
diff --git a/CLAUDE.md b/CLAUDE.md
index 51f0f467..c43430a7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -93,6 +93,7 @@ cargo run -p path-cli -- p import claude --project . --no-cache | path p render
# Share an agent session to Pathbase (interactive picker, single-shot)
cargo run -p path-cli -- share
cargo run -p path-cli -- share --harness claude --session --project /path/to/project
+cargo run -p path-cli -- share --all --project-under ~/work # bulk upload; --dry-run / --yes
# Resume a Toolpath document into a coding agent (interactive harness picker)
cargo run -p path-cli -- resume
@@ -216,8 +217,8 @@ Format references for the agent on-disk formats live at `docs/agents/formats/`
### CLI behaviors
- Interactive pickers: `p import ` auto-launches a fuzzy picker when TTY and no `--session`. Backend: external `fzf` if present, else the embedded skim picker (`embedded-picker` default feature, `crates/path-cli/src/skim_picker.rs`). Multi-select produces a `Graph`; single-select a `Path`. No usable backend falls back to most-recent (with `--project`) or prints the manual recipe. `p list --format tsv` is the machine-readable surface; the trailing column carries `first_user_message`.
-- `path share` is the one-shot `p import | p export pathbase`: probes installed harnesses, aggregates sessions into one picker ranking current-directory sessions first; `--harness`/`--session`/`--project` skip the picker; pathbase flags match `p export pathbase`. When the sync manifest shows the picked session unchanged (`sync::fresh_cache_id`), it uploads the cached doc instead of re-deriving. Uploads carry the same full derivation as local projection — no egress stripping.
-- Share also resolves a **configured share remote** from the session's own directory (the project for path-keyed harnesses, the recorded cwd otherwise — via the doc's `path.base` when no `--project` is in play): `crates/path-cli/src/share_config.rs` checks `~/.toolpath/config.toml` `[[project]]` rules (`dir` subtree match, `~/`-expandable, most specific wins; `remote` = bare `owner/name` or a canonical Pathbase repo web URL `https:///u//`, which also carries the server — the URL scheme is the extension point for future backends, unknown schemes rejected). Precedence: `--repo` flag > config > `/pathstash`; `--url` beats a URL remote's embedded server. A resolved remote prints `Sharing to ()`; hitting one while logged out errors with a `path auth login` hint (no silent anon fall-through), and explicit `--anon` opts out of the mapping. Both sides of the subtree match are prefix-canonicalized (longest existing ancestor resolved, tail re-appended) so macOS `/var`→`/private/var` and deleted checkouts still match. A repo-tracked `.toolpath.toml` is deliberately **not** consulted — a committed file redirecting other users' uploads needs a first-use consent flow (issue #179).
+- `path share` is the one-shot `p import | p export pathbase`: probes installed harnesses, aggregates sessions into one picker ranking current-directory sessions first; `--harness`/`--session`/`--project` skip the picker; pathbase flags match `p export pathbase`. When the sync manifest shows the picked session unchanged (`sync::fresh_cache_id`), it uploads the cached doc instead of re-deriving. Uploads carry the same full derivation as local projection — no egress stripping. `share --all` skips the picker and uploads every session in scope (`--project-under` subtree, `--harness`), printing a per-project summary and confirming first; it needs login. Authed uploads are recorded on the manifest record (`SyncRecord.uploads`: graph id, URL, source stamp; the URL `/u///graphs/` is what destination matching keys on); `--all` skips sessions recorded as uploaded to that destination, unchanged or changed (no update endpoint exists, so a changed session would only duplicate), and single `share` short-circuits on an unchanged one. `--force` ignores the record. `put_record` in `sync/engine.rs` carries `uploads` across sync/import rewrites.
+- Share also resolves a **configured share remote** from the session's own directory (the recorded cwd wherever the provider reports one — claude/pi carry it on `ConversationMetadata.cwd`/`SessionMeta.cwd` because their slug-decoded project paths are lossy; else `--project`; else the doc's `path.base`): `crates/path-cli/src/share_config.rs` checks `~/.toolpath/config.toml` `[[project]]` rules (`dir` subtree match, `~/`-expandable, most specific wins; `remote` = bare `owner/name` or a canonical Pathbase repo web URL `https:///u//`, which also carries the server — the URL scheme is the extension point for future backends, unknown schemes rejected). Precedence: `--repo` flag > config > `/pathstash`; `--url` beats a URL remote's embedded server. A resolved remote prints `Sharing to ()`; hitting one while logged out errors with a `path auth login` hint (no silent anon fall-through), and explicit `--anon` opts out of the mapping. Both sides of the subtree match are prefix-canonicalized (longest existing ancestor resolved, tail re-appended) so macOS `/var`→`/private/var` and deleted checkouts still match. A repo-tracked `.toolpath.toml` is deliberately **not** consulted — a committed file redirecting other users' uploads needs a first-use consent flow (issue #179).
- `path resume ` is the inverse: accepts a Pathbase URL, `owner/repo/slug` shorthand, local file, or cache id; validates a single agent-bearing `Path`; opens a harness picker (pre-selecting `path.meta.source` when installed; `--harness` skips); projects the session into the harness's on-disk layout under `-C/--cwd` (default: shell cwd) and `execvp`'s the harness's resume command (spawn-and-wait on Windows).
- `path query` plans before it scans: `crates/path-cli/src/query/plan.rs` classifies the jaq filter into `PerFileStream` (element-wise, print as you go), `Decompose` (algebraic aggregation with a derived combine), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative so **the planner never changes an answer**; `query/filter.rs` tests assert streamed output equals slurp byte-for-byte. Execution is parallel (`mod.rs::execute_plan`/`for_each_file`): `PerFileStream`/`Decompose` run the whole per-file pipeline (parse → wrap → filter → render/pack) on rayon workers in chunks — partials cross threads as compact JSON bytes since jaq `Val`s are `Rc`-based — while `Slurp` parallelizes only parse/wrap; output stays byte-identical to a sequential scan (ordering, warnings, error precedence), and the emscripten build stays fully sequential. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan; there is no user-facing flag. Caveat: a streamed top-N matches slurp's ranking, but boundary ties may resolve to different rows.
@@ -225,7 +226,7 @@ Format references for the agent on-disk formats live at `docs/agents/formats/`
- `p cache sync [types…]` incrementally ingests artifacts into the cache. Engine in `crates/path-cli/src/sync/` (`engine.rs` loop + `SyncObserver`, `sources.rs` per-provider `ArtifactSource` impls); artifact model in `src/artifact.rs`; progress UI in `cmd_cache.rs`.
- Change detection is **stat-level** (source mtime+size, or DB row updated-at) — a no-op sync reads no session bodies. Claude stamps the *whole session chain* (max segment mtime + summed sizes) because appends land in the newest segment, not the chain head.
-- Manifest at `~/.toolpath/manifest.json`: artifact type → id → `{path?, cache_id, modified?, size?, synced_at}`. Atomic temp+rename writes, advisory lock with read-merge-save, checkpointed every 10 writes — concurrent invocations union their records, and an interrupted run keeps what it derived (derives run newest-first).
+- Manifest at `~/.toolpath/manifest.json`: artifact type → id → `{path?, cache_id, modified?, size?, synced_at, uploads?}`. Atomic temp+rename writes, advisory lock with read-merge-save, checkpointed every 10 writes — concurrent invocations union their records, and an interrupted run keeps what it derived (derives run newest-first).
- Sync writes with refresh semantics and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record without `cache_id` is "known, not materialized" (scope-excluded, or downgraded by `p cache rm`); the next in-scope sync re-materializes it, and sync verifies doc files actually exist before skipping.
- `--project-under ` (on both `p cache sync` and `path query`) restricts to sessions whose project directory is under that subtree. Claude compares in *slug space* (its dir slugs are lossy — `/`, `_`, `.` all became `-`); codex/copilot get a one-line cwd peek only when new/changed, memoized into the record. The stat gate always runs before any scope check. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's recorded cwd, not the lossy slug.
- `path query` runs sync implicitly, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), degrading to the cache as-is if sync fails; `--no-sync` opts out.
diff --git a/Cargo.lock b/Cargo.lock
index 77131a28..0c5cf52d 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.13.0"
+version = "0.13.1"
dependencies = [
"anyhow",
"chrono",
@@ -4372,7 +4372,7 @@ dependencies = [
[[package]]
name = "toolpath-pi"
-version = "0.6.1"
+version = "0.6.2"
dependencies = [
"anyhow",
"chrono",
diff --git a/Cargo.toml b/Cargo.toml
index 5944d9e9..8af01ff1 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.0", path = "crates/toolpath-claude", default-features = false }
+toolpath-claude = { version = "0.13.1", 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" }
@@ -36,8 +36,8 @@ toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" }
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" }
+toolpath-pi = { version = "0.6.2", path = "crates/toolpath-pi" }
+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 68805fd5..5c7fb691 100644
--- a/crates/path-cli/src/cmd_export.rs
+++ b/crates/path-cli/src/cmd_export.rs
@@ -1911,7 +1911,7 @@ fn run_pathbase(args: PathbaseExportArgs) -> Result<()> {
let needs_auth = upload.repo.is_some() || upload.public || upload.name.is_some();
let auth = preflight_auth(&base_url, upload.anon, needs_auth)?;
let summary_source = file.display().to_string();
- run_pathbase_inner(auth, base_url, upload, &body, &summary_source)
+ run_pathbase_inner(auth, base_url, upload, &body, &summary_source).map(|_| ())
}
}
@@ -1933,6 +1933,16 @@ pub(crate) fn resolve_upload_base_url(args: &PathbaseUploadArgs) -> String {
resolve_url(None)
}
+#[cfg(not(target_os = "emscripten"))]
+/// An authed upload's result: where it landed and what the server
+/// returned. `run_pathbase_inner` yields `None` for anonymous uploads.
+#[cfg(not(target_os = "emscripten"))]
+pub(crate) struct UploadedGraph {
+ pub(crate) owner: String,
+ pub(crate) repo: String,
+ pub(crate) created: crate::cmd_pathbase::CreatedGraph,
+}
+
#[cfg(not(target_os = "emscripten"))]
pub(crate) fn run_pathbase_inner(
auth: crate::cmd_pathbase::AuthMode,
@@ -1940,7 +1950,7 @@ pub(crate) fn run_pathbase_inner(
args: PathbaseUploadArgs,
body: &str,
summary_source: &str,
-) -> Result<()> {
+) -> Result> {
use crate::cmd_pathbase::{AuthMode, anon_graphs_post, graphs_post, repos_post};
use pathbase_client::types::Visibility;
@@ -1969,7 +1979,7 @@ pub(crate) fn run_pathbase_inner(
body.len()
);
println!("{printable}");
- return Ok(());
+ return Ok(None);
}
AuthMode::Authed { token, username } => (token, username),
};
@@ -2021,7 +2031,11 @@ pub(crate) fn run_pathbase_inner(
body.len()
);
println!("{}", created.url);
- Ok(())
+ Ok(Some(UploadedGraph {
+ owner,
+ repo,
+ created,
+ }))
}
/// Default display label for a graph uploaded via `export pathbase`.
@@ -2033,7 +2047,7 @@ pub(crate) fn run_pathbase_inner(
/// hash the canonical JSON and use a short hex prefix so re-uploads
/// of the same content produce the same display label.
#[cfg(not(target_os = "emscripten"))]
-fn derive_name(doc: &toolpath::v1::Graph) -> String {
+pub(crate) fn derive_name(doc: &toolpath::v1::Graph) -> String {
let raw = match doc.single_path() {
Some(p) => p.path.id.as_str(),
None => doc.graph.id.as_str(),
diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs
index 0d6ba9c4..1687d6d3 100644
--- a/crates/path-cli/src/cmd_share.rs
+++ b/crates/path-cli/src/cmd_share.rs
@@ -57,6 +57,56 @@ pub struct ShareArgs {
/// Skip writing the cache; derive in-memory only
#[arg(long)]
pub no_cache: bool,
+
+ /// Upload every session instead of picking one. Requires login.
+ /// Every run uploads everything in scope — there is no record of
+ /// what was shared before, so re-running creates duplicate graphs.
+ #[arg(long, conflicts_with_all = ["session", "anon", "project"])]
+ pub all: bool,
+
+ /// With --all: only sessions whose project directory is under this path
+ #[arg(long, requires = "all", value_name = "DIR")]
+ pub project_under: Option,
+
+ /// With --all: print what would be uploaded and exit
+ #[arg(long, requires = "all")]
+ pub dry_run: bool,
+
+ /// With --all: skip the confirmation prompt
+ #[arg(long, short = 'y', requires = "all")]
+ pub yes: bool,
+
+ /// Upload even if the session was already uploaded to this
+ /// destination (recorded in the sync manifest)
+ #[arg(long)]
+ pub force: bool,
+}
+
+/// Which sessions `gather_artifacts` keeps, by project directory.
+#[derive(Debug, Clone, Copy)]
+pub(crate) enum ProjectScope<'a> {
+ /// Sessions tied to exactly this directory (`--project`).
+ Exact(&'a std::path::Path),
+ /// Sessions whose directory is this one or below it (`--project-under`).
+ Under(&'a std::path::Path),
+}
+
+impl ProjectScope<'_> {
+ fn admits(&self, t: ArtifactType, dir: &str) -> bool {
+ match self {
+ ProjectScope::Exact(p) => paths_match(std::path::Path::new(dir), p),
+ ProjectScope::Under(p) => crate::sync::sources::project_in_scope(t, dir, p),
+ }
+ }
+}
+
+/// `None` scope admits everything, including sessions with no known
+/// directory; any scope excludes those.
+fn admits(scope: Option<&ProjectScope<'_>>, t: ArtifactType, dir: Option<&str>) -> bool {
+ match scope {
+ None => true,
+ Some(s) => dir.is_some_and(|d| s.admits(t, d)),
+ }
}
/// One artifact surfaced by a provider — today always an agent session.
@@ -64,9 +114,12 @@ pub struct ShareArgs {
#[derive(Debug, Clone)]
pub(crate) struct ArtifactRow {
pub(crate) artifact_type: ArtifactType,
- /// Project path for keyed providers; `None` for codex/opencode.
+ /// Project path for keyed providers, as the provider keys it
+ /// (claude and pi decode theirs from a lossy slug); `None` for
+ /// session-keyed providers.
pub(crate) path: Option,
- /// Recorded cwd from the session (codex/opencode only).
+ /// Working directory the session recorded. The real directory
+ /// wherever the provider reports one; `None` when it doesn't.
pub(crate) cwd: Option,
pub(crate) session_id: String,
pub(crate) title: String,
@@ -81,55 +134,54 @@ pub(crate) struct ArtifactRow {
/// rows whose project (or recorded cwd) canonicalizes to `cwd` come
/// first, sorted by descending `last_activity`.
///
-/// Filters: `harness_filter` keeps only rows from one harness; `project_filter`
+/// Filters: `harness_filter` keeps only rows from one harness; `scope`
/// keeps only rows whose project (for keyed) or cwd (for session-keyed)
-/// canonicalizes to that path.
+/// the scope admits.
pub(crate) fn gather_artifacts(
bundle: &HarnessBundle,
cwd: &std::path::Path,
harness_filter: Option,
- project_filter: Option<&std::path::Path>,
+ scope: Option<&ProjectScope<'_>>,
) -> Vec {
let mut rows = Vec::new();
let canonical_cwd = canonicalize_or_self(cwd);
- let canonical_project = project_filter.map(canonicalize_or_self);
let want = |h: ArtifactType| harness_filter.is_none_or(|f| f == h);
if want(ArtifactType::Claude)
&& let Some(mgr) = &bundle.claude
{
- collect_claude(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
+ collect_claude(mgr, &canonical_cwd, scope, &mut rows);
}
if want(ArtifactType::Gemini)
&& let Some(mgr) = &bundle.gemini
{
- collect_gemini(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
+ collect_gemini(mgr, &canonical_cwd, scope, &mut rows);
}
if want(ArtifactType::Pi)
&& let Some(mgr) = &bundle.pi
{
- collect_pi(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
+ collect_pi(mgr, &canonical_cwd, scope, &mut rows);
}
if want(ArtifactType::Codex)
&& let Some(mgr) = &bundle.codex
{
- collect_codex(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
+ collect_codex(mgr, &canonical_cwd, scope, &mut rows);
}
if want(ArtifactType::Copilot)
&& let Some(mgr) = &bundle.copilot
{
- collect_copilot(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
+ collect_copilot(mgr, &canonical_cwd, scope, &mut rows);
}
if want(ArtifactType::Opencode)
&& let Some(mgr) = &bundle.opencode
{
- collect_opencode(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
+ collect_opencode(mgr, &canonical_cwd, scope, &mut rows);
}
if want(ArtifactType::Cursor)
&& let Some(mgr) = &bundle.cursor
{
- collect_cursor(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
+ collect_cursor(mgr, &canonical_cwd, scope, &mut rows);
}
rows.sort_by(|a, b| {
@@ -151,7 +203,7 @@ fn paths_match(a: &std::path::Path, b: &std::path::Path) -> bool {
fn collect_claude(
mgr: &toolpath_claude::ClaudeConvo,
canonical_cwd: &std::path::Path,
- project_filter: Option<&std::path::Path>,
+ scope: Option<&ProjectScope<'_>>,
out: &mut Vec,
) {
let projects = match mgr.list_projects() {
@@ -165,9 +217,7 @@ fn collect_claude(
};
for project in projects {
let project_path = std::path::Path::new(&project);
- if let Some(filter) = project_filter
- && !paths_match(project_path, filter)
- {
+ if !admits(scope, ArtifactType::Claude, Some(&project)) {
continue;
}
let metas = match mgr.list_conversation_metadata(&project) {
@@ -179,10 +229,13 @@ fn collect_claude(
};
let matches_cwd = paths_match(project_path, canonical_cwd);
for m in metas {
+ // The slug-decoded project is what the provider keys on;
+ // the recorded cwd is the real directory for display,
+ // grouping, and remote lookup.
out.push(ArtifactRow {
artifact_type: ArtifactType::Claude,
path: Some(m.project_path),
- cwd: None,
+ cwd: m.cwd,
session_id: m.session_id,
title: m
.first_user_message
@@ -198,7 +251,7 @@ fn collect_claude(
fn collect_gemini(
mgr: &toolpath_gemini::GeminiConvo,
canonical_cwd: &std::path::Path,
- project_filter: Option<&std::path::Path>,
+ scope: Option<&ProjectScope<'_>>,
out: &mut Vec,
) {
let projects = match mgr.list_projects() {
@@ -212,9 +265,7 @@ fn collect_gemini(
};
for project in projects {
let project_path = std::path::Path::new(&project);
- if let Some(filter) = project_filter
- && !paths_match(project_path, filter)
- {
+ if !admits(scope, ArtifactType::Gemini, Some(&project)) {
continue;
}
let metas = match mgr.list_conversation_metadata(&project) {
@@ -245,7 +296,7 @@ fn collect_gemini(
fn collect_pi(
mgr: &toolpath_pi::PiConvo,
canonical_cwd: &std::path::Path,
- project_filter: Option<&std::path::Path>,
+ scope: Option<&ProjectScope<'_>>,
out: &mut Vec,
) {
let projects = match mgr.list_projects() {
@@ -259,9 +310,7 @@ fn collect_pi(
};
for project in projects {
let project_path = std::path::Path::new(&project);
- if let Some(filter) = project_filter
- && !paths_match(project_path, filter)
- {
+ if !admits(scope, ArtifactType::Pi, Some(&project)) {
continue;
}
let metas = match mgr.list_sessions(&project) {
@@ -289,7 +338,7 @@ fn collect_pi(
out.push(ArtifactRow {
artifact_type: ArtifactType::Pi,
path: Some(project.clone()),
- cwd: None,
+ cwd: m.cwd,
session_id: m.id,
title: m
.first_user_message
@@ -305,7 +354,7 @@ fn collect_pi(
fn collect_codex(
mgr: &toolpath_codex::CodexConvo,
canonical_cwd: &std::path::Path,
- project_filter: Option<&std::path::Path>,
+ scope: Option<&ProjectScope<'_>>,
out: &mut Vec,
) {
let metas = match mgr.list_sessions() {
@@ -319,14 +368,8 @@ fn collect_codex(
};
for m in metas {
let cwd_str = m.cwd.as_ref().map(|p| p.to_string_lossy().into_owned());
- if let Some(filter) = project_filter {
- let stored = match cwd_str.as_deref() {
- Some(s) => std::path::PathBuf::from(s),
- None => continue,
- };
- if !paths_match(&stored, filter) {
- continue;
- }
+ if !admits(scope, ArtifactType::Codex, cwd_str.as_deref()) {
+ continue;
}
let matches_cwd = m
.cwd
@@ -351,7 +394,7 @@ fn collect_codex(
fn collect_copilot(
mgr: &toolpath_copilot::CopilotConvo,
canonical_cwd: &std::path::Path,
- project_filter: Option<&std::path::Path>,
+ scope: Option<&ProjectScope<'_>>,
out: &mut Vec,
) {
let metas = match mgr.list_sessions() {
@@ -366,11 +409,8 @@ fn collect_copilot(
for m in metas {
// Copilot stores cwd as a String (from session.start `context.cwd`).
let stored = m.cwd.as_deref().map(std::path::PathBuf::from);
- if let Some(filter) = project_filter {
- match &stored {
- Some(p) if paths_match(p, filter) => {}
- _ => continue,
- }
+ if !admits(scope, ArtifactType::Copilot, m.cwd.as_deref()) {
+ continue;
}
let matches_cwd = stored
.as_deref()
@@ -394,7 +434,7 @@ fn collect_copilot(
fn collect_opencode(
mgr: &toolpath_opencode::OpencodeConvo,
canonical_cwd: &std::path::Path,
- project_filter: Option<&std::path::Path>,
+ scope: Option<&ProjectScope<'_>>,
out: &mut Vec,
) {
let metas = match mgr.io().list_session_metadata(None) {
@@ -407,13 +447,11 @@ fn collect_opencode(
}
};
for m in metas {
- if let Some(filter) = project_filter
- && !paths_match(&m.directory, filter)
- {
+ let cwd_str = m.directory.to_string_lossy().into_owned();
+ if !admits(scope, ArtifactType::Opencode, Some(&cwd_str)) {
continue;
}
let matches_cwd = paths_match(&m.directory, canonical_cwd);
- let cwd_str = m.directory.to_string_lossy().into_owned();
let title = match (&m.first_user_message, m.title.is_empty()) {
(Some(s), _) if !s.is_empty() => s.clone(),
(_, false) => m.title.clone(),
@@ -435,7 +473,7 @@ fn collect_opencode(
fn collect_cursor(
mgr: &toolpath_cursor::CursorConvo,
canonical_cwd: &std::path::Path,
- project_filter: Option<&std::path::Path>,
+ scope: Option<&ProjectScope<'_>>,
out: &mut Vec,
) {
let metas = match mgr.io().list_session_metadata() {
@@ -456,13 +494,11 @@ fn collect_cursor(
let Some(workspace) = m.workspace_path.as_ref() else {
continue;
};
- if let Some(filter) = project_filter
- && !paths_match(workspace, filter)
- {
+ let cwd_str = workspace.to_string_lossy().into_owned();
+ if !admits(scope, ArtifactType::Cursor, Some(&cwd_str)) {
continue;
}
let matches_cwd = paths_match(workspace, canonical_cwd);
- let cwd_str = workspace.to_string_lossy().into_owned();
let title = match (&m.first_user_message, &m.name) {
(Some(s), _) if !s.is_empty() => s.clone(),
(_, Some(n)) if !n.is_empty() => n.clone(),
@@ -505,13 +541,21 @@ pub fn run(args: ShareArgs) -> Result<()> {
// Explicit-args: validate creds before derive so a credential
// failure doesn't waste the derive/cache work.
let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?;
- return share_explicit(h, session.as_str(), &args, auth, base_url);
+ return share_explicit(h, session.as_str(), &args, auth, base_url, None);
}
let cwd = std::env::current_dir()?;
let bundle = HarnessBundle::from_environment();
+
+ if args.all {
+ let auth = crate::cmd_pathbase::preflight_auth(&base_url, false, true)
+ .context("`share --all` requires an authenticated upload")?;
+ return share_all(&args, harness, &bundle, &cwd, auth, base_url);
+ }
+
let project_filter = args.project.as_deref();
- let rows = gather_artifacts(&bundle, &cwd, harness, project_filter);
+ let scope = project_filter.map(ProjectScope::Exact);
+ let rows = gather_artifacts(&bundle, &cwd, harness, scope.as_ref());
if rows.is_empty() {
return bail_no_sessions(&bundle, project_filter);
@@ -579,12 +623,26 @@ pub fn run(args: ShareArgs) -> Result<()> {
None
},
no_cache: args.no_cache,
+ all: false,
+ project_under: None,
+ dry_run: false,
+ yes: false,
+ force: args.force,
};
// Show the conversation title in the confirmation line; the session id
// is opaque and doesn't help the user verify they picked the right
// thing. `{:?}` adds the surrounding quotes per the spec.
eprintln!("Picked {} session {:?}", h.name(), title);
- share_explicit(h, &session, &explicit, auth, base_url)
+ // The picker line carries the provider's project key, which for
+ // claude and pi is decoded from a lossy slug. The row still has
+ // the session's recorded cwd; that is the directory the configured
+ // remote should be looked up under.
+ let session_dir = rows
+ .iter()
+ .find(|r| r.artifact_type == h && r.session_id == session)
+ .and_then(|r| r.cwd.clone())
+ .map(PathBuf::from);
+ share_explicit(h, &session, &explicit, auth, base_url, session_dir)
}
fn bail_no_sessions(
@@ -761,12 +819,17 @@ fn harness_status_cursor(bundle: &HarnessBundle, home: Option<&std::path::Path>)
}
}
+/// `session_dir` is the directory to resolve a configured remote
+/// under when the caller knows it (the picker passes the row's
+/// recorded cwd); otherwise it comes from `--project`, then from the
+/// derived document's `path.base`.
fn share_explicit(
harness: ArtifactType,
session: &str,
args: &ShareArgs,
auth: crate::cmd_pathbase::AuthMode,
base_url: String,
+ session_dir: Option,
) -> Result<()> {
let project = match (harness.path_keyed(), args.project.as_ref()) {
(true, Some(p)) => Some(p.to_string_lossy().into_owned()),
@@ -777,52 +840,239 @@ fn share_explicit(
(false, _) => None,
};
- // Fast path: when the manifest shows this exact source state is
- // already in the cache, upload the cached doc instead of re-deriving
- // — a derive would reproduce it byte-for-byte anyway.
- if !args.no_cache
+ let loaded = load_session(harness, project.as_deref(), session, args.no_cache)?;
+ let summary = format!("{} session {}", harness.name(), loaded.cache_id);
+ match &loaded.origin {
+ LoadOrigin::Cache => {
+ eprintln!("Cache is current for {summary}; uploading without re-deriving")
+ }
+ LoadOrigin::Derived(path) => eprintln!("Cached {summary} ({})", path.display()),
+ LoadOrigin::DerivedUncached => {}
+ }
+ let session_dir = session_dir
+ .or_else(|| project.as_deref().map(PathBuf::from))
+ .or_else(|| {
+ loaded
+ .doc
+ .as_ref()
+ .map(std::borrow::Cow::Borrowed)
+ .or_else(|| {
+ toolpath::v1::Graph::from_json(&loaded.body)
+ .ok()
+ .map(std::borrow::Cow::Owned)
+ })
+ .and_then(|doc| doc_session_dir(&doc))
+ });
+ let dest = resolve_destination(args, &auth, base_url, session_dir)?;
+
+ let stamp = source_stamp(
+ &HarnessBundle::from_environment(),
+ harness,
+ project.as_deref(),
+ session,
+ );
+ if let crate::cmd_pathbase::AuthMode::Authed { username, .. } = &auth {
+ let repo = dest
+ .repo
+ .as_ref()
+ .map(|r| format!("{}/{}", r.owner, r.name))
+ .unwrap_or_else(|| format!("{username}/pathstash"));
+ let repo_url = repo_url(&dest.base_url, &repo);
+ match upload_class(harness, session, &repo_url, stamp) {
+ UploadClass::Uploaded(url) if !args.force => {
+ eprintln!(
+ "Already uploaded to {repo}, unchanged since; pass --force to upload again"
+ );
+ println!("{url}");
+ return Ok(());
+ }
+ UploadClass::Uploaded(url) => {
+ eprintln!("Already uploaded to {repo} ({url}); uploading again");
+ }
+ UploadClass::Changed(url) => {
+ eprintln!(
+ "Previously uploaded to {repo} ({url}); session changed since, uploading a new graph"
+ );
+ }
+ UploadClass::New => {}
+ }
+ }
+
+ let upload = crate::cmd_export::PathbaseUploadArgs {
+ url: args.url.clone(),
+ anon: args.anon,
+ repo: dest.repo,
+ name: args.name.clone(),
+ public: args.public,
+ };
+ let base_url = dest.base_url.clone();
+ let done =
+ crate::cmd_export::run_pathbase_inner(auth, dest.base_url, upload, &loaded.body, &summary)?;
+ if let Some(done) = done {
+ record_upload(
+ harness,
+ session,
+ project.as_deref(),
+ &repo_url(&base_url, &format!("{}/{}", done.owner, done.repo)),
+ &done.created,
+ stamp,
+ );
+ }
+ Ok(())
+}
+
+/// The provider's project key for a row — what derive and stamp take
+/// as `project`; `None` for session-keyed harnesses.
+fn row_project(row: &ArtifactRow) -> Option<&str> {
+ if row.artifact_type.path_keyed() {
+ row.path.as_deref()
+ } else {
+ None
+ }
+}
+
+/// `/u//` — the key upload records match on.
+fn repo_url(base_url: &str, repo: &str) -> String {
+ format!("{}/u/{repo}", base_url.trim_end_matches('/'))
+}
+
+/// The manifest's view of one session relative to one destination.
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum UploadClass {
+ New,
+ /// Uploaded and unchanged since; carries the graph URL.
+ Uploaded(String),
+ /// Uploaded but the source changed since; carries the graph URL.
+ Changed(String),
+}
+
+/// Current source fingerprint for a session; `(None, None)` when the
+/// provider can't stat it, which reads as "changed".
+fn source_stamp(
+ bundle: &HarnessBundle,
+ harness: ArtifactType,
+ project: Option<&str>,
+ session: &str,
+) -> crate::sync::sources::Stamp {
+ crate::sync::sources::source_for(bundle, harness)
+ .and_then(|s| s.stamp(project, session))
+ .unwrap_or((None, None))
+}
+
+fn upload_class(
+ harness: ArtifactType,
+ session: &str,
+ repo_url: &str,
+ stamp: crate::sync::sources::Stamp,
+) -> UploadClass {
+ let manifest = crate::config::config_dir()
+ .and_then(|dir| crate::sync::load_manifest(&dir))
+ .unwrap_or_default();
+ classify(&manifest, harness, session, repo_url, stamp)
+}
+
+fn classify(
+ manifest: &crate::sync::Manifest,
+ harness: ArtifactType,
+ session: &str,
+ repo_url: &str,
+ stamp: crate::sync::sources::Stamp,
+) -> UploadClass {
+ match crate::sync::upload_state(manifest, harness, session, repo_url, stamp) {
+ crate::sync::UploadState::New => UploadClass::New,
+ crate::sync::UploadState::Uploaded(u) => UploadClass::Uploaded(u.url.clone()),
+ crate::sync::UploadState::Changed(u) => UploadClass::Changed(u.url.clone()),
+ }
+}
+
+/// Remember a successful authed upload in the manifest. A failure
+/// here only costs a future skip, so it warns instead of erroring.
+fn record_upload(
+ harness: ArtifactType,
+ session: &str,
+ project: Option<&str>,
+ repo_url: &str,
+ created: &crate::cmd_pathbase::CreatedGraph,
+ stamp: crate::sync::sources::Stamp,
+) {
+ let record = crate::sync::UploadRecord {
+ graph_id: created.id.clone(),
+ url: created.url.clone(),
+ modified: stamp.0,
+ size: stamp.1,
+ uploaded_at: Utc::now(),
+ };
+ if let Err(e) = crate::config::config_dir().and_then(|dir| {
+ crate::sync::record_upload(&dir, harness, session, project, repo_url, record)
+ }) {
+ eprintln!("warning: upload not recorded in sync manifest: {e}");
+ }
+}
+
+/// A session's document as it should be uploaded.
+struct LoadedSession {
+ cache_id: String,
+ body: String,
+ /// The parsed document when this load derived it; `None` when the
+ /// body was read straight from the cache.
+ doc: Option,
+ /// Where the load came from, for the caller's status line.
+ origin: LoadOrigin,
+}
+
+enum LoadOrigin {
+ /// Read from the cache: the manifest showed the source unchanged.
+ Cache,
+ /// Derived from the source and written to the cache at this path.
+ Derived(PathBuf),
+ /// Derived in memory only (`--no-cache`).
+ DerivedUncached,
+}
+
+impl LoadOrigin {
+ fn short(&self) -> &'static str {
+ match self {
+ LoadOrigin::Cache => "cached",
+ LoadOrigin::Derived(_) | LoadOrigin::DerivedUncached => "derived",
+ }
+ }
+}
+
+/// The current document for one session: read from the cache when the
+/// manifest shows the source unchanged since it was written (a derive
+/// would reproduce it byte-for-byte), otherwise derived — and, unless
+/// `no_cache`, written to the cache so cache and upload agree.
+fn load_session(
+ harness: ArtifactType,
+ project: Option<&str>,
+ session: &str,
+ no_cache: bool,
+) -> Result {
+ if !no_cache
&& let Some(cache_id) = crate::sync::fresh_cache_id(
&HarnessBundle::from_environment(),
harness,
- project.as_deref(),
+ project,
session,
)
{
let doc_path = crate::cache::cache_path(&cache_id)?;
let body = std::fs::read_to_string(&doc_path)
.with_context(|| format!("Failed to read {}", doc_path.display()))?;
- eprintln!(
- "Cache is current for {} session {cache_id}; uploading without re-deriving",
- harness.name()
- );
- let session_dir = project.as_deref().map(PathBuf::from).or_else(|| {
- toolpath::v1::Graph::from_json(&body)
- .ok()
- .and_then(|doc| doc_session_dir(&doc))
+ return Ok(LoadedSession {
+ cache_id,
+ body,
+ doc: None,
+ origin: LoadOrigin::Cache,
});
- let dest = resolve_destination(args, &auth, base_url, session_dir)?;
- let summary = format!("{} session {}", harness.name(), cache_id);
- let upload = crate::cmd_export::PathbaseUploadArgs {
- url: args.url.clone(),
- anon: args.anon,
- repo: dest.repo,
- name: args.name.clone(),
- public: args.public,
- };
- return crate::cmd_export::run_pathbase_inner(auth, dest.base_url, upload, &body, &summary);
}
- let derived = derive_session(harness, project.as_deref(), session)?;
- let summary = format!("{} session {}", harness.name(), derived.cache_id);
-
- if !args.no_cache {
- // The cache entry should always reflect what was just uploaded.
- // `path share` is "ship the current state of this session"; if
- // the conversation has grown since a prior share, the in-memory
- // body has the new turns but a stale cache file would not — and
- // the upload uses the fresh body, not the cache. Always
- // overwrite so cache and upload agree (use `--no-cache` to skip
- // the cache write entirely).
+ let derived = derive_session(harness, project, session)?;
+ let mut origin = LoadOrigin::DerivedUncached;
+ if !no_cache {
+ // Always overwrite: `share` ships the current state of the
+ // session, and a stale cache file from a prior share would
+ // otherwise disagree with what was uploaded.
let path = crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?;
// Transitional: `share` does not take `&Config` yet; load one
// for the engine. A load failure degrades like a manifest-write
@@ -833,28 +1083,418 @@ fn share_explicit(
{
eprintln!("warning: sync manifest not updated: {e}");
}
- eprintln!(
- "Cached {} session → {} ({})",
- harness.name(),
- derived.cache_id,
- path.display()
- );
+ origin = LoadOrigin::Derived(path);
}
-
- let session_dir = project
- .as_deref()
- .map(PathBuf::from)
- .or_else(|| doc_session_dir(&derived.doc));
- let dest = resolve_destination(args, &auth, base_url, session_dir)?;
let body = derived.doc.to_json()?;
- let upload = crate::cmd_export::PathbaseUploadArgs {
- url: args.url.clone(),
- anon: args.anon,
- repo: dest.repo,
- name: args.name.clone(),
- public: args.public,
+ Ok(LoadedSession {
+ cache_id: derived.cache_id,
+ body,
+ doc: Some(derived.doc),
+ origin,
+ })
+}
+
+// ── `share --all` ───────────────────────────────────────────────────
+
+/// Where one bulk-uploaded session goes.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
+struct BulkTarget {
+ owner: String,
+ repo: String,
+ base_url: String,
+}
+
+impl BulkTarget {
+ fn display(&self) -> String {
+ format!("{}/{}", self.owner, self.repo)
+ }
+
+ fn url(&self) -> String {
+ format!("{}/u/{}/{}", self.base_url, self.owner, self.repo)
+ }
+}
+
+/// One project directory's worth of sessions in the bulk summary.
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct BulkGroup {
+ /// Session directory as reported by the providers; `None` for
+ /// sessions with no recorded cwd.
+ dir: Option,
+ /// Row indices into the gathered artifacts.
+ rows: Vec,
+ /// Manifest classification per row, parallel to `rows`.
+ states: Vec,
+ /// A remote configured for this directory, when one resolved and
+ /// no `--repo` overrides it.
+ configured: Option,
+}
+
+/// Group `rows` by session directory — the recorded cwd when known,
+/// else the provider's project key — resolving each directory's
+/// configured remote once through `remote` and classifying each row
+/// against its destination through `state`. Groups sort by descending
+/// session count, then directory; the no-directory group sorts last.
+fn plan_bulk(
+ rows: &[ArtifactRow],
+ default_target: &BulkTarget,
+ mut remote: impl FnMut(&str) -> Result>,
+ mut state: impl FnMut(&ArtifactRow, &BulkTarget) -> UploadClass,
+) -> Result> {
+ use std::collections::BTreeMap;
+ let mut by_dir: BTreeMap, Vec> = BTreeMap::new();
+ for (i, row) in rows.iter().enumerate() {
+ let dir = row.cwd.clone().or_else(|| row.path.clone());
+ by_dir.entry(dir).or_default().push(i);
+ }
+ let mut groups = Vec::with_capacity(by_dir.len());
+ for (dir, idxs) in by_dir {
+ let configured = match &dir {
+ Some(d) => remote(d)?,
+ None => None,
+ };
+ let target = configured.as_ref().unwrap_or(default_target);
+ let states = idxs.iter().map(|&i| state(&rows[i], target)).collect();
+ groups.push(BulkGroup {
+ dir,
+ rows: idxs,
+ states,
+ configured,
+ });
+ }
+ groups.sort_by(|a, b| {
+ b.rows
+ .len()
+ .cmp(&a.rows.len())
+ .then_with(|| match (&a.dir, &b.dir) {
+ (Some(x), Some(y)) => x.cmp(y),
+ (Some(_), None) => std::cmp::Ordering::Less,
+ (None, Some(_)) => std::cmp::Ordering::Greater,
+ (None, None) => std::cmp::Ordering::Equal,
+ })
+ });
+ Ok(groups)
+}
+
+/// Per-harness session counts, most first, then by name.
+fn harness_totals(rows: &[ArtifactRow]) -> Vec<(ArtifactType, usize)> {
+ let mut totals: Vec<(ArtifactType, usize)> = Vec::new();
+ for row in rows {
+ match totals.iter_mut().find(|(h, _)| *h == row.artifact_type) {
+ Some((_, n)) => *n += 1,
+ None => totals.push((row.artifact_type, 1)),
+ }
+ }
+ totals.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name().cmp(b.0.name())));
+ totals
+}
+
+/// How a group's directory is shown: relative to `--project-under`
+/// when given (`.` for the root itself), else `~`-relative.
+fn group_label(
+ dir: Option<&str>,
+ project_under: Option<&std::path::Path>,
+ home: Option<&std::path::Path>,
+) -> String {
+ let Some(dir) = dir else {
+ return "(no project)".to_string();
};
- crate::cmd_export::run_pathbase_inner(auth, dest.base_url, upload, &body, &summary)
+ let path = std::path::Path::new(dir);
+ if let Some(under) = project_under {
+ let candidates = [under.to_path_buf(), canonicalize_or_self(under)];
+ if let Some(rest) = candidates.iter().find_map(|u| path.strip_prefix(u).ok()) {
+ return if rest.as_os_str().is_empty() {
+ ".".to_string()
+ } else {
+ rest.display().to_string()
+ };
+ }
+ }
+ crate::config::home_relative(path, home)
+}
+
+/// (new, already uploaded, changed since upload) for one group.
+fn tally(states: &[UploadClass]) -> (usize, usize, usize) {
+ states.iter().fold((0, 0, 0), |(n, u, c), st| match st {
+ UploadClass::New => (n + 1, u, c),
+ UploadClass::Uploaded(_) => (n, u + 1, c),
+ UploadClass::Changed(_) => (n, u, c + 1),
+ })
+}
+
+/// The pre-upload summary: a heading with what was found and what
+/// will upload, harness totals, then one line per project directory
+/// with the number of sessions that will upload, what is being skipped
+/// (already uploaded / changed since), and the configured remote if
+/// any. The prompt line is the caller's.
+fn render_bulk_summary(
+ groups: &[BulkGroup],
+ rows: &[ArtifactRow],
+ project_under: Option<&std::path::Path>,
+ home: Option<&std::path::Path>,
+) -> String {
+ let total = rows.len();
+ let mut out = match project_under {
+ Some(p) => format!(
+ "Found {total} sessions under {}",
+ crate::config::home_relative(p, home)
+ ),
+ None => format!("Found {total} sessions"),
+ };
+ let to_upload: usize = groups.iter().map(|g| tally(&g.states).0).sum();
+ if to_upload != total {
+ out.push_str(&format!(", {to_upload} to upload"));
+ }
+ out.push('\n');
+ let totals: Vec = harness_totals(rows)
+ .iter()
+ .map(|(t, n)| format!("{} {n}", t.name()))
+ .collect();
+ out.push_str(&format!(" {}\n\n", totals.join(", ")));
+
+ let labels: Vec = groups
+ .iter()
+ .map(|g| group_label(g.dir.as_deref(), project_under, home))
+ .collect();
+ let label_width = labels.iter().map(|l| l.len()).max().unwrap_or(0);
+ let counts: Vec = groups.iter().map(|g| tally(&g.states).0).collect();
+ let count_width = counts
+ .iter()
+ .map(|n| n.to_string().len())
+ .max()
+ .unwrap_or(1);
+ for ((g, label), count) in groups.iter().zip(&labels).zip(&counts) {
+ let mut line = format!(" {label:count_width$}");
+ let (_, uploaded, changed) = tally(&g.states);
+ let mut notes = Vec::new();
+ if uploaded > 0 {
+ notes.push(format!("{uploaded} already uploaded"));
+ }
+ if changed > 0 {
+ notes.push(format!("{changed} changed since upload"));
+ }
+ if !notes.is_empty() {
+ line.push_str(&format!(" ({})", notes.join(", ")));
+ }
+ if let Some(t) = &g.configured {
+ line.push_str(&format!(" → {}", t.display()));
+ }
+ out.push_str(&line);
+ out.push('\n');
+ }
+ out.push('\n');
+ out
+}
+
+/// "N sessions to " for the prompt and dry-run lines.
+fn upload_phrase(to_upload: usize, groups: &[BulkGroup], default_target: &BulkTarget) -> String {
+ let mut phrase = format!("{to_upload} sessions to {}", default_target.display());
+ if groups.iter().any(|g| g.configured.is_some()) {
+ phrase.push_str(" (or the noted remote)");
+ }
+ phrase
+}
+
+fn share_all(
+ args: &ShareArgs,
+ harness: Option,
+ bundle: &HarnessBundle,
+ cwd: &std::path::Path,
+ auth: crate::cmd_pathbase::AuthMode,
+ base_url: String,
+) -> Result<()> {
+ let crate::cmd_pathbase::AuthMode::Authed { token, username } = auth else {
+ anyhow::bail!("`share --all` requires login. Run `path auth login`.");
+ };
+
+ let scope = args.project_under.as_deref().map(ProjectScope::Under);
+ let rows = gather_artifacts(bundle, cwd, harness, scope.as_ref());
+ if rows.is_empty() {
+ match args.project_under.as_deref() {
+ Some(p) => anyhow::bail!("No agent sessions found under {}.", p.display()),
+ None => return bail_no_sessions(bundle, None),
+ }
+ }
+
+ let default_target = BulkTarget {
+ owner: args
+ .repo
+ .as_ref()
+ .map(|r| r.owner.clone())
+ .unwrap_or_else(|| username.clone()),
+ repo: args
+ .repo
+ .as_ref()
+ .map(|r| r.name.clone())
+ .unwrap_or_else(|| "pathstash".to_string()),
+ base_url: base_url.clone(),
+ };
+
+ let manifest = if args.force {
+ crate::sync::Manifest::default()
+ } else {
+ crate::sync::load_manifest(&crate::config::config_dir()?)?
+ };
+ let mut resolved: std::collections::HashMap> = Default::default();
+ let groups = plan_bulk(
+ &rows,
+ &default_target,
+ |dir| {
+ if args.repo.is_some() {
+ return Ok(None);
+ }
+ if let Some(t) = resolved.get(dir) {
+ return Ok(t.clone());
+ }
+ let target =
+ crate::share_config::resolve_remote(std::path::Path::new(dir))?.map(|found| {
+ BulkTarget {
+ owner: found.repo.owner,
+ repo: found.repo.name,
+ base_url: match (&args.url, found.base_url) {
+ (None, Some(remote_url)) => remote_url,
+ _ => base_url.clone(),
+ },
+ }
+ });
+ resolved.insert(dir.to_string(), target.clone());
+ Ok(target)
+ },
+ |row, target| {
+ if args.force {
+ return UploadClass::New;
+ }
+ let stamp = source_stamp(bundle, row.artifact_type, row_project(row), &row.session_id);
+ classify(
+ &manifest,
+ row.artifact_type,
+ &row.session_id,
+ &target.url(),
+ stamp,
+ )
+ },
+ )?;
+ let to_upload: usize = groups
+ .iter()
+ .map(|g| g.states.iter().filter(|s| **s == UploadClass::New).count())
+ .sum();
+
+ let home = crate::config::home_dir();
+ eprint!(
+ "{}",
+ render_bulk_summary(
+ &groups,
+ &rows,
+ args.project_under.as_deref(),
+ home.as_deref(),
+ )
+ );
+ if to_upload == 0 {
+ eprintln!("Nothing to upload; pass --force to upload everything again.");
+ return Ok(());
+ }
+ let phrase = upload_phrase(to_upload, &groups, &default_target);
+ if args.dry_run {
+ eprintln!("Would upload {phrase}");
+ return Ok(());
+ }
+ if !args.yes {
+ let answer = crate::cmd_pathbase::prompt_line(&format!("Upload {phrase}? [y/N] "))?;
+ if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
+ eprintln!("Aborted.");
+ std::process::exit(130);
+ }
+ }
+
+ // Ensure the pathstash repo exists once, not once per upload. A
+ // configured remote is expected to exist already.
+ if args.repo.is_none() {
+ crate::cmd_pathbase::repos_post(&base_url, &token, &username, "pathstash")?;
+ }
+
+ let mut uploaded: std::collections::BTreeMap = Default::default();
+ let mut failed = 0usize;
+ let total = to_upload;
+ let mut n = 0usize;
+ for group in &groups {
+ let target = group.configured.as_ref().unwrap_or(&default_target);
+ for (&i, state) in group.rows.iter().zip(&group.states) {
+ if *state != UploadClass::New {
+ continue;
+ }
+ n += 1;
+ let row = &rows[i];
+ let project = row_project(row);
+ // One line per session, completed in place: the prefix goes
+ // out before the (possibly slow) derive so progress is
+ // visible, the outcome finishes it.
+ eprint!(
+ "[{n}/{total}] {} {}",
+ row.artifact_type.name(),
+ row.session_id
+ );
+ let stamp = source_stamp(bundle, row.artifact_type, project, &row.session_id);
+ let result = load_session(row.artifact_type, project, &row.session_id, args.no_cache)
+ .and_then(|loaded| {
+ let doc = toolpath::v1::Graph::from_json(&loaded.body)
+ .map_err(|e| anyhow::anyhow!("Invalid toolpath document: {e}"))?;
+ let name = args
+ .name
+ .clone()
+ .unwrap_or_else(|| crate::cmd_export::derive_name(&doc));
+ let created = crate::cmd_pathbase::graphs_post(
+ &target.base_url,
+ &token,
+ &target.owner,
+ &target.repo,
+ Some(&name),
+ &loaded.body,
+ args.public,
+ )?;
+ Ok((loaded.origin, created))
+ });
+ match result {
+ Ok((origin, created)) => {
+ eprintln!(" ({}) → {}", origin.short(), created.url);
+ record_upload(
+ row.artifact_type,
+ &row.session_id,
+ project,
+ &target.url(),
+ &created,
+ stamp,
+ );
+ *uploaded.entry(target.clone()).or_default() += 1;
+ }
+ Err(e) => {
+ eprintln!(" failed: {e:#}");
+ failed += 1;
+ }
+ }
+ }
+ }
+
+ let ok = total - failed;
+ if failed > 0 {
+ eprintln!("Uploaded {ok} of {total} sessions ({failed} failed)");
+ } else {
+ eprintln!("Uploaded {ok} sessions");
+ }
+ let width = uploaded
+ .keys()
+ .map(|t| t.display().len())
+ .max()
+ .unwrap_or(0);
+ for (target, count) in &uploaded {
+ println!(
+ " {:4} {}",
+ target.display(),
+ target.url()
+ );
+ }
+ if failed > 0 {
+ anyhow::bail!("{failed} of {total} uploads failed");
+ }
+ Ok(())
}
/// The directory a derived session document belongs to: its single
@@ -1268,6 +1908,254 @@ mod tests {
);
}
+ fn bulk_row(t: ArtifactType, dir: Option<&str>, session: &str) -> ArtifactRow {
+ let (path, cwd) = if t.path_keyed() {
+ (dir.map(str::to_string), None)
+ } else {
+ (None, dir.map(str::to_string))
+ };
+ ArtifactRow {
+ artifact_type: t,
+ path,
+ cwd,
+ session_id: session.to_string(),
+ title: "t".to_string(),
+ last_activity: None,
+ message_count: Some(1),
+ matches_cwd: false,
+ }
+ }
+
+ fn target(owner: &str, repo: &str) -> BulkTarget {
+ BulkTarget {
+ owner: owner.to_string(),
+ repo: repo.to_string(),
+ base_url: "https://pb.test".to_string(),
+ }
+ }
+
+ #[test]
+ fn gather_artifacts_project_under_admits_subtree() {
+ let temp = TempDir::new().unwrap();
+ let claude = temp.path().join(".claude");
+ write_claude_session(&claude, "-work-foo", "s1", "a");
+ write_claude_session(&claude, "-work-foo-sub", "s2", "b");
+ write_claude_session(&claude, "-other", "s3", "c");
+ let bundle = claude_only_bundle(temp.path());
+ let scope = ProjectScope::Under(Path::new("/work"));
+ let mut rows = gather_artifacts(&bundle, Path::new("/x"), None, Some(&scope));
+ rows.sort_by(|a, b| a.session_id.cmp(&b.session_id));
+ let ids: Vec<&str> = rows.iter().map(|r| r.session_id.as_str()).collect();
+ assert_eq!(ids, ["s1", "s2"]);
+ }
+
+ #[test]
+ fn gather_artifacts_claude_rows_carry_recorded_cwd() {
+ let temp = TempDir::new().unwrap();
+ write_claude_session(&temp.path().join(".claude"), "-test-project", "s1", "a");
+ let bundle = claude_only_bundle(temp.path());
+ let rows = gather_artifacts(&bundle, Path::new("/x"), None, None);
+ assert_eq!(rows[0].cwd.as_deref(), Some("/test/project"));
+ }
+
+ #[test]
+ fn plan_bulk_groups_by_recorded_cwd_over_project_key() {
+ let mut a = bulk_row(ArtifactType::Claude, Some("/w/my/app"), "a");
+ a.cwd = Some("/w/my_app".to_string());
+ let mut b = bulk_row(ArtifactType::Pi, Some("/w/my/app"), "b");
+ b.cwd = Some("/w/my_app".to_string());
+ let groups = plan_bulk(
+ &[a, b],
+ &target("me", "pathstash"),
+ |_| Ok(None),
+ |_, _| UploadClass::New,
+ )
+ .unwrap();
+ assert_eq!(groups.len(), 1);
+ assert_eq!(groups[0].dir.as_deref(), Some("/w/my_app"));
+ }
+
+ #[test]
+ fn plan_bulk_groups_by_dir_and_sorts_by_count() {
+ let rows = vec![
+ bulk_row(ArtifactType::Codex, None, "n1"),
+ bulk_row(ArtifactType::Claude, Some("/w/bar"), "b1"),
+ bulk_row(ArtifactType::Claude, Some("/w/foo"), "f1"),
+ bulk_row(ArtifactType::Codex, Some("/w/foo"), "f2"),
+ bulk_row(ArtifactType::Claude, Some("/w/foo"), "f3"),
+ bulk_row(ArtifactType::Cursor, Some("/w/bar"), "b2"),
+ bulk_row(ArtifactType::Cursor, Some("/w/bar"), "b3"),
+ ];
+ let mut asked = Vec::new();
+ let mut classified = Vec::new();
+ let groups = plan_bulk(
+ &rows,
+ &target("me", "pathstash"),
+ |dir| {
+ asked.push(dir.to_string());
+ Ok((dir == "/w/foo").then(|| target("me", "foo")))
+ },
+ |row, t| {
+ classified.push((row.session_id.clone(), t.display()));
+ UploadClass::New
+ },
+ )
+ .unwrap();
+
+ let dirs: Vec> = groups.iter().map(|g| g.dir.as_deref()).collect();
+ assert_eq!(dirs, [Some("/w/bar"), Some("/w/foo"), None]);
+ assert_eq!(groups[0].rows.len(), 3);
+ assert_eq!(groups[1].configured, Some(target("me", "foo")));
+ assert_eq!(groups[0].configured, None);
+ assert_eq!(groups[2].configured, None);
+ assert_eq!(asked.len(), 2, "one resolve per directory, none for no-dir");
+ classified.sort();
+ assert_eq!(
+ classified,
+ vec![
+ ("b1".to_string(), "me/pathstash".to_string()),
+ ("b2".to_string(), "me/pathstash".to_string()),
+ ("b3".to_string(), "me/pathstash".to_string()),
+ ("f1".to_string(), "me/foo".to_string()),
+ ("f2".to_string(), "me/foo".to_string()),
+ ("f3".to_string(), "me/foo".to_string()),
+ ("n1".to_string(), "me/pathstash".to_string()),
+ ],
+ "each row is classified against its own destination"
+ );
+ }
+
+ #[test]
+ fn render_bulk_summary_layout() {
+ let rows = vec![
+ bulk_row(ArtifactType::Claude, Some("/home/me/work/foo"), "f1"),
+ bulk_row(ArtifactType::Codex, Some("/home/me/work/foo"), "f2"),
+ bulk_row(ArtifactType::Claude, Some("/home/me/work/bar"), "b1"),
+ bulk_row(ArtifactType::Claude, Some("/home/me/work"), "r1"),
+ bulk_row(ArtifactType::Cursor, None, "n1"),
+ ];
+ let groups = plan_bulk(
+ &rows,
+ &target("me", "pathstash"),
+ |dir| Ok((dir == "/home/me/work/foo").then(|| target("me", "foo"))),
+ |_, _| UploadClass::New,
+ )
+ .unwrap();
+ let out = render_bulk_summary(
+ &groups,
+ &rows,
+ Some(Path::new("/home/me/work")),
+ Some(Path::new("/home/me")),
+ );
+ assert_eq!(
+ out,
+ "Found 5 sessions under ~/work\n\
+ \x20 claude 3, codex 1, cursor 1\n\
+ \n\
+ \x20 foo 2 → me/foo\n\
+ \x20 . 1\n\
+ \x20 bar 1\n\
+ \x20 (no project) 1\n\
+ \n"
+ );
+ assert_eq!(
+ upload_phrase(5, &groups, &target("me", "pathstash")),
+ "5 sessions to me/pathstash (or the noted remote)"
+ );
+ }
+
+ #[test]
+ fn render_bulk_summary_without_project_under_uses_home_relative() {
+ let rows = vec![bulk_row(ArtifactType::Claude, Some("/home/me/p"), "s")];
+ let groups = plan_bulk(
+ &rows,
+ &target("me", "pathstash"),
+ |_| Ok(None),
+ |_, _| UploadClass::New,
+ )
+ .unwrap();
+ let out = render_bulk_summary(&groups, &rows, None, Some(Path::new("/home/me")));
+ assert_eq!(out, "Found 1 sessions\n claude 1\n\n ~/p 1\n\n");
+ assert_eq!(
+ upload_phrase(1, &groups, &target("me", "pathstash")),
+ "1 sessions to me/pathstash"
+ );
+ }
+
+ #[test]
+ fn render_bulk_summary_reports_upload_classes() {
+ let rows = vec![
+ bulk_row(ArtifactType::Claude, Some("/p"), "a"),
+ bulk_row(ArtifactType::Claude, Some("/p"), "b"),
+ bulk_row(ArtifactType::Codex, Some("/p"), "c"),
+ bulk_row(ArtifactType::Claude, Some("/p"), "d"),
+ bulk_row(ArtifactType::Claude, Some("/q"), "e"),
+ ];
+ let groups = plan_bulk(
+ &rows,
+ &target("me", "pathstash"),
+ |dir| Ok((dir == "/q").then(|| target("me", "q"))),
+ |row, _| match row.session_id.as_str() {
+ "a" | "b" | "e" => UploadClass::Uploaded("u".into()),
+ "c" => UploadClass::Changed("u".into()),
+ _ => UploadClass::New,
+ },
+ )
+ .unwrap();
+ let out = render_bulk_summary(&groups, &rows, None, None);
+ assert_eq!(
+ out,
+ "Found 5 sessions, 1 to upload\n\
+ \x20 claude 4, codex 1\n\
+ \n\
+ \x20 /p 1 (2 already uploaded, 1 changed since upload)\n\
+ \x20 /q 0 (1 already uploaded) → me/q\n\
+ \n"
+ );
+ }
+
+ #[test]
+ fn classify_reads_manifest_uploads() {
+ use crate::sync::{Manifest, SyncRecord, UploadRecord};
+ let stamp = (Some("2026-01-01T00:00:00Z".parse().unwrap()), Some(10u64));
+ let upload = UploadRecord {
+ graph_id: "g1".into(),
+ url: "https://pb.test/u/me/foo/graphs/g1".into(),
+ modified: stamp.0,
+ size: stamp.1,
+ uploaded_at: "2026-01-02T00:00:00Z".parse().unwrap(),
+ };
+ let mut manifest = Manifest::default();
+ manifest.entry("claude".into()).or_default().insert(
+ "s1".into(),
+ SyncRecord {
+ path: None,
+ cache_id: None,
+ modified: None,
+ size: None,
+ synced_at: "2026-01-02T00:00:00Z".parse().unwrap(),
+ uploads: vec![upload.clone()],
+ },
+ );
+ let c = |repo_url: &str, st| classify(&manifest, ArtifactType::Claude, "s1", repo_url, st);
+ let foo = repo_url("https://pb.test/", "me/foo");
+ assert_eq!(foo, "https://pb.test/u/me/foo");
+ assert_eq!(c(&foo, stamp), UploadClass::Uploaded(upload.url.clone()));
+ assert_eq!(
+ c(&foo, (stamp.0, Some(11))),
+ UploadClass::Changed(upload.url.clone())
+ );
+ assert_eq!(
+ c(&foo, (None, None)),
+ UploadClass::Changed(upload.url.clone())
+ );
+ assert_eq!(c("https://pb.test/u/me/bar", stamp), UploadClass::New);
+ assert_eq!(
+ classify(&manifest, ArtifactType::Codex, "s1", &foo, stamp),
+ UploadClass::New
+ );
+ }
+
#[test]
fn parse_picker_row_roundtrips_keyed() {
let row = ArtifactRow {
@@ -1408,6 +2296,11 @@ mod tests {
session: None,
project: None,
no_cache: false,
+ all: false,
+ project_under: None,
+ dry_run: false,
+ yes: false,
+ force: false,
}
}
diff --git a/crates/path-cli/src/sync/engine.rs b/crates/path-cli/src/sync/engine.rs
index 29e2a76c..f7676d57 100644
--- a/crates/path-cli/src/sync/engine.rs
+++ b/crates/path-cli/src/sync/engine.rs
@@ -44,6 +44,110 @@ pub(crate) struct SyncRecord {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) size: Option,
pub(crate) synced_at: DateTime,
+ /// Pathbase uploads of this artifact, one per server+repo. Only
+ /// authed uploads are recorded — anonymous ones have no repo to
+ /// key on and cannot be listed later.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub(crate) uploads: Vec,
+}
+
+/// One upload of an artifact to a Pathbase repo, with the source
+/// fingerprint at upload time so a later run can tell "unchanged
+/// since" from "changed since". The destination is not stored
+/// separately: the graph URL the server returned is
+/// `/u///graphs/`, so it names the repo.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub(crate) struct UploadRecord {
+ pub(crate) graph_id: String,
+ pub(crate) url: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) modified: Option>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) size: Option,
+ pub(crate) uploaded_at: DateTime,
+}
+
+impl UploadRecord {
+ /// Whether this upload landed in the repo at `repo_url`
+ /// (`/u//`, built from the base URL and the
+ /// repo the upload targets): the graph URL must be
+ /// `/graphs/…`. A plain string match — a base URL that
+ /// differs from what the server echoes (scheme, host case) just
+ /// means one extra upload.
+ fn in_repo(&self, repo_url: &str) -> bool {
+ self.url
+ .starts_with(&format!("{}/graphs/", repo_url.trim_end_matches('/')))
+ }
+}
+
+/// What the manifest knows about an artifact relative to one upload
+/// destination.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub(crate) enum UploadState<'a> {
+ /// No upload recorded for this destination.
+ New,
+ /// Uploaded, and the source fingerprint still matches.
+ Uploaded(&'a UploadRecord),
+ /// Uploaded, but the source has changed since (or freshness is
+ /// unknowable — no stamp on either side).
+ Changed(&'a UploadRecord),
+}
+
+/// The artifact's upload state for the repo at `repo_url`
+/// (`/u//`), judged against the current source stamp. Only a real, matching stamp can vouch for
+/// "unchanged"; a `None` stamp on either side reads as changed.
+pub(crate) fn upload_state<'a>(
+ manifest: &'a Manifest,
+ artifact_type: ArtifactType,
+ id: &str,
+ repo_url: &str,
+ current: sources::Stamp,
+) -> UploadState<'a> {
+ let Some(rec) = manifest
+ .get(artifact_type.name())
+ .and_then(|records| records.get(id))
+ .and_then(|rec| rec.uploads.iter().find(|u| u.in_repo(repo_url)))
+ else {
+ return UploadState::New;
+ };
+ let (modified, size) = current;
+ if (rec.modified.is_some() || rec.size.is_some())
+ && rec.modified == modified
+ && rec.size == size
+ {
+ UploadState::Uploaded(rec)
+ } else {
+ UploadState::Changed(rec)
+ }
+}
+
+/// Record a successful upload to the repo at `repo_url`. Replaces any
+/// prior record in that repo. Creates a known-but-uncached record when the artifact
+/// has none yet (e.g. a `--no-cache` share).
+pub(crate) fn record_upload(
+ config_dir: &Path,
+ artifact_type: ArtifactType,
+ id: &str,
+ path: Option<&str>,
+ repo_url: &str,
+ upload: UploadRecord,
+) -> Result<()> {
+ update_manifest(config_dir, |manifest| {
+ let rec = manifest
+ .entry(artifact_type.name().to_string())
+ .or_default()
+ .entry(id.to_string())
+ .or_insert_with(|| SyncRecord {
+ path: path.map(str::to_string),
+ cache_id: None,
+ modified: None,
+ size: None,
+ synced_at: Utc::now(),
+ uploads: Vec::new(),
+ });
+ rec.uploads.retain(|u| !u.in_repo(repo_url));
+ rec.uploads.push(upload);
+ })
}
/// The sync manifest: artifact type (`"claude"`, `"codex"`, …) →
@@ -153,6 +257,17 @@ fn newest_first(artifacts: &[ArtifactRef]) -> Vec<&ArtifactRef> {
order
}
+/// Replace `id`'s record, carrying over its upload history — sync and
+/// import rewrite the source fingerprint, never what was uploaded.
+fn put_record(records: &mut BTreeMap, id: String, mut rec: SyncRecord) {
+ if rec.uploads.is_empty()
+ && let Some(prev) = records.get_mut(&id)
+ {
+ rec.uploads = std::mem::take(&mut prev.uploads);
+ }
+ records.insert(id, rec);
+}
+
/// Merge staged records into the manifest under the lock and clear
/// the stage.
fn flush_writes(
@@ -165,10 +280,10 @@ fn flush_writes(
let batch = std::mem::take(pending);
update_manifest(config_dir, move |manifest| {
for (name, records) in batch {
- manifest
- .entry(name.to_string())
- .or_default()
- .extend(records);
+ let target = manifest.entry(name.to_string()).or_default();
+ for (id, rec) in records {
+ put_record(target, id, rec);
+ }
}
})
}
@@ -241,6 +356,7 @@ fn sync_artifacts(
modified: artifact.modified,
size: artifact.size,
synced_at: Utc::now(),
+ uploads: Vec::new(),
},
);
unflushed += 1;
@@ -276,6 +392,7 @@ fn sync_artifacts(
modified: artifact.modified,
size: artifact.size,
synced_at: Utc::now(),
+ uploads: Vec::new(),
},
);
unflushed += 1;
@@ -310,19 +427,20 @@ pub(crate) fn record_artifact(
) -> Result<()> {
let config_dir = config.config_dir()?;
update_manifest(&config_dir, |manifest| {
- manifest
- .entry(artifact.artifact_type.name().to_string())
- .or_default()
- .insert(
- artifact.id.clone(),
- SyncRecord {
- path: artifact.path.clone(),
- cache_id: Some(cache_id.to_string()),
- modified: artifact.modified,
- size: artifact.size,
- synced_at: Utc::now(),
- },
- );
+ put_record(
+ manifest
+ .entry(artifact.artifact_type.name().to_string())
+ .or_default(),
+ artifact.id.clone(),
+ SyncRecord {
+ path: artifact.path.clone(),
+ cache_id: Some(cache_id.to_string()),
+ modified: artifact.modified,
+ size: artifact.size,
+ synced_at: Utc::now(),
+ uploads: Vec::new(),
+ },
+ );
})
}
@@ -497,6 +615,129 @@ mod tests {
result
}
+ fn upload(server: &str, repo: &str, graph_id: &str) -> UploadRecord {
+ UploadRecord {
+ graph_id: graph_id.to_string(),
+ url: format!("{server}/u/{repo}/graphs/{graph_id}"),
+ modified: Some("2026-01-01T00:00:00Z".parse().unwrap()),
+ size: Some(10),
+ uploaded_at: "2026-01-02T00:00:00Z".parse().unwrap(),
+ }
+ }
+
+ #[test]
+ fn record_upload_creates_and_replaces_per_destination() {
+ with_cfg(|_, cfg| {
+ let put = |path: Option<&str>, repo_url: &str, u: UploadRecord| {
+ record_upload(cfg, ArtifactType::Claude, "s1", path, repo_url, u).unwrap()
+ };
+ put(
+ Some("/p"),
+ "https://a/u/me/x",
+ upload("https://a", "me/x", "g1"),
+ );
+ put(None, "https://a/u/me/y", upload("https://a", "me/y", "g2"));
+ // Trailing slash on the repo URL: still replaces the me/x record.
+ put(None, "https://a/u/me/x/", upload("https://a", "me/x", "g3"));
+ let m = load_manifest(cfg).unwrap();
+ let rec = &m["claude"]["s1"];
+ assert_eq!(rec.path.as_deref(), Some("/p"));
+ assert_eq!(rec.cache_id, None, "known but not materialized");
+ let ids: Vec<&str> = rec.uploads.iter().map(|u| u.graph_id.as_str()).collect();
+ assert_eq!(ids, ["g2", "g3"]);
+ });
+ }
+
+ #[test]
+ fn sync_and_import_writes_keep_upload_history() {
+ with_cfg(|_, cfg| {
+ record_upload(
+ cfg,
+ ArtifactType::Claude,
+ "s1",
+ None,
+ "https://a/u/me/x",
+ upload("https://a", "me/x", "g1"),
+ )
+ .unwrap();
+ let artifact = ArtifactRef {
+ artifact_type: ArtifactType::Claude,
+ id: "s1".into(),
+ path: Some("/p".into()),
+ modified: Some("2026-03-01T00:00:00Z".parse().unwrap()),
+ size: Some(99),
+ };
+ let config = Config::load().unwrap();
+ record_artifact(&config, &artifact, "claude-s1").unwrap();
+ let m = load_manifest(cfg).unwrap();
+ let rec = &m["claude"]["s1"];
+ assert_eq!(rec.cache_id.as_deref(), Some("claude-s1"));
+ assert_eq!(rec.size, Some(99));
+ assert_eq!(
+ rec.uploads.len(),
+ 1,
+ "record_artifact must not drop uploads"
+ );
+
+ let mut pending = BTreeMap::new();
+ pending
+ .entry("claude")
+ .or_insert_with(BTreeMap::new)
+ .insert(
+ "s1".to_string(),
+ SyncRecord {
+ path: Some("/p".into()),
+ cache_id: Some("claude-s1".into()),
+ modified: None,
+ size: Some(100),
+ synced_at: Utc::now(),
+ uploads: Vec::new(),
+ },
+ );
+ flush_writes(cfg, &mut pending).unwrap();
+ let m = load_manifest(cfg).unwrap();
+ let rec = &m["claude"]["s1"];
+ assert_eq!(rec.size, Some(100));
+ assert_eq!(rec.uploads.len(), 1, "flush_writes must not drop uploads");
+ });
+ }
+
+ #[test]
+ fn upload_state_judges_by_stamp_and_destination() {
+ let mut m = Manifest::default();
+ m.entry("claude".into()).or_default().insert(
+ "s1".into(),
+ SyncRecord {
+ path: None,
+ cache_id: None,
+ modified: None,
+ size: None,
+ synced_at: Utc::now(),
+ uploads: vec![upload("https://a", "me/x", "g1")],
+ },
+ );
+ let same = (Some("2026-01-01T00:00:00Z".parse().unwrap()), Some(10));
+ let st = |id: &str, repo_url: &str, stamp| {
+ upload_state(&m, ArtifactType::Claude, id, repo_url, stamp)
+ };
+ assert!(matches!(
+ st("s1", "https://a/u/me/x", same),
+ UploadState::Uploaded(u) if u.graph_id == "g1"
+ ));
+ assert!(matches!(
+ st("s1", "https://a/u/me/x", (same.0, Some(11))),
+ UploadState::Changed(_)
+ ));
+ assert!(matches!(
+ st("s1", "https://a/u/me/x", (None, None)),
+ UploadState::Changed(_)
+ ));
+ assert_eq!(st("s1", "https://b/u/me/x", same), UploadState::New);
+ assert_eq!(st("s1", "https://a/u/me/xy", same), UploadState::New);
+ assert_eq!(st("s1", "https://a/u/me", same), UploadState::New);
+ assert_eq!(st("s2", "https://a/u/me/x", same), UploadState::New);
+ }
+
fn write_claude_session(home: &Path, project_slug: &str, session: &str, prompt: &str) {
let project_dir = home.join(".claude/projects").join(project_slug);
std::fs::create_dir_all(&project_dir).unwrap();
@@ -552,6 +793,7 @@ mod tests {
modified: Some("2024-01-02T00:00:01.123456789Z".parse().unwrap()),
size: Some(4096),
synced_at: "2026-07-09T00:00:00Z".parse().unwrap(),
+ uploads: Vec::new(),
},
);
save_manifest(config_dir, &manifest).unwrap();
diff --git a/crates/path-cli/src/sync/sources.rs b/crates/path-cli/src/sync/sources.rs
index d310249c..15b012db 100644
--- a/crates/path-cli/src/sync/sources.rs
+++ b/crates/path-cli/src/sync/sources.rs
@@ -93,6 +93,18 @@ fn canonicalize_or_self(p: &Path) -> PathBuf {
/// Subtree check for a real filesystem path. Canonicalizes both
/// sides, but also accepts the raw parent so a not-yet-resolvable
/// constraint (or an unresolvable dir) still matches literally.
+/// Whether `dir` (a project directory or recorded cwd as the provider
+/// reports it) lies under `project_under`, using the same per-provider
+/// comparison sync uses — slug space for claude and pi, whose on-disk
+/// names are lossy.
+pub(crate) fn project_in_scope(t: ArtifactType, dir: &str, project_under: &Path) -> bool {
+ match t {
+ ArtifactType::Claude => claude_project_in_scope(dir, project_under),
+ ArtifactType::Pi => pi_project_in_scope(dir, project_under),
+ _ => dir_in_scope(dir, project_under),
+ }
+}
+
fn dir_in_scope(dir: &str, project_under: &Path) -> bool {
let d = canonicalize_or_self(Path::new(dir));
d.starts_with(canonicalize_or_self(project_under)) || d.starts_with(project_under)
diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml
index a0aaf7ab..e49e3345 100644
--- a/crates/toolpath-claude/Cargo.toml
+++ b/crates/toolpath-claude/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "toolpath-claude"
-version = "0.13.0"
+version = "0.13.1"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
diff --git a/crates/toolpath-claude/src/lib.rs b/crates/toolpath-claude/src/lib.rs
index a3ae20d9..baeaffbf 100644
--- a/crates/toolpath-claude/src/lib.rs
+++ b/crates/toolpath-claude/src/lib.rs
@@ -186,6 +186,7 @@ impl ClaudeConvo {
let mut last_activity = None;
let mut file_path = std::path::PathBuf::new();
let mut first_user_message: Option = None;
+ let mut cwd: Option = None;
for (i, segment_id) in chain.iter().enumerate() {
let meta = self
@@ -206,6 +207,9 @@ impl ClaudeConvo {
if first_user_message.is_none() && meta.first_user_message.is_some() {
first_user_message = meta.first_user_message;
}
+ if cwd.is_none() {
+ cwd = meta.cwd;
+ }
}
Ok(ConversationMetadata {
@@ -216,6 +220,7 @@ impl ClaudeConvo {
started_at,
last_activity,
first_user_message,
+ cwd,
})
}
diff --git a/crates/toolpath-claude/src/reader.rs b/crates/toolpath-claude/src/reader.rs
index 0fd75912..2a938f5c 100644
--- a/crates/toolpath-claude/src/reader.rs
+++ b/crates/toolpath-claude/src/reader.rs
@@ -88,6 +88,7 @@ impl ConversationReader {
let mut started_at = None;
let mut last_activity = None;
let mut first_user_message: Option = None;
+ let mut cwd: Option = None;
for line in reader.lines() {
let line = line?;
@@ -101,6 +102,9 @@ impl ConversationReader {
if entry.message.is_some() {
message_count += 1;
}
+ if cwd.is_none() {
+ cwd = entry.cwd.clone().filter(|c| !c.is_empty());
+ }
// Skip tool-result-only user entries — `Message::text()`
// collapses them to "".
@@ -136,6 +140,7 @@ impl ConversationReader {
started_at,
last_activity,
first_user_message,
+ cwd,
})
}
@@ -328,6 +333,7 @@ mod tests {
assert_eq!(meta.message_count, 2);
assert_eq!(meta.session_id, "session-1");
assert_eq!(meta.project_path, "/Users/alex/Devel/myproject");
+ assert_eq!(meta.cwd.as_deref(), Some("/Users/ben/elsewhere"));
assert!(meta.started_at.is_some());
assert!(meta.last_activity.is_some());
}
diff --git a/crates/toolpath-claude/src/types.rs b/crates/toolpath-claude/src/types.rs
index 88c92155..72bc7c53 100644
--- a/crates/toolpath-claude/src/types.rs
+++ b/crates/toolpath-claude/src/types.rs
@@ -542,6 +542,11 @@ pub struct ConversationMetadata {
/// First non-empty user-prompt text. Used as a human-readable title.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_user_message: Option,
+ /// The working directory the session recorded (`cwd` on its first
+ /// entry that carries one). Unlike `project_path`, which is decoded
+ /// from the lossy directory slug, this is the real path.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option,
}
#[cfg(test)]
diff --git a/crates/toolpath-pi/Cargo.toml b/crates/toolpath-pi/Cargo.toml
index 72b1e236..89ab3c48 100644
--- a/crates/toolpath-pi/Cargo.toml
+++ b/crates/toolpath-pi/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "toolpath-pi"
-version = "0.6.1"
+version = "0.6.2"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
diff --git a/crates/toolpath-pi/src/io.rs b/crates/toolpath-pi/src/io.rs
index 16c619b0..bcec7e5c 100644
--- a/crates/toolpath-pi/src/io.rs
+++ b/crates/toolpath-pi/src/io.rs
@@ -69,9 +69,7 @@ pub fn list_sessions(resolver: &PathResolver, project: &str) -> Result Result {
+ let (id, timestamp, entry_count, cwd) = match parsed {
+ Some((id, ts, cwd)) => {
let ec = total_nonempty.saturating_sub(1);
- (id, ts, ec)
+ (id, ts, ec, cwd)
}
None => {
let id = fallback_id_from_stem(stem);
let ts = file_mtime_rfc3339(&path).unwrap_or_else(|| String::from(""));
- (id, ts, total_nonempty)
+ (id, ts, total_nonempty, None)
}
};
@@ -118,6 +116,7 @@ pub fn list_sessions(resolver: &PathResolver, project: &str) -> Result std::io::Result> {
}
/// If the line is a `{"type":"session", ...}` header, return `(id, timestamp)`.
-fn parse_header_id_and_timestamp(line: &str) -> Option<(String, String)> {
+/// (id, timestamp, cwd) from a session header line; `None` when the
+/// line is not a header or lacks id/timestamp.
+fn parse_header(line: &str) -> Option<(String, String, Option)> {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
let obj = v.as_object()?;
if obj.get("type").and_then(|t| t.as_str()) != Some("session") {
@@ -155,7 +156,12 @@ fn parse_header_id_and_timestamp(line: &str) -> Option<(String, String)> {
}
let id = obj.get("id")?.as_str()?.to_string();
let timestamp = obj.get("timestamp")?.as_str()?.to_string();
- Some((id, timestamp))
+ let cwd = obj
+ .get("cwd")
+ .and_then(|c| c.as_str())
+ .filter(|c| !c.is_empty())
+ .map(str::to_string);
+ Some((id, timestamp, cwd))
}
/// Strip a leading timestamp prefix from a filename stem.
@@ -324,6 +330,7 @@ mod tests {
assert_eq!(s.id, "s1");
assert_eq!(s.timestamp, "2026-04-16T10:00:00Z");
assert_eq!(s.entry_count, 2);
+ assert_eq!(s.cwd.as_deref(), Some("/p"));
assert!(s.file_path.to_string_lossy().ends_with(".jsonl"));
}
diff --git a/crates/toolpath-pi/src/reader.rs b/crates/toolpath-pi/src/reader.rs
index 3f6b0595..4bbf3a06 100644
--- a/crates/toolpath-pi/src/reader.rs
+++ b/crates/toolpath-pi/src/reader.rs
@@ -22,6 +22,10 @@ pub struct SessionMeta {
/// human-readable title for picker UIs (e.g. `path list pi --format tsv`
/// piped into fzf). `None` if the session has no parseable user message.
pub first_user_message: Option,
+ /// The working directory from the session header. Unlike the
+ /// project string decoded from the session directory name, which
+ /// turns every `-` into `/`, this is the real path.
+ pub cwd: Option,
}
/// In-memory representation of a Pi session file (plus optional parent).
diff --git a/site/_data/crates.json b/site/_data/crates.json
index 489fdd57..062ba579 100644
--- a/site/_data/crates.json
+++ b/site/_data/crates.json
@@ -33,7 +33,7 @@
},
{
"name": "toolpath-claude",
- "version": "0.13.0",
+ "version": "0.13.1",
"description": "Derive from Claude conversation logs",
"docs": "https://docs.rs/toolpath-claude",
"crate": "https://crates.io/crates/toolpath-claude",
@@ -73,7 +73,7 @@
},
{
"name": "toolpath-pi",
- "version": "0.6.1",
+ "version": "0.6.2",
"description": "Derive Toolpath provenance documents from Pi (pi.dev) agent session logs",
"docs": "https://docs.rs/toolpath-pi",
"crate": "https://crates.io/crates/toolpath-pi",
@@ -113,7 +113,7 @@
},
{
"name": "path-cli",
- "version": "0.18.0",
+ "version": "0.19.0",
"description": "Unified CLI (binary: path)",
"docs": "https://docs.rs/path-cli",
"crate": "https://crates.io/crates/path-cli",