Skip to content

feat(cli): path resume takes --remote and resumes a Claude session on an ssh host - #244

Draft
ecalifornica wants to merge 3 commits into
ecalifornica/resume-remote-simplefrom
ecalifornica/ssh-transport
Draft

feat(cli): path resume takes --remote and resumes a Claude session on an ssh host#244
ecalifornica wants to merge 3 commits into
ecalifornica/resume-remote-simplefrom
ecalifornica/ssh-transport

Conversation

@ecalifornica

@ecalifornica ecalifornica commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

path resume <input> --remote <dest> [-C <remote-dir>] [--dry-run] resumes a Claude session on an ssh host under tmux, and scripts/resume-remote.sh hands off to it after its bootstrap steps. The local host does all toolpath work. The remote runs claude and tmux only.

  • Three commits: the ssh transport module, the plan (read-only), and ship, launch, attach with the script handoff. The module has its first caller in the second commit.
  • The transport is an in-process SSH client (russh); no ssh binary is involved. The destination names the host and, before @, the user; the port is 22. The agent authenticates first, then the default identity files under ~/.ssh. ~/.ssh/known_hosts verifies the host key: a changed key is an error, and an unknown host is learned on first contact with a notice (accept-new). Every remote call has a wall-clock timeout, and a timeout error carries the stderr received so far.
  • RemoteCommand is the only way to build the string the exec request carries: an argv, or a constant sh script (&'static str) that reads its values as positional parameters, every word through shlex quoting. Two read-only probes (constant scripts) report the remote home, the claude path, tmux, the physical project directory, whether the tmux session for the derived ID is live, and whether the session file exists. Any other output, such as a login banner, is an error. The plan prints. --dry-run stops there.
  • The remote wins once it exists. A live tmux session is attached to as is. A present session file is launched with claude -r <id> and attached to. An absent file is shipped, launched, and attached to. Nothing overwrites remote turns. To reset, delete the file on the remote and re-run.
  • The ship projects the conversation in memory, renames it to the derived session ID (the one p export claude --derive-session-id produces), roots it at the remote project directory, and sends the JSONL over the channel's stdin. The remote writes a temporary name, checks the byte count, and renames it into place, so a present file is a complete file. The ship's timeout grows with the byte count.
  • The attach opens a PTY channel sized to this terminal, puts the terminal in raw mode, forwards keystrokes, output, and resizes, and exits with tmux's status, so the command needs a TTY on stdin unless --dry-run.
  • scripts/resume-remote.sh keeps build, session resolution, --create, --setup seeding, and the working-tree sync, then execs the command. Its one probe reads the remote home and whether the session file exists, which gates the sync.
  • Tests drive the command through a scripted fake transport. Quoting is tested against a real sh. A live test (--ignored, PATH_TEST_SSH_DEST=user@host) exercises the transport against a real host.
  • Dependencies: russh 0.63 (its crypto backend, aws-lc-rs, is already in the build through rustls), shlex, and crossterm (already in the build through the embedded picker).

path-cli 0.21.0; toolpath-cli 0.21.0 (lockstep bump of the shim).

Stacked on #243.

@ecalifornica ecalifornica self-assigned this Aug 26, 2026
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

🔍 Preview deployed: https://3d9b5ca1.toolpath.pages.dev

@ecalifornica
ecalifornica force-pushed the ecalifornica/ssh-transport branch from 516c045 to c7fd8c9 Compare August 26, 2026 19:03
`ssh.rs` runs commands on a remote host through an in-process SSH
client (`russh`). The destination names the host and, before `@`, the
user; the port is 22. The agent authenticates first, then the default
identity files under `~/.ssh`; an encrypted file is skipped with a
hint. `~/.ssh/known_hosts` verifies the host key: a changed key is an
error, and an unknown host is learned on first contact with a notice
(accept-new).

`Transport::run` opens one exec channel per call: one task feeds
stdin while another collects stdout, stderr, and the exit status, all
under the caller's wall-clock timeout; on expiry the connection drops
and the error carries the stderr received so far. Connect, handshake,
and authentication share a 30s bound. Keepalives are on every
session.

`RemoteCommand` is the only way to build the string the exec request
carries. `new` is an argv. `script` is a constant `sh` script that
reads its values as positional parameters; the text is `&'static
str`, so a value cannot be interpolated into shell text. Every word
is quoted through shlex. `Destination` restricts the destination to
`[A-Za-z0-9@._-]` with an alphanumeric first character.

`parse_facts` reads `TP_<NAME>=<value>` lines and rejects any other
shape, so a login banner cannot become a path component. `FakeSsh`
scripts replies and records rendered commands for tests. Quoting and
scripts are tested against a real `sh`; the live test (`--ignored`,
`PATH_TEST_SSH_DEST`) exercises run, stdin, stderr, the exit status,
and the timeout against a real host.

The module has no caller yet, so `lib.rs` allows dead code on it. No
version bump: the dependencies become user-visible with the command
that consumes the module.
@ecalifornica
ecalifornica force-pushed the ecalifornica/ssh-transport branch from c7fd8c9 to b17f788 Compare August 26, 2026 19:57
Robert Queenin and others added 2 commits August 26, 2026 16:01
… ssh host

`path resume <input> --remote <dest> [-C <dir>] [--dry-run]` plans a
resume on an ssh host without touching it. Claude only. Two read-only
ssh calls through the transport module gather the facts:

- Call 1: the remote home, the claude path (PATH, then ~/.local/bin,
  ~/.claude/local, ~/.npm-global/bin), tmux presence. The remote
  project directory is `-C` verbatim, else the local cwd with the
  local home swapped for the remote home.
- Call 2: the directory is physical (`pwd -P` returns it), the tmux
  session for the derived ID is live, the target session file exists.

The plan prints the facts and what a run does: a live tmux session is
attached to, a present session file is launched, an absent file is
shipped. `--dry-run` exits after the plan. Without it the command
errors: ship, launch, and attach are not implemented yet.

The session ID comes from `session_id_from_document_hash`, shared with
`p export claude --derive-session-id`. `resolve_input` returns a
`ResolvedInput` carrying the document text next to the parsed graph,
so the ID hashes the input bytes rather than a type round-trip. The
tmux session name is `path-<first 8 of the ID>`.

The probe scripts print `TP_<NAME>=<value>` lines and the parser
rejects any other output, so a login banner cannot become a path
component. A captured path must be a non-empty single line starting
with `/`. A non-physical directory errors with the physical path as a
`-C` hint. Tests drive the command through the scripted fake
transport; nothing is public for tests.

path-cli 0.21.0; toolpath-cli 0.21.0 (lockstep bump of the shim).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mws8xxV7vuJs6rQSKG69ye
After the plan, the command does what the remote state asks. An
absent session file is shipped: the conversation is projected in
memory, renamed to the derived session ID, rooted at the remote
project directory, and sent over ssh stdin (0600 via umask 077). The
remote writes a temporary name, checks the byte count against the
local JSONL, and renames it into place, so a present file is a
complete file. The ship's timeout grows with the byte count. A fresh
`claude -r <id>` starts in a detached tmux session named
`path-<first 8 of the ID>` unless one is live. The command then
attaches this terminal to `tmux attach-session -d -t =<name>` over a
PTY channel and exits with tmux's status.

The remote wins once it exists: a live tmux session is attached to
as is, a present session file is launched as is, and nothing
overwrites remote turns. A failed ship or launch stops the run with
the remote stderr. Without `--dry-run` the command requires a TTY on
stdin, because the attach is interactive.

The rewrite helpers move out of `run_claude` into `rename_session`
and `reroot_cwd`, shared with `p export claude`; the export behavior
is unchanged. The launch script embeds the claude path in escaped
double quotes for the shell tmux starts; a probed path carrying a
quote, `$`, backtick, or backslash errors before any write.

`Transport::attach` opens a PTY channel sized to this terminal, puts
the terminal in raw mode for the duration, forwards keystrokes to the
channel, channel output to stdout, and terminal resizes as window
changes, and returns the command's exit status. The terminal reader
is a blocking task that ends only on the next keypress, so the
runtime shuts down in the background on drop instead of waiting for
it. `FakeSsh` records attach calls.

`scripts/resume-remote.sh` hands off to the command after build,
session resolution, optional VM creation, `--setup` seeding, and the
working-tree sync. Its probe shrinks to one call for the remote home
and the session file's existence, which gates the sync. The plan,
ship, launch, and attach blocks are gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mws8xxV7vuJs6rQSKG69ye
@ecalifornica ecalifornica changed the title feat(cli): path-cli gains an ssh transport module feat(cli): path resume takes --remote and resumes a Claude session on an ssh host Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant