Skip to content

feat(resume): remote persistence & transport picker (--persist / --via)#146

Draft
bdelanghe wants to merge 40 commits into
empathic:mainfrom
bdelanghe:bdelanghe/remote-persistence-picker
Draft

feat(resume): remote persistence & transport picker (--persist / --via)#146
bdelanghe wants to merge 40 commits into
empathic:mainfrom
bdelanghe:bdelanghe/remote-persistence-picker

Conversation

@bdelanghe

@bdelanghe bdelanghe commented Jul 24, 2026

Copy link
Copy Markdown

Follow-up to #145 (path resume --remote). Adds a persistence + transport picker so a remote-resumed session can survive SSH drops and (later) ride faster transports.

⚠️ Stacked on #145 — review that first. This branch descends from #145's remote-resume work, so until #145 merges the diff below includes those commits; once #145 lands it shrinks to just the persistence-picker changes.

What it adds

  • --persist <backend> — wrap the remote launch in a detachable session so it survives disconnects: tmux, zellij, abduco, dtach, shpool, or plain (no persistence). --tmux becomes a deprecated alias for --persist tmux.
  • --via <transport>ssh (default), with mosh and et seams reserved (et errors as not-yet-supported; validated early).
  • Availability-aware: ExecStrategy::remote_which probes the target over SSH for installed backends; persist_candidates/preferred_backend pick the best available so it degrades gracefully instead of failing at launch.
  • Per-backend launch plans (persist_plan): direct-wrap backends (tmux/zellij/abduco/dtach) build the full run command; shpool is attach-only with a post-note telling you to run the command yourself. Session names are sanitized.
  • Wired end-to-end through run_remote: probe → ship (SFTP) → persist-wrapped launch.

Still host does all the toolpath work; the target needs only sshd + the harness, plus whichever persistence/transport binary you ask for (not path).

Testing

cargo clippy -D warnings clean; 62 cmd_resume unit tests + 13 resume integration tests pass, including per-backend persist_plan, exhaustive PersistBackend::describe() coverage, resolve_persist_flag conflict rejection, the remote_which probe, and preferred_backend selection.

Versioning

path-cli 0.17.0 → 0.18.0 (new --persist/--via surface).

Docs

docs/agents/remote-resume-targets.md (verified exe.dev + Sprite runbook) and docs/superpowers/specs/2026-07-23-remote-persistence-picker-design.md (four-layer model, transport landscape) ride along.


Stack (review bottom-up): #141 empty-seed → #142 opencode test isolation → #145 path resume --remotethis (persistence & transport picker).

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

bdelanghe and others added 30 commits July 23, 2026 14:02
Claude Code streams an assistant message as several JSONL lines — the
first an empty "seed" (text: "") superseded by the real-text line, both
sharing one message.id. The projector wrote that seed back as
content:[{"type":"text","text":""}], 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 messages: text content blocks must be non-empty

so a 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 no attached tool-result
events), re-linking the following turn's parentUuid to the dropped seed's
parent via the existing parent_rewrites machinery so the uuid chain stays
intact. The group token total is re-expanded onto the surviving line as
before.

Verified end-to-end by re-exporting a real captured session: empty text
blocks 1 -> 0, chain re-linked past the seed. toolpath-claude 0.12.0 ->
0.12.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dispatch a resume to a remote host that has `path` installed, over SSH.
The host builds an `ssh` invocation and hands off; the remote does the
resolve, harness pick, projection, and exec.

Host/remote arg split:
- `--remote` is the only host-side arg (never forwarded — would recurse)
- every other arg (input, --harness, --cwd, --no-cache, --force, --url)
  is forwarded into the far-side `path resume`
- `--harness` is required with `--remote`: the remote picker has no TTY
  over a non-interactive SSH session, and the host can't pick either
  (v0 never resolves the doc locally), so fail fast on the host

Toward empathic#140.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`path resume --remote` now probes `ssh host 'path --version'` before the
interactive handoff and echoes `host path: <X> / remote path: <Y>`. This
confirms SSH is reachable and `path` is installed remotely; a failed probe
aborts with a clear error instead of dropping the user into a doomed
session.

Adds a `capture` method to the `ExecStrategy` seam (RealExec runs the
command and returns trimmed stdout; RecordingExec records probes and can
simulate a failing probe) so both the probe and the dispatch stay
testable without touching a real host.

