diff --git a/CLAUDE.md b/CLAUDE.md index e5ccabd..0b6269d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,14 +53,19 @@ only the last week. | argv[0] | Mode | Description | |---------|------|-------------| -| `nssh` (or anything else) | session | SSH/mosh wrapper + ntfy subscriber | +| `nssh` (or anything else) | session | SSH/mosh/et wrapper + ntfy subscriber | | `xclip` | shim | Clipboard bridge via ntfy | | `wl-copy` / `wl-paste` | shim | Wayland clipboard bridge via ntfy | | `xdg-open` / `sensible-browser` | shim | URL forwarding via ntfy | ### Session mode (local) -- Wraps `ssh` or `mosh` with automatic transport selection +- Wraps `ssh`, `mosh`, or `et` (Eternal Terminal) with automatic transport + selection: when no `--ssh`/`--mosh`/`--et` flag is given, prefers `et` + (local `et` + remote `etserver`), then `mosh` (local `mosh` + remote + `mosh-server`), else falls back to plain `ssh`. Detection is a binary + presence check only; if a forced/auto transport's daemon is down, that + transport's own launch fails and the user reruns with another flag. - Generates a random topic (or reads a pinned one from config), writes it to the remote's `~/.config/nssh/session`, subscribes to ntfy in a background goroutine - Dispatches incoming messages by `kind`: diff --git a/README.md b/README.md index 3e47a90..2ce1dc8 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,11 @@ For nix/home-manager managed hosts, add the flake input and add a single activat ## Usage ```bash -# Connect (auto-selects mosh if both sides have it) +# Connect (auto-selects et > mosh > ssh based on what both sides have) nssh devbox nssh --ssh devbox # force plain SSH nssh --mosh devbox # force mosh +nssh --et devbox # force Eternal Terminal # Inside the remote session: @@ -151,7 +152,7 @@ The `NSSH_NTFY_BASE` environment variable overrides the server. - **Local:** macOS, Go 1.25+, [`pngpaste`](https://github.com/jcsalterego/pngpaste) (`brew install pngpaste`) - **Remote:** Linux with `~/.local/bin` in PATH. Zero runtime deps. - **Optional:** Self-hosted [ntfy](https://docs.ntfy.sh/install/) for privacy (public ntfy.sh works out of the box). -- **Optional:** `mosh` on both ends for session roaming. +- **Optional:** `mosh` (both ends) or [Eternal Terminal](https://eternalterminal.dev/) (`et` locally + `etserver` on the remote) for session roaming. When available, `et` is auto-selected over `mosh` over plain `ssh`. ## Further reading diff --git a/cmd/nssh/log.go b/cmd/nssh/log.go index f58961c..4d5c833 100644 --- a/cmd/nssh/log.go +++ b/cmd/nssh/log.go @@ -13,8 +13,8 @@ import ( // LogEvent is the on-disk schema for each JSONL log line. Both the writer // (logEvent below) and the reader (status.go::formatEvent) marshal against // this type, so renaming or adding a field is type-checked instead of -// grep-and-pray. Exit and Mosh are pointers so callers can record an -// explicit zero/false without omitempty dropping the field. +// grep-and-pray. Exit is a pointer so callers can record an explicit zero +// (exit=0, success) without omitempty dropping the field. type LogEvent struct { TS string `json:"ts"` Event string `json:"event"` @@ -29,14 +29,14 @@ type LogEvent struct { Size int `json:"size,omitempty"` // Session lifecycle. - Target string `json:"target,omitempty"` - Host string `json:"host,omitempty"` - Server string `json:"server,omitempty"` - Topic string `json:"topic,omitempty"` - Version string `json:"version,omitempty"` - Exit *int `json:"exit,omitempty"` - Mosh *bool `json:"mosh,omitempty"` - Joined int `json:"joined,omitempty"` + Target string `json:"target,omitempty"` + Host string `json:"host,omitempty"` + Server string `json:"server,omitempty"` + Topic string `json:"topic,omitempty"` + Version string `json:"version,omitempty"` + Exit *int `json:"exit,omitempty"` + Transport string `json:"transport,omitempty"` + Joined int `json:"joined,omitempty"` // Shim invocation. Persona string `json:"persona,omitempty"` diff --git a/cmd/nssh/log_test.go b/cmd/nssh/log_test.go index 6311916..5a8bf9d 100644 --- a/cmd/nssh/log_test.go +++ b/cmd/nssh/log_test.go @@ -25,8 +25,7 @@ func TestLogEventOmitsZeros(t *testing.T) { func TestLogEventExitZeroPreserved(t *testing.T) { zero := 0 - yes := true - e := LogEvent{Event: "session-end", Exit: &zero, Mosh: &yes} + e := LogEvent{Event: "session-end", Exit: &zero, Transport: "et"} data, err := json.Marshal(e) if err != nil { t.Fatal(err) @@ -35,25 +34,24 @@ func TestLogEventExitZeroPreserved(t *testing.T) { if !strings.Contains(got, `"exit":0`) { t.Errorf("exit=0 was dropped: %s", got) } - if !strings.Contains(got, `"mosh":true`) { - t.Errorf("mosh=true missing: %s", got) + if !strings.Contains(got, `"transport":"et"`) { + t.Errorf("transport=et missing: %s", got) } } func TestLogEventRoundTrip(t *testing.T) { exit := 42 - mosh := false want := LogEvent{ - TS: "2026-05-05T07:43:42Z", - Event: "session-end", - Side: "session", - PID: 12345, - Target: "devbox", - Server: "https://ntfy.sh", - Topic: "nssh_abc", - Version: "v0.1.0", - Exit: &exit, - Mosh: &mosh, + TS: "2026-05-05T07:43:42Z", + Event: "session-end", + Side: "session", + PID: 12345, + Target: "devbox", + Server: "https://ntfy.sh", + Topic: "nssh_abc", + Version: "v0.1.0", + Exit: &exit, + Transport: "mosh", } data, err := json.Marshal(want) if err != nil { @@ -69,8 +67,8 @@ func TestLogEventRoundTrip(t *testing.T) { if got.Exit == nil || *got.Exit != exit { t.Errorf("Exit not preserved: %v", got.Exit) } - if got.Mosh == nil || *got.Mosh != mosh { - t.Errorf("Mosh not preserved: %v", got.Mosh) + if got.Transport != want.Transport { + t.Errorf("Transport not preserved: %q", got.Transport) } } diff --git a/cmd/nssh/main.go b/cmd/nssh/main.go index a46a6ea..bb08c20 100644 --- a/cmd/nssh/main.go +++ b/cmd/nssh/main.go @@ -14,7 +14,7 @@ var buildVersion string func usage() { fmt.Fprintln(os.Stderr, "usage:") - fmt.Fprintln(os.Stderr, " nssh [--ssh|--mosh] [--join|--replace|--new] [ssh args...]") + fmt.Fprintln(os.Stderr, " nssh [--ssh|--mosh|--et] [--join|--replace|--new] [ssh args...]") fmt.Fprintln(os.Stderr, " open a session") fmt.Fprintln(os.Stderr, " nssh infect [--force] install on a remote host") fmt.Fprintln(os.Stderr, " nssh infect [--force] self symlink personas on this machine") diff --git a/cmd/nssh/session.go b/cmd/nssh/session.go index 39a3771..763630f 100644 --- a/cmd/nssh/session.go +++ b/cmd/nssh/session.go @@ -31,6 +31,7 @@ func nsshMain() { args := os.Args[1:] forceSSH := false forceMosh := false + forceET := false collisionFlag := "" // "join" | "replace" | "new" | "" parse: for len(args) > 0 { @@ -39,6 +40,8 @@ parse: forceSSH = true case "--mosh": forceMosh = true + case "--et": + forceET = true case "--join", "--replace", "--new": collisionFlag = strings.TrimPrefix(args[0], "--") case "-h", "--help": @@ -48,10 +51,25 @@ parse: } args = args[1:] } - if forceSSH && forceMosh { - fmt.Fprintln(os.Stderr, "nssh: --ssh and --mosh are mutually exclusive") + forced := 0 + for _, f := range []bool{forceSSH, forceMosh, forceET} { + if f { + forced++ + } + } + if forced > 1 { + fmt.Fprintln(os.Stderr, "nssh: --ssh, --mosh, and --et are mutually exclusive") os.Exit(1) } + forceTransport := "" + switch { + case forceSSH: + forceTransport = "ssh" + case forceMosh: + forceTransport = "mosh" + case forceET: + forceTransport = "et" + } if len(args) < 1 { usage() } @@ -136,14 +154,14 @@ parse: sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - session, useMosh := selectTransport(forceSSH, forceMosh, sshArgs, sshTarget) + session, transport := selectTransport(forceTransport, sshArgs, sshTarget) sessErr := runSession(session, sigs) resetTerminal() exitCode := 0 if exitErr, ok := sessErr.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() } - logEvent(LogEvent{Event: "session-end", Exit: &exitCode, Mosh: &useMosh}) + logEvent(LogEvent{Event: "session-end", Exit: &exitCode, Transport: transport}) unregisterSession(sessionFile) // defers don't fire under os.Exit if exitCode != 0 { os.Exit(exitCode) @@ -263,36 +281,67 @@ func resetTerminal() { ) } -// remoteHasMosh checks if mosh-server is on the remote's PATH. Used to -// auto-select transport when neither --ssh nor --mosh is given. -func remoteHasMosh(sshTarget string) bool { - cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "command -v mosh-server >/dev/null 2>&1") - return cmd.Run() == nil +// remoteHasCommand reports whether `cmd` is on the remote's PATH. Used to +// auto-select a transport (mosh-server / etserver) when none is forced. +func remoteHasCommand(sshTarget, cmd string) bool { + c := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, + fmt.Sprintf("command -v %s >/dev/null 2>&1", cmd)) + return c.Run() == nil } -// selectTransport picks ssh or mosh based on the user's flags and (if -// neither is forced) whether mosh is installed locally and on the remote. -// Returns the configured exec.Cmd plus a useMosh flag for downstream -// logging. When mosh is selected we force a UTF-8 locale because mosh's -// terminal emulation breaks under POSIX/C locales on minimal images. -func selectTransport(forceSSH, forceMosh bool, sshArgs []string, sshTarget string) (*exec.Cmd, bool) { - useMosh := false +// pickTransport decides which interactive transport to use. force is one of +// "" (auto), "ssh", "mosh", "et"; a forced choice is honored regardless of +// detected availability — the user knows their setup. When auto-selecting, +// preference is et > mosh > ssh, and a transport only wins if both its local +// and remote halves are present. +func pickTransport(force string, localMosh, remoteMosh, localET, remoteET bool) string { + switch force { + case "ssh", "mosh", "et": + return force + } switch { - case forceSSH: - case forceMosh: - useMosh = true + case localET && remoteET: + return "et" + case localMosh && remoteMosh: + return "mosh" default: - if _, err := exec.LookPath("mosh"); err == nil && remoteHasMosh(sshTarget) { - useMosh = true + return "ssh" + } +} + +// selectTransport resolves the transport (honoring force, else probing local +// binaries and the remote PATH), builds the interactive command, and returns +// it alongside the chosen transport name for downstream logging. Remote probes +// are skipped entirely when a choice is forced or the local binary is absent. +// When mosh is selected we force a UTF-8 locale because mosh's terminal +// emulation breaks under POSIX/C locales on minimal images. +func selectTransport(force string, sshArgs []string, sshTarget string) (*exec.Cmd, string) { + var localMosh, remoteMosh, localET, remoteET bool + if force == "" { + _, errET := exec.LookPath("et") + localET = errET == nil + _, errMosh := exec.LookPath("mosh") + localMosh = errMosh == nil + if localET { + remoteET = remoteHasCommand(sshTarget, "etserver") + } + if localMosh { + remoteMosh = remoteHasCommand(sshTarget, "mosh-server") } } - if useMosh { + + switch pickTransport(force, localMosh, remoteMosh, localET, remoteET) { + case "et": + fmt.Fprintln(os.Stderr, "nssh: using et for interactive session") + return exec.Command("et", sshTarget), "et" + case "mosh": fmt.Fprintln(os.Stderr, "nssh: using mosh for interactive session") cmd := exec.Command("mosh", sshTarget) cmd.Env = append(os.Environ(), "LC_ALL=C.UTF-8", "LANG=C.UTF-8") - return cmd, true + return cmd, "mosh" + default: + return exec.Command("ssh", sshArgs...), "ssh" } - return exec.Command("ssh", sshArgs...), false } // deadlineConn wraps net.Conn to push the read deadline forward on every Read. diff --git a/cmd/nssh/status.go b/cmd/nssh/status.go index 652a2c8..1db8985 100644 --- a/cmd/nssh/status.go +++ b/cmd/nssh/status.go @@ -342,8 +342,8 @@ func formatEvent(raw, label string) string { if e.Mime != "" { fmt.Fprintf(&sb, " mime=%s", e.Mime) } - if e.Mosh != nil { - fmt.Fprintf(&sb, " mosh=%v", *e.Mosh) + if e.Transport != "" { + fmt.Fprintf(&sb, " transport=%s", e.Transport) } if e.Persona != "" { fmt.Fprintf(&sb, " persona=%s", e.Persona) diff --git a/cmd/nssh/transport_test.go b/cmd/nssh/transport_test.go new file mode 100644 index 0000000..606c071 --- /dev/null +++ b/cmd/nssh/transport_test.go @@ -0,0 +1,39 @@ +package main + +import "testing" + +func TestPickTransport(t *testing.T) { + cases := []struct { + name string + force string + localMosh, remoteMosh, localET, remoteET bool + want string + }{ + // Forced choices win regardless of detected availability. + {"force ssh ignores availability", "ssh", true, true, true, true, "ssh"}, + {"force mosh ignores availability", "mosh", false, false, false, false, "mosh"}, + {"force et ignores availability", "et", false, false, false, false, "et"}, + + // Auto-select precedence: et > mosh > ssh. + {"auto all available picks et", "", true, true, true, true, "et"}, + {"auto et beats mosh", "", true, true, true, true, "et"}, + {"auto no et falls to mosh", "", true, true, false, false, "mosh"}, + {"auto nothing falls to ssh", "", false, false, false, false, "ssh"}, + + // Partial availability: both halves required for a transport to win. + {"auto local et only -> mosh", "", true, true, true, false, "mosh"}, + {"auto remote et only -> mosh", "", true, true, false, true, "mosh"}, + {"auto local mosh only -> ssh", "", true, false, false, false, "ssh"}, + {"auto remote mosh only -> ssh", "", false, true, false, false, "ssh"}, + {"auto local et only, no mosh -> ssh", "", false, false, true, false, "ssh"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := pickTransport(tc.force, tc.localMosh, tc.remoteMosh, tc.localET, tc.remoteET) + if got != tc.want { + t.Errorf("pickTransport(%q, mosh=%v/%v, et=%v/%v) = %q, want %q", + tc.force, tc.localMosh, tc.remoteMosh, tc.localET, tc.remoteET, got, tc.want) + } + }) + } +} diff --git a/docs/internals.md b/docs/internals.md index c479422..ac18f19 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -45,6 +45,24 @@ user who wants a stable topic can pin it in `config.toml`; any password auth on top is delegated to ntfy's own ACL config (we don't reimplement it). +### Transport selection (ssh / mosh / et) + +The interactive shell can ride `ssh`, `mosh`, or `et` (Eternal Terminal), +chosen in `selectTransport` (`session.go`). With no `--ssh`/`--mosh`/`--et` +flag, auto-selection prefers `et` (local `et` + remote `etserver`), then +`mosh` (local `mosh` + remote `mosh-server`), else `ssh`. Detection is a +binary-presence check only (`command -v` over `ssh -o BatchMode=yes`); the +pure decision lives in `pickTransport` and is unit-tested. A daemon that's +installed but not listening isn't caught here — the transport's own launch +fails and the user reruns with another flag. + +Note that `et` *does* support port forwarding, unlike mosh. nssh still +routes the clipboard/URL bridge over ntfy regardless of transport, so the +bridge's behavior is identical no matter how the shell is carried — the +remote shims never have to know which transport is in play. Like the mosh +path, the `et` launch passes only the host target (`et` resolves it via +`~/.ssh/config`); extra ssh flags are not forwarded. + ## Why dispatch on argv[0] Tools like Claude Code, `gh auth login`, and `gcloud auth login` call @@ -203,8 +221,8 @@ Browser GETs http://localhost:8585/cb?code=... Notes on this flow: - We use a fresh `ssh -W` per callback. No `ControlMaster`, no socket - files. This makes it work whether the outer session is ssh or mosh - — `ssh -W` is its own connection. The user authenticates once when + files. This makes it work whether the outer session is ssh, mosh, or + et — `ssh -W` is its own connection. The user authenticates once when the session starts (or relies on a key); subsequent `ssh -W` calls for OAuth callbacks reuse the same auth. - `ln.Accept()` has a 5-minute deadline (`oauthAcceptTimeout` in diff --git a/docs/protocol.md b/docs/protocol.md index cfdf4b2..8b146e8 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -91,9 +91,9 @@ type LogEvent struct { Server string `json:"server,omitempty"` Topic string `json:"topic,omitempty"` Version string `json:"version,omitempty"` - Exit *int `json:"exit,omitempty"` - Mosh *bool `json:"mosh,omitempty"` - Joined int `json:"joined,omitempty"` + Exit *int `json:"exit,omitempty"` + Transport string `json:"transport,omitempty"` + Joined int `json:"joined,omitempty"` // Shim invocation. Persona string `json:"persona,omitempty"` @@ -115,7 +115,7 @@ type LogEvent struct { |-------|------------|--------|---------| | `session-open` | local (during `prepareRemote`, written to remote log via SSH heredoc) | `server`, `topic`, `target`, `version` | Local nssh announces itself to the remote at session start. Side is `session-init`. | | `session-start` | local | `target`, `host`, `server`, `joined` | Local subscriber is starting. `joined` is the PID of the existing nssh whose topic we adopted, omitted on a fresh connect. | -| `session-end` | local | `exit`, `mosh` | Local interactive session ended. `exit` is `0` on success; `mosh` records which transport was used. | +| `session-end` | local | `exit`, `transport` | Local interactive session ended. `exit` is `0` on success; `transport` is `ssh`, `mosh`, or `et` — which transport carried the session. | | `subscribe-up` | local | `reconnect`, `gap`, `since` | ntfy `/json` long-poll connected. `reconnect=true` plus `gap` is set after a prior `subscribe-down`; `since` echoes the ntfy message id resumed from. | | `subscribe-down` | local | `err` | ntfy `/json` long-poll dropped (network, timeout, EOF). Followed by a reconnect attempt. | | `msg-send` | either | `kind`, `mime`, `id`, `url`, `size` | Envelope published to the topic. | @@ -132,11 +132,12 @@ type LogEvent struct { attachments, it's the attachment's reported size; for inline payloads, it's the decoded base64 length. -`Exit` and `Mosh` are pointer-typed because they need to record an -explicit zero/false meaning — `exit=0` (success) and `mosh=false` -(used ssh) are real values that should appear in the log, not be -silently dropped by `omitempty`. All other zero-valued fields *are* -dropped. +`Exit` is pointer-typed because it needs to record an explicit zero — +`exit=0` (success) is a real value that should appear in the log, not be +silently dropped by `omitempty`. `Transport` is a plain string: its +empty value means "unset" and is correctly dropped, while a real +transport (`ssh`/`mosh`/`et`) is always non-empty. All other zero-valued +fields *are* dropped. ## Config precedence diff --git a/docs/superpowers/specs/2026-06-03-eternal-terminal-transport-design.md b/docs/superpowers/specs/2026-06-03-eternal-terminal-transport-design.md new file mode 100644 index 0000000..5ee9d30 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-eternal-terminal-transport-design.md @@ -0,0 +1,150 @@ +# Eternal Terminal as a third transport + +## Summary + +Add [Eternal Terminal](https://eternalterminal.dev/) (`et`) as a third +interactive transport alongside ssh and mosh. ET is a TCP-based roaming shell +(survives disconnects/IP changes like mosh, but supports native scrollback and +works through more firewalls). It bootstraps over SSH and brokers the session +through a persistent `etserver` daemon on the remote. + +## Why this is cleanly scoped + +nssh's clipboard/URL bridge runs entirely over ntfy and is **transport-agnostic**. +The interactive transport is just the subprocess `runSession` execs after the +remote-prepare step (`prepareRemote`, which writes the session file + seeds the +remote log over a plain `ssh ... bash -l -s` invocation — independent of which +transport carries the interactive shell). ET slots in as a peer of mosh. No +changes to: + +- the ntfy subscriber (`subscribeNtfy`/`handleMessage`) +- the wire format / envelope kinds +- the remote shims (`xclip`, `wl-copy`, `xdg-open`, …) +- `prepareRemote` / session-file convention +- session-collision logic + +The work is concentrated in `selectTransport` plus flag, log-schema, and doc +plumbing. + +## Behavior + +### Flags +- New `--et` flag in session mode, **mutually exclusive** with `--ssh` and + `--mosh`. At most one of the three may be passed. + +### Auto-selection (when none of `--ssh`/`--mosh`/`--et` is forced) +Order of preference: **ET → mosh → ssh**. + +- ET is chosen when `et` is on the **local** PATH **and** `etserver` is on the + **remote** PATH (binary check only — `command -v etserver`, mirroring the + existing `command -v mosh-server` probe for mosh). +- Else mosh, when `mosh` is local and `mosh-server` is remote. +- Else ssh. + +Binary-check-only is a deliberate best-effort: `etserver` is a long-running +daemon, so the binary being present doesn't *prove* the daemon is listening. If +it's down, `et` errors out on launch and the user reruns with `--mosh`/`--ssh`. +nssh does not attempt to recover or probe the port — same posture as today's +mosh handling. + +### ET launch form +`et ` — like the existing mosh path, **only the host target (`args[0]`) is +passed**, not the user's extra ssh flags. `et` resolves the host (and any +custom port/options) via `~/.ssh/config`. Default etserver port only; no +nssh-level port knob. No `LC_ALL`/`LANG` UTF-8 override (that workaround stays +mosh-specific). + +## Code changes (`cmd/nssh/`) + +### `main.go` +- Add `--et` to the session usage line in `usage()`. + +### `session.go` +- Parse `--et` → `forceET` in `nsshMain`'s flag loop. +- Enforce "at most one of `--ssh`/`--mosh`/`--et`" (extend the current + ssh+mosh mutual-exclusion check). +- Rework `selectTransport` to return the transport **name** as a string + (`"ssh" | "mosh" | "et"`) instead of `(*exec.Cmd, useMosh bool)`. The ET + branch builds `exec.Command("et", sshTarget)` with no env override. +- Add `remoteHasET(sshTarget)`; fold it and `remoteHasMosh` into a single + `remoteHasCommand(sshTarget, cmd string) bool` helper that runs + `ssh -o BatchMode=yes "command -v >/dev/null 2>&1"`. (Small, + in-scope dedup of two identical probes.) +- Update the `session-end` log call site to record `Transport: ` (see + log schema change below) instead of `Mosh: &useMosh`. + +### Testability +- Extract the pure decision into: + ```go + func pickTransport(force string, localMosh, remoteMosh, localET, remoteET bool) string + ``` + where `force` is `""|"ssh"|"mosh"|"et"`. `selectTransport` feeds it the real + `exec.LookPath` + `remoteHasCommand` results and then constructs the + `*exec.Cmd` for the chosen name. +- Add `transport_test.go` with a truth table over `pickTransport` (forced + cases, auto-select precedence, partial availability). This logic is currently + untested because `selectTransport` does I/O. + +## Log schema change + +`session-end` records which transport was used. The current `Mosh *bool` cannot +express three transports, so: + +- In `log.go`, **replace** `Mosh *bool` with `Transport string` + (`json:"transport,omitempty"`). It is no longer pointer-typed — the empty + string naturally means "unset" and is dropped by `omitempty`, and there's no + meaningful zero-value-that-must-be-recorded as there was for `mosh=false`. +- In `status.go::formatEvent`, replace the `if e.Mosh != nil` block with + `if e.Transport != "" { fmt.Fprintf(&sb, " transport=%s", e.Transport) }`. +- Old log lines carrying a `mosh` field are silently ignored by the new reader + (the field no longer exists on the struct). Acceptable: logs are ephemeral + per-session diagnostics. + +`Exit *int` stays pointer-typed (exit=0 is a real value that must be logged). + +## Data flow + +Unchanged. ET, like mosh, is only the interactive subprocess. Clipboard (text + +images) and URL/OAuth forwarding continue to flow over the ntfy topic exactly as +before. + +## Error handling + +- Daemon down despite `etserver` binary present → `et` exits non-zero; + `runSession` returns the exit error; nssh reports the exit code. User reruns + with `--mosh`/`--ssh`. No special-casing. +- `--ssh`/`--mosh`/`--et` conflict → error to stderr, exit 1 (existing pattern). + +## Testing + +- **Unit:** `transport_test.go` truth table over `pickTransport`: + - each `force` value returns that transport regardless of availability + - unforced + all available → `et` + - unforced + et missing, mosh present → `mosh` + - unforced + neither → `ssh` + - unforced + local et but no remote etserver → falls through to mosh/ssh +- **Manual:** + - `nssh --et ` launches et and the clipboard bridge still works. + - On a host with both et and mosh, unforced `nssh ` selects et + (stderr: "nssh: using et for interactive session"). + - `nssh status --tail` shows `transport=et` on the `session-end` line. + +## Docs to update (same change) + +- **CLAUDE.md** — session-mode description ("Wraps `ssh` or `mosh`" → add et); + the persona/transport mention. +- **docs/internals.md** — transport-selection narrative; "whether the outer + session is ssh or mosh" → add et. +- **docs/protocol.md** — `LogEvent` struct (`Mosh *bool` → `Transport string`), + the `session-end` event-vocabulary row, and the pointer-typed-fields note + (now only `Exit` is pointer-typed). +- **README.md** — pitch lines that say "ssh/mosh", the `--et` line in the usage + block, and a requirements note (et on both ends, optional like mosh). + +## Out of scope + +- ET port config knob (`et_port` in config.toml) — default only for now. +- `nssh sweep` support for `et`/`etterminal` — etserver is a shared daemon that + must not be killed; ET cleans up its own sessions. +- Forwarding the user's extra ssh flags to `et` — mirrors the existing mosh + limitation.