Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
c7caa90
fix(claude): drop empty streaming-seed assistant lines on projection
bdelanghe Jul 23, 2026
3ca1a6d
feat(resume): add `path resume --remote <ssh-url>` (v0)
Jul 23, 2026
3570767
feat(resume): preflight remote `path --version` before remote dispatch
bdelanghe Jul 23, 2026
770bd9d
feat(resume): remote resume v1 — host resolves + stages over SSH
bdelanghe Jul 23, 2026
79c0f0b
test(resume): pin XDG_DATA_HOME in ScopedHome to sandbox opencode
bdelanghe Jul 23, 2026
ad68075
feat(resume): remote resume v2 — pipe JSON into remote `path p incept`
bdelanghe Jul 23, 2026
e9a186b
fix(resume): mkdir -p the pinned --cwd before remote incept
bdelanghe Jul 23, 2026
b42d2f3
feat(resume): remote resume v3 — ship projected files, no remote `path`
bdelanghe Jul 23, 2026
5080dfa
fix(resume): mkdir -p the pinned --cwd before the remote launch cd
bdelanghe Jul 23, 2026
85458fe
refactor(resume): typed SFTP transport for remote resume — no shell s…
bdelanghe Jul 23, 2026
fee15aa
feat(resume): ssh_config-aware auth + SCP/exec fallback for the SFTP …
bdelanghe Jul 23, 2026
e62ccac
feat(resume): --tmux for detachable remote sessions + derive launch f…
bdelanghe Jul 23, 2026
27a1cd3
chore(release): bump path-cli to 0.16.0 — remote resume over SSH
bdelanghe Jul 23, 2026
1499558
fix(resume): address cloud review — auth, path safety, chain integrity
Jul 24, 2026
92c7e0f
fix(resume): reject non-path remote-home output early (exe.dev banner)
Jul 24, 2026
30cc713
Merge remote-tracking branch 'origin/main' into bdelanghe/140-remote-…
Jul 24, 2026
cdcf235
chore(release): bump toolpath-claude to 0.12.2 (0.12.1 taken by main'…
Jul 24, 2026
05d7507
refactor(convo): extract Turn::is_content_empty() (mirrors #141 review)
Jul 24, 2026
e1d48e6
docs(resume): implementation plan for remote persistence picker
bdelanghe Jul 24, 2026
7334fad
feat(resume): PersistBackend enum for remote persistence backends
bdelanghe Jul 24, 2026
5d96eaa
feat(resume): persist_plan + PersistPlan replacing remote_launch_comm…
bdelanghe Jul 24, 2026
ea36699
feat(resume): zellij layout-wrap persistence backend
bdelanghe Jul 24, 2026
df41b6c
feat(resume): shpool attach-only persistence backend + post-note
bdelanghe Jul 24, 2026
1c3ca6e
feat(resume): Transport enum + launch_invocation seam (ssh; mosh/et r…
bdelanghe Jul 24, 2026
20ba351
feat(resume): ExecStrategy::remote_which probe (+ RecordingExec canne…
bdelanghe Jul 24, 2026
a2eb51d
feat(resume): --persist and --via flags (+ --tmux deprecation alias)
bdelanghe Jul 24, 2026
a036106
feat(resume): persist candidate assembly + preferred-backend selection
bdelanghe Jul 24, 2026
0250d0a
docs(resume): remote session-persistence & transport landscape reference
bdelanghe Jul 24, 2026
49ff8d2
feat(resume): wire persistence picker + transport through run_remote
bdelanghe Jul 24, 2026
21931f1
docs(resume): document persistence picker + --via; bump path-cli
bdelanghe Jul 24, 2026
54a7b5e
docs(resume): restore design spec + targets runbook dropped in concur…
bdelanghe Jul 24, 2026
f8a859d
fix(resume): probe before ship + non-fatal probe; validate --via earl…
bdelanghe Jul 24, 2026
0a7c9bf
test(resume): exhaustive PersistBackend::describe() coverage
Jul 24, 2026
18a848c
test(resume): cover run_remote persist auto-select, plain fallback, a…
Jul 25, 2026
1544201
fix(resume): canonicalize remote --cwd (symlink-safe project dir)
Jul 25, 2026
8f5e160
feat(resume): implement --via mosh transport
Jul 25, 2026
0f4e41f
refactor(resume): rename --via → --transport; ssh only, nothing reserved
Jul 25, 2026
ccb0ebd
fix(resume): ssh-CLI ship fallback for proxied/bastion hosts
Jul 25, 2026
5378f5b
refactor(resume): cut persist backends to tmux + dtach + plain
Jul 25, 2026
4a1c806
docs(resume): drop design-exploration prose; trim exe.dev runbook
Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,94 @@

All notable changes to the Toolpath workspace are documented here.

## Resume: remote session-persistence picker + transport seam — 2026-07-24

- **`path-cli`** (0.18.0): `path resume --remote` gains `--persist
<backend>`, generalizing the previous `--tmux`-only detach story into a
picker over three backends: `tmux`, `dtach`, and `plain` (none).
`tmux` wraps the launch in an attach-or-create named session; `dtach`
uses `dtach -A <socket> -r winch sh -c '…'` (`-r winch` makes Claude's
self-repainting TUI redraw on reattach); `plain` is a bare launch. The
remote is probed for installed backends; `--persist X` skips the
picker; a non-TTY invocation auto-picks the best available (tmux >
dtach > plain). `--tmux` is a **deprecated alias** for `--persist
tmux` — combining both is an error.
- **Why only two real backends.** The job is narrow — hold one
`claude -r` PTY across a disconnect — and Claude collapses the usual
multiplexer trade-offs: `claude -r` *is* the crash-recovery layer
(conversation state lives in Claude Code's session files, not the
terminal, so you re-run `-r` rather than reattach to a corpse), and
Claude's Ink TUI repaints itself (no server-side terminal model
needed). That deletes tmux's two distinguishing wins for this use,
leaving `tmux` (tested default, ubiquitous, introspectable) and
`dtach` (no daemon, socket-is-the-API, argv-exec so no quoting-class
bug). `abduco`/`zellij`/`shpool` were evaluated and cut — they added
daemons, KDL layouts, and nesting guards for zero gain here. Both
shipped backends are verified live end-to-end (tmux: persistence +
real `claude -r` resume; dtach: socket lifecycle + argv fidelity).
- New `--transport` flag names the carrier protocol for the
interactive launch stream. One value — `ssh` — implemented and
default; the flag exists as the extension seam. It selects *carriage
only*, not routing/reachability (`~/.ssh/config`) or credentials.
- Reachability over Tailscale, Cloudflare Tunnel, or a bastion hop
needs no new flag — it rides the existing `~/.ssh/config` `Host`
alias resolution already documented for `--remote`. **The ship now
honors it too:** libssh2 dials a raw socket and can't traverse
`ProxyJump`/`ProxyCommand`, so when the host declares a proxy (or a
direct dial fails), the probe/ship/realpath fall back to the `ssh`
CLI (`ssh host 'mkdir -p … && cat > …'`), which honors the full
config. Previously such a host launched fine but silently failed at
the ship step.
- **Symlink-safe `--cwd`**: the remote launch cwd is now canonicalized
over SFTP (`remote_realpath`) before keying the shipped project dir,
so a `--cwd` through a symlink (e.g. macOS `/tmp` → `/private/tmp`)
ships to the *physical* path Claude's `getcwd` reports — previously
the session shipped to a dir `claude -r` never scanned and silently
failed to resume. Found and fixed by a live end-to-end run.

## Resume: remote resume over SSH — 2026-07-23

- **`path-cli`** (0.17.0): `path resume` gains `--remote
ssh://[user@]host[:port]` — resume a session on a remote host. The host
resolves the Toolpath document AND projects the session fully in memory,
ships the finished harness file to the remote, and launches the harness
over an interactive `ssh -t`. The remote needs only sshd + the harness:
no `path` installed, no Pathbase access, no temp files.
- Transport is typed libssh2 calls (SFTP, with an SCP-protocol +
minimal-exec fallback for servers whose SFTP channel won't open) — no
composed remote shell strings, same ethos as `git2` over shelling out.
- Natively honors the `HostName`/`User`/`Port`/`IdentityFile` subset of
`~/.ssh/config` (Host blocks, `*`/`?`/`!` globs, first-value-wins, `~`
expansion); configured identities are matched against the SSH agent by
public-key blob, so key-pinned hosts (e.g. exe.dev, which identifies
the account *by* the key) authenticate as the right account.
- `--tmux` wraps the remote launch in `tmux new-session -A -s
path-<id> …` for detachable sessions: detach with `ctrl-b d`, re-run
the same resume to re-attach.
- `--harness` is required with `--remote` and currently must be
`claude`; `--cwd` keys the remote project dir and the launch `cd`
(both created if missing). Bounded connect/per-op timeouts; one cached
connection per target.
- New deps: `ssh2`, `base64`.

## Project: drop empty streaming-seed assistant lines — 2026-07-23

- **`toolpath-claude`** (0.12.2): the Claude projector no longer emits
content-empty assistant messages. Claude Code streams a message as several
JSONL lines — the first an empty "seed" (`text: ""`) superseded by the
real-text line, both sharing one `message.id`. Projecting that seed as
`content:[{"type":"text","text":""}]` produced an API-invalid message: on the
next turn of a *resumed* session Claude replays the whole transcript and
Anthropic rejects the empty text block with `400 … text content blocks must
be non-empty`, so the resumed session couldn't take a second turn. The
projector now skips any assistant turn with no text, thinking, tool-uses,
delegations, or file-mutations and re-links the following turn's `parentUuid`
to the dropped seed's parent, keeping the uuid chain intact. The group's
token total is re-expanded onto the surviving line as before.
- **`toolpath-convo`** (0.12.0): new `Turn::is_content_empty()` — the
"no renderable content of its own" predicate the seed-drop check calls
instead of an inline conjunction.

## One artifact-type layer and per-session imports — 2026-07-16

Groundwork for a cache that fills itself: one enum for artifact
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,6 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag
- Interactive session selection: `path p import <provider>` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show <provider> --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list <provider> --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic.
- Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option<String>` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface.
- `path share` is the one-shot equivalent of `path p import <harness> | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export <harness>`) — there is no egress stripping.
- `path resume <input>` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r <id>` / `gemini --resume <id>` / `codex resume <id>` / `opencode --session <id>` / `pi --session <id>`). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness.
- `path resume <input>` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r <id>` / `gemini --resume <id>` / `codex resume <id>` / `opencode --session <id>` / `pi --session <id>`). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. With `--remote ssh://[user@]host[:port]` the resume happens on a remote host instead: the host projects the session fully in memory and ships the finished JSONL over libssh2 (SFTP, SCP fallback) into the remote's Claude layout, then execs an interactive `ssh -t … claude -r <id>` — the remote needs only sshd + the harness (no `path`, no Pathbase). The transport honors the `HostName`/`User`/`Port`/`IdentityFile` subset of `~/.ssh/config` and matches identities against the agent by public-key blob. `--tmux` wraps the remote launch in `tmux new-session -A -s path-<id>` for detachable sessions (re-run the same resume to re-attach). `--remote` requires `--harness` and currently supports only `claude`. `--persist <backend>` generalizes `--tmux` (now a deprecated alias for `--persist tmux`) into a six-backend picker (`plain`/`tmux`/`abduco`/`dtach`/`zellij`/`shpool`) across three launch mechanisms — direct-wrap, zellij's layout-wrap, and shpool's attach-only (prints the attach command); the remote is probed for installed backends and a non-TTY invocation auto-picks the best available one. `--via ssh|mosh|et` selects the transport carrying the launch (`ssh` implemented; `mosh`/`et` reserved). Tailscale/Cloudflare Tunnel/bastion reachability needs no flag — point `--remote` at a `~/.ssh/config` `Host` alias.
- `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows.
- `ArtifactType` (`crates/path-cli/src/artifact.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources. It derives `clap::ValueEnum` and is used by `ArtifactRow.artifact_type` and `cmd_import`'s cache-id prefixes (`name()` is the `make_id` source string). The deliberately parallel `Harness` enum (`crates/path-cli/src/harness.rs`, alongside `HarnessBundle`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only.
20 changes: 17 additions & 3 deletions Cargo.lock

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

8 changes: 5 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ license = "Apache-2.0"

[workspace.dependencies]
toolpath = { version = "0.7.0", path = "crates/toolpath" }
toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" }
toolpath-convo = { version = "0.12.0", path = "crates/toolpath-convo" }
toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" }
toolpath-claude = { version = "0.12.1", path = "crates/toolpath-claude", default-features = false }
toolpath-claude = { version = "0.12.2", path = "crates/toolpath-claude", default-features = false }
toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false }
toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" }
toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" }
Expand All @@ -37,7 +37,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" }
toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" }
toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" }
toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" }
path-cli = { version = "0.16.0", path = "crates/path-cli" }
path-cli = { version = "0.18.0", path = "crates/path-cli" }
pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" }

reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] }
Expand All @@ -54,6 +54,8 @@ similar = "2"
tempfile = "3.15"
insta = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
ssh2 = "0.9"
base64 = "0.22"
uuid = { version = "1", features = ["serde"] }

[profile.wasm]
Expand Down
4 changes: 3 additions & 1 deletion crates/path-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "path-cli"
version = "0.16.0"
version = "0.18.0"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
Expand Down Expand Up @@ -56,6 +56,8 @@ git2 = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true }
rusqlite = { workspace = true }
ssh2 = { workspace = true }
base64 = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
# Embedded fuzzy picker — used when external `fzf` isn't on PATH.
# Disable default features to avoid pulling in skim's CLI deps
Expand Down
45 changes: 45 additions & 0 deletions crates/path-cli/src/cmd_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,47 @@ pub(crate) struct PathbaseUploadArgs {
// projected session id. They are called by `path resume`; the existing
// `run_<harness>` functions are untouched.

/// Project `path` into a Claude session entirely in memory, returning
/// `(session_id, jsonl)` without writing anything. The id is
/// deterministic — the projector takes it verbatim from the document's
/// conversation view — and the JSONL is byte-identical to what
/// `p export claude` would write. Used by `path resume --remote` to
/// ship a ready-to-resume session file to a host with no `path`
/// installed.
#[cfg(not(target_os = "emscripten"))]
pub(crate) fn claude_session_jsonl(path: &toolpath::v1::Path) -> Result<(String, String)> {
let conv = build_claude_conversation(path)?;
validate_session_id(&conv.session_id)?;
let jsonl = serialize_jsonl(&conv)?;
Ok((conv.session_id, jsonl))
}

/// Reject session ids that aren't safe as a single filesystem path
/// component. The id becomes `<dir>/<id>.jsonl` both locally and (over
/// SFTP) on a remote host, and `path resume` accepts third-party
/// Pathbase documents — so an id like `../../../.ssh/authorized_keys`
/// from a malicious doc would otherwise let the write escape the
/// session directory. Every real Claude session id is a UUID; this
/// allows the UUID charset plus a conservative margin.
#[cfg(not(target_os = "emscripten"))]
pub(crate) fn validate_session_id(id: &str) -> Result<()> {
if id.is_empty() {
anyhow::bail!("document projects to an empty Claude session id");
}
if id.len() > 128
|| id.starts_with('.')
|| !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
{
anyhow::bail!(
"refusing session id `{id}`: it isn't a safe filename component \
(expected UUID-like `[A-Za-z0-9_-]`, ≤128 chars, no leading dot)"
);
}
Ok(())
}

/// Project `path` into a Claude session under `project_dir` and return
/// the resulting session id.
#[cfg(not(target_os = "emscripten"))]
Expand All @@ -307,6 +348,7 @@ pub(crate) fn project_claude(
project_dir: &std::path::Path,
) -> Result<String> {
let conv = build_claude_conversation(path)?;
validate_session_id(&conv.session_id)?;
let jsonl = serialize_jsonl(&conv)?;
write_into_claude_project(&conv, &jsonl, project_dir)?;
Ok(conv.session_id)
Expand Down Expand Up @@ -693,6 +735,9 @@ fn write_into_claude_project(
jsonl: &str,
project_dir: &std::path::Path,
) -> Result<PathBuf> {
// Chokepoint for every local write — the id becomes a filename, so
// reject path-traversal ids here (covers `run_claude` too).
validate_session_id(&conv.session_id)?;
let project_dir = std::fs::canonicalize(project_dir)
.with_context(|| format!("resolve project path {}", project_dir.display()))?;
let project_path = project_dir.to_string_lossy();
Expand Down
Loading