Toward empathic#140.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1 replaces v0's forward-the-ref mechanism. Instead of the remote
resolving the document from Pathbase, the host now resolves it locally
and ships the hydrated JSON, so the remote needs only `path` + the target
harness installed — no Pathbase access.

Flow (run_remote):
1. resolve + validate the doc on the host (fail fast on bad/non-agent
   input, before touching SSH);
2. version preflight `ssh host 'path --version'` (echo + abort on failure,
   unchanged from the prior commit);
3. stage the JSON via `ssh host 'cat > /tmp/toolpath-resume-<uuid>.json'`
   piped on stdin — best-effort cleanup (execvp precludes a trailing rm);
4. execvp interactive `ssh -t host 'path resume <tempfile> --harness X
   [-C cwd]'` so the remote harness gets a real terminal.

Only --cwd forwards alongside the staged file; the resolution-only flags
(--no-cache/--force/--url) are moot once the doc is a local file on the
remote and are dropped.

ExecStrategy gains `pipe` (stdin staging) alongside `capture`; RecordingExec
records staged bytes so the whole probe→stage→dispatch sequence is testable
without a real host. Verified end-to-end against the real binary: resolves a
Pathbase URL, probes, and errors clearly when the remote is unreachable.

Toward empathic#140.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The opencode `PathResolver` prefers `$XDG_DATA_HOME` over `$HOME` when
locating `opencode.db`. `ScopedHome` only pinned `$HOME`, so on a machine
that sets `$XDG_DATA_HOME` (common on Linux; set on this dev box) the
opencode resume test escaped its sandbox: it seeded — and the projector
wrote into — the *real* user database. The seed's `CREATE TABLE` then hit
the already-populated real DB and failed with "table project already
exists" (green in CI where XDG is unset, red locally), and a fresh-DB run
would silently mutate the user's live opencode data.

Pin `$XDG_DATA_HOME` to `<tempdir>/.local/share` in ScopedHome (restored
on drop) so every harness — opencode included — stays sandboxed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the v1 temp-file staging (`ssh 'cat > /tmp/…'` + remote
`path resume <tempfile>`) with a single pipe into `path p incept
claude` on the remote, then launch `claude -r <id>` directly over an
interactive `ssh -t`.

The host computes the session id locally: it's a pure function of the
document (the projector takes it verbatim from the conversation view),
so host and remote projecting the same bytes agree — new
`cmd_export::claude_session_id` pins that. Claude-only for now;
other harnesses error clearly before any SSH.

`--cwd` does double duty as incept's --project dir and the launch's
cd target; absent, both default to the remote ssh cwd ($HOME).
Deletes remote_temp_path / remote_staged_command / harness_value.

Verified live against a local sshd sandbox: version echo, incept
writes the session under the remote ~/.claude/projects keyed on -C,
host and remote ids identical, correct `ssh -t … claude -r` launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resuming into a fresh directory is the normal case, but incept
canonicalizes its --project path and bailed when the dir didn't exist
on the remote. Prefix the hydrate command with `mkdir -p`.

Verified live: `--remote … -C /tmp/somewhere` with no such dir now
incepts and launches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The good version from the roadmap: the host resolves AND projects the
Claude session fully in memory (`cmd_export::claude_session_jsonl`),
probes the remote with plain `pwd` (reachability + default launch
dir), ships the finished JSONL straight into
`$HOME/.claude/projects/<sanitized-cwd>/<id>.jsonl` via the existing
ssh pipe, and launches `claude -r <id>` over an interactive `ssh -t`.

The remote needs only sshd and the harness — no `path`, no Pathbase,
no temp files. The project-dir key mirrors Claude Code's sanitization;
a unit test pins it against toolpath-claude's PathResolver so the two
can't drift. Replaces the v2 incept flow (claude_session_id →
claude_session_jsonl, remote_incept_command → remote_ship_command +
claude_project_dir_name).

Verified live against the sshd sandbox: probe echo, 150KB JSONL landed
under the remote's ~/.claude/projects keyed on -C, correct launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2's incept created the cwd as a side effect; v3's ship only creates
the Claude projects dir, so `cd '<cwd>'` failed on a fresh remote
directory. Create it in the launch command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…trings

Replace the composed remote shell commands (`pwd`, `mkdir -p … &&
cat > …`) with typed libssh2 SFTP calls, matching the repo's
git2-over-shelling-out ethos. ExecStrategy loses capture/pipe and
gains remote_home / remote_mkdirs / remote_write against a parsed
SshTarget (new parse_ssh_url; ssh_invocation_tty rebuilt on it).

The preflight is now an SFTP realpath of the remote home (first
remote touch → reachability/auth errors surface there, agent auth
with a clear ssh-add hint); the session file and a pinned --cwd are
created over SFTP. The only remaining remote shell string is the
interactive launch (`cd <cwd> && claude -r <id>`), which stays on
the real ssh binary because it needs the user's TTY and ssh config.

RecordingExec now records typed values (targets, dirs, path+bytes)
instead of shell strings. Adds ssh2 to the workspace.

Verified live against the sshd sandbox: agent auth, home resolved,
JSONL written and launch dir created over SFTP, correct launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…transport

Two real-world fixes from testing against an exe.dev VM:

1. libssh2 reads no ssh config, so agent auth authenticated with
   whatever key the agent listed first — and key-pinned hosts (exe.dev
   identifies the account BY the key) routed the session to an
   unregistered identity. The transport now natively parses the
   HostName/User/Port/IdentityFile subset of ~/.ssh/config (Host
   blocks, */?/! globs, first-value-wins, ~ expansion — no new deps
   beyond base64; ssh2-config was rejected for hard-depending on
   git2 0.20), matches configured identities against the agent by
   public-key blob, and only falls back to any-key agent auth.

2. Servers whose SFTP channel won't open get an SCP-protocol upload
   (libssh2 scp_send) plus a minimal exec channel for pwd/mkdir -p —
   still no external binaries. Connections are now bounded (connect
   and per-op timeouts) and cached per target instead of re-dialing,
   and exec exit codes are trusted only when there's no output (some
   minimal sshds report bogus statuses).

Verified live against a real exe.dev VM end-to-end: correct identity
picked, home resolved, session shipped, and  launched on
the VM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rom harness table

- `--tmux` (requires --remote) wraps the remote launch in
  `tmux new-session -A -s path-<id> '…'` so the session survives SSH
  disconnects and can be detached; `-A` re-attaches on a re-run.
- `remote_launch_command` now derives binary+argv from the same
  per-harness invocation table the local resume uses (name() +
  argv_for) instead of hardcoding `claude -r`, so the two can't
  drift when more harnesses go remote.
- New `shell_quote` quotes only when needed, keeping the echoed
  recipe human-readable.

Verified live against the exe.dev VM: correct tmux launch line issued.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the release checklist: crate version, workspace dep entry,
site/_data/crates.json, and a CHANGELOG section covering --remote
(host-side projection + libssh2 SFTP/SCP transport, ssh_config-aware
auth) and --tmux (detachable remote sessions). CLAUDE.md's resume
bullet now documents both flags. Site builds 11 pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six findings from the branch review:

- **auth misroute (normal):** authenticate() no longer falls back to
  any-key agent auth when IdentityFile(s) are configured or
  IdentitiesOnly is set — pinned-but-failed auth now errors instead of
  silently trying an arbitrary agent key (the exact misroute the config
  parsing exists to prevent). Also parse IdentitiesOnly and fix the
  `Key = value` (padded `=`) split that mangled IdentityFile paths.
- **path traversal (normal):** validate the projected Claude session id
  (`[A-Za-z0-9_-]`, ≤128, no leading dot) at the write chokepoints
  (claude_session_jsonl + write_into_claude_project) so a malicious
  third-party doc can't escape the session dir over SFTP or locally.
- **--cwd misroute (normal):** normalize the remote cwd (trailing
  slash / `.` / `..` / relative-to-home) to the absolute path the
  remote shell lands on, so the host's project-dir key matches what
  remote Claude derives from getcwd. Used for both the shipped-file key
  and the launch `cd`.
- **known_hosts (nit):** verify the server host key against
  ~/.ssh/known_hosts before shipping the transcript (the SFTP upload
  precedes the interactive ssh's own check); abort on mismatch,
  accept-new on first contact.
- **chain root (nit):** the empty-seed drop now records a rewrite even
  for a rootless seed (parent_rewrites value is Option), so a surviving
  child inherits None instead of dangling on the never-emitted seed id.
- **stale docstring (nit):** rewrite run_remote's v2 doc to v3 and drop
  the link to the renamed claude_session_id (fixes cargo doc warnings).

New unit tests for each; full workspace green, cargo doc -D warnings
clean, re-verified live against the exe.dev VM (incl. trailing-slash
--cwd now shipping to the correct project dir).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Some sshds authenticate a key at the transport layer but answer every
command with a notice instead of running it — e.g. exe.dev replies
`Please complete registration by running: ssh exe.dev` for a key not
yet bound to an account. Without a check that banner became a session
path component and failed deep in the file ship. validate_remote_home
requires a single-line absolute path, failing early with the offending
output and a hint about unregistered keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…resume

# Conflicts:
#	CHANGELOG.md
#	CLAUDE.md
#	Cargo.toml
…s session_chain)

Main bumped toolpath-claude to 0.12.1 for `ClaudeConvo::session_chain`;
the merge silently unified that with this branch's empty-seed fix,
which also claimed 0.12.1. Give the empty-seed fix its own 0.12.2 so
the two changes don't collide on one version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… review)

Same cleanup the empathic#141 reviewer asked for, applied here where the
projector's seed-drop check lives in its improved (root-of-chain) form:
promote the intrinsic predicate to Turn::is_content_empty() on
toolpath-convo and keep the tool-result-events check at the call site.
toolpath-convo 0.11.1 → 0.12.0 (additive), with a unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eserved)

Adds Transport enum with Ssh, Mosh, Et variants (derives Debug, Clone, Copy,
PartialEq, Eq, clap::ValueEnum) and launch_invocation function that:
- Ssh: delegates to existing ssh_invocation_tty(remote, remote_cmd, true)
- Mosh/Et: return anyhow::bail with "not yet supported" message

Includes test_launch_invocation_ssh_and_deferred_transports validating the
behavior. Task 9 will wire launch_invocation into the remote resume flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add pure functions for Task 9:
- persist_candidates: assembles DISPLAY_ORDER filtered to available + Plain
- preferred_backend: picks first of [Tmux, Zellij, Abduco, Dtach] or Plain

Test: persist_candidates_and_preference passes (available {dtach,zellij}
→ candidates [Zellij, Dtach, Plain], preferred Zellij; empty → [Plain],
preferred Plain; {tmux,shpool} → preferred Tmux).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captures the four-layer model (reachability/connection-survival/session-survival/
workspace) and the tool landscape behind --persist and --via: tmux/zellij/shpool/
abduco/dtach, mosh/et/ssh3, tailscale/wireguard/cloudflare/teleport, and the
libssh2 ProxyCommand ship limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bdelanghe and others added 10 commits July 24, 2026 12:03
…rent rebase

The design spec (2026-07-23-remote-persistence-picker-design.md) and the
exe.dev/Sprite targets runbook were committed earlier on this branch but
dropped when the branch was rebased by concurrent work. Restored from the
dangling commits (5a796a6, 42086c4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y; sanitize session name

Reorders run_remote so the persistence backend is resolved before the
session file ships, and makes a remote_which probe failure fall back
to Plain instead of aborting a resume that previously never needed an
exec channel. Validates --via up front so mosh/et bail before any
remote side effects. Sanitizes the multiplexer/session name (and the
dtach socket path) to [A-Za-z0-9_-] so a crafted session id can't
steer the zellij layout file write. Drops the misleading close_on_exit
mention from the zellij layout comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Assert describe() for every variant (Plain/Tmux/Abduco/Dtach/Zellij/
Shpool) and that each blurb leads with the backend's name; length-check
against DISPLAY_ORDER so a new backend can't be added without a test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd --via et gating

Three end-to-end run_remote tests that the per-method unit tests didn't
reach: (1) --via et fails before any remote touch (no probe/ship/launch);
(2) with no --persist, the remote_which probe auto-selects the preferred
backend (tmux over zellij) and wraps the launch; (3) with no backend
available, it ships + launches plain (claude -r, no wrapper).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live testing against a macOS sshd surfaced a real bug: with `--cwd
/tmp/x`, the session shipped to `~/.claude/projects/-tmp-x/` but
`claude -r` (which keys on the *physical* cwd) looked in
`-private-tmp-x/` (macOS `/tmp` → `/private/tmp`) and reported
"No conversation found" — a silent resume failure.

Fix: new `ExecStrategy::remote_realpath` (SFTP realpath, with a
`cd && pwd -P` fallback for SFTP-less hosts) canonicalizes the launch
cwd after creating it; the shipped project dir and the launch `cd` now
use that physical path. No --cwd → the already-canonical SFTP home.

Verified end-to-end: `path resume --remote --persist tmux -C /tmp/…`
now ships to the `-private-tmp-…` dir and `claude -r` resumes the
shipped session (replied to a print-mode prompt). Adds a RecordingExec
`with_realpath` seam + regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mosh was a reserved seam; wire it up. launch_invocation gains
mosh_invocation: mosh owns the TTY (no ssh -t), a custom SSH port rides
mosh's `--ssh` handshake override (its own -p is the UDP range), and the
persist-wrapped remote command runs via `sh -c` after `--`. Ship/probe
still go over SFTP; only the interactive launch changes client.

Verified live against a local mosh-server: the client connects over the
box's custom SSH port and runs the remote command (claude on PATH).
Unit test pins the argv shape; integration test asserts run_remote
launches the mosh client + ships. Docs/CHANGELOG updated (mosh no longer
reserved; et still is).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the transport decision: --via implied routing, but this flag
selects a *carrier protocol* for the interactive byte stream — nothing
else. Rename to --transport; the name polices its own boundary
(teleport/tailscale aren't transports).

- Transport enum reduces to a single `Ssh` variant (default). The
  flag stays as the extension seam — a future carrier is a variant +
  match arm — but nothing is reserved: drop the `mosh`/`et` values
  and their bespoke error strings. Unknown input now gets clap's
  generic `invalid value 'X' … [possible values: ssh]`.
- Reverts the just-added mosh transport (mosh_invocation + tests):
  transport is low-stakes for an agent TUI — `claude -r` reconstructs
  the session, and what must survive a disconnect is the *process*
  (tmux/dtach's job, --persist). Not worth a named reservation.
- launch_invocation loses its error arms (single transport); run_remote
  drops the now-moot --via pre-validation.

Field/flag/help/docs updated. Internal design-doc prose about the
excluded transports is left for a separate editorial pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
libssh2 dials a raw TCP socket and can't traverse ProxyJump/
ProxyCommand, so a host reachable only through a bastion/tunnel/mesh
launched fine (the ssh CLI honors config) but silently FAILED at the
probe/ship step. Close the gap:

- RemoteConn becomes an enum: Libssh2 (SFTP/SCP as before) | Cli.
- connect_remote returns Cli when ssh_config declares a ProxyJump/
  ProxyCommand for the host, OR when a direct libssh2 dial fails.
- Cli mode runs every file op through the ssh binary (pwd, mkdir -p,
  cat > dest, cd && pwd -P, command -v) — honors full ~/.ssh/config.
- ssh_config parser learns has_proxy.

Unit tests: proxy detection + ssh-CLI argv shape. CLI ship primitives
verified live over the real ssh binary against the sandbox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 (state lives in Claude Code's session files, not
the terminal), and Claude's Ink TUI repaints itself. That deletes
tmux's two distinguishing wins (remain-on-exit post-mortem, server-side
repaint), leaving tmux (tested default, ubiquitous, introspectable) and
dtach (no daemon, socket-is-the-API, argv-exec) as the only backends
that earn their place, plus plain.

- Drop Abduco/Zellij/Shpool from PersistBackend + bin/describe/
  DISPLAY_ORDER/preferred_backend; delete zellij_plan/shpool_plan.
- PersistPlan struct collapses to a plain String (no more extra_file/
  post_note plumbing in run_remote — those existed only for zellij/
  shpool).
- dtach upgraded to `-A <socket> -r winch sh -c '…'` (-r winch makes
  the self-repainting TUI redraw on reattach; was -z).

Both shipped backends verified live: tmux (persistence + real claude -r
resume) earlier; dtach now (socket lifecycle + argv fidelity — `-r`
and the session id arrive as separate args, so the quoting-class bug
can't exist). Tests + module docs updated; full path-cli suite + clippy
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the transport/backend decisions, the repo shouldn't carry the
non-goals/exploration prose (the excluded tools live in the decision
record + CHANGELOG + git history, not the tree):

- Remove remote-session-persistence-landscape.md (a survey of tools we
  evaluated and mostly rejected), the six-backend picker design spec,
  and its plan doc — all superseded by the shipped tmux+dtach+plain /
  ssh-only design documented in the code + CHANGELOG.
- Trim remote-resume-targets.md to the verified exe.dev runbook: fix the
  stale 'path needed on the remote' claim (v3 ships the session — remote
  needs only sshd + claude), drop the design-spec pointer, note the
  proxied-host ssh-CLI fallback, and cut the speculative Sprite section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bdelanghe
bdelanghe force-pushed the bdelanghe/remote-persistence-picker branch from 1e72e5e to 4a1c806 Compare July 25, 2026 21:45
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