diff --git a/.github/scripts/assert-static.sh b/.github/scripts/assert-static.sh
index 8c4cca95..59d553cf 100755
--- a/.github/scripts/assert-static.sh
+++ b/.github/scripts/assert-static.sh
@@ -3,8 +3,8 @@
# Fail unless the binary is a fully static ELF — no dynamic loader, no shared
# library dependencies.
#
-# This is the mechanical guard behind D10 (docs/2026-07-27-remote-workspace-design.md):
-# one `tty7-server` binary is pushed to arbitrary remote machines and must run
+# This is the mechanical guard behind decision D10: one `tty7-server` binary
+# is pushed to arbitrary remote machines and must run
# there regardless of what libc, and what *version* of it, that machine has. A
# build that silently picked up a dynamic dependency would still pass a
# compile-only CI job and then fail on the first old box a user connects to —
diff --git a/.github/scripts/bundle-windows.ps1 b/.github/scripts/bundle-windows.ps1
index f57cbb16..e82a8a0c 100644
--- a/.github/scripts/bundle-windows.ps1
+++ b/.github/scripts/bundle-windows.ps1
@@ -27,7 +27,7 @@ Copy-Item LICENSE "$Stage/LICENSE.txt"
Copy-Item README.md "$Stage/README.md"
# The Linux musl `tty7-server`, staged at server/ so a WSL distro can be handed
-# the binary this client shipped with (design §12: WSL downloads nothing). The
+# the binary this client shipped with (WSL downloads nothing). The
# lookup path is a contract with `daemon::install::wsl` — it searches
#
/server/ first — so this directory name is not free to
# change on its own. Missing is a warning, not an error, matching `server-musl`'s
diff --git a/.github/scripts/check-host-boundary.sh b/.github/scripts/check-host-boundary.sh
index 51eb7149..b1dc7216 100755
--- a/.github/scripts/check-host-boundary.sh
+++ b/.github/scripts/check-host-boundary.sh
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
#
-# Contract §10.6 — the GUI must not touch the filesystem or git directly.
+# The GUI must not touch the filesystem or git directly.
#
# Once a workspace can live on a remote machine, a path held by `ui::` or
# `terminal::` is not necessarily a path on *this* box, and `std::path`'s
-# fs-backed APIs quietly answer for the wrong machine (contract §4.3):
+# fs-backed APIs quietly answer for the wrong machine:
# `canonicalize` walks the local filesystem, `is_absolute` says `false` for
# `/home/me` on Windows, `read_dir` lists the client's disk. Everything that may
# be looking at a workspace path has to go through `ui::host_ops` / the `Host`
@@ -151,7 +151,7 @@ if [ "$violations" -ne 0 ]; then
cat >&2 <<'EOF'
--------------------------------------------------------------------------------
-Contract §10.6: the GUI reached the filesystem/git directly.
+The GUI reached the filesystem/git directly.
A path in `ui::` or `terminal::` may belong to a remote workspace, where these
calls answer for the wrong machine. Route it through `ui::host_ops` / `Host`
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 86a803f9..fb645354 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,9 +22,9 @@ jobs:
components: rustfmt
- run: cargo fmt --check
- # Contract §10.6: `ui::` and `terminal::` must not reach the filesystem or git
+ # `ui::` and `terminal::` must not reach the filesystem or git
# directly, because once a workspace can be remote those calls answer for the
- # wrong machine (§4.3). The allowlist of genuinely-local paths lives in the
+ # wrong machine. The allowlist of genuinely-local paths lives in the
# script, next to the reason each one is exempt.
#
# A standalone job on purpose, and one that must stay *non-required*: main's
@@ -34,7 +34,7 @@ jobs:
# `server-musl` below. Cheap enough (a checkout and a grep) that it does not
# need caching or a toolchain.
host-boundary:
- name: host boundary (§10.6)
+ name: host boundary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -94,7 +94,7 @@ jobs:
run: cargo test --locked --target ${{ matrix.target }}
# Static musl builds of the headless server binary that remote workspaces push
- # onto the far machine (docs/2026-07-27-remote-workspace-design.md, D10/§12).
+ # onto the far machine (decision D10).
# One binary has to run on any distro without regard to the target's glibc
# version, so it is linked fully static against musl rather than built per
# distro. Compile-only — this job publishes nothing; release.yml and
@@ -103,8 +103,7 @@ jobs:
# Deliberately a *separate* job rather than two more rows in the `build` matrix
# above: those three `build & test ()` names are main's required
# checks, and reshaping that matrix would wedge branch protection on every open
- # PR. Keep this job non-required until it has a few weeks of green — see
- # docs/remote-server-assets.md.
+ # PR. Keep this job non-required until it has a few weeks of green.
server-musl:
name: tty7-server musl (${{ matrix.target }})
runs-on: ubuntu-latest
@@ -153,7 +152,7 @@ jobs:
with:
key: ${{ matrix.target }}
- # The crate split (§11) lands separately; until `tty7-server` exists as a
+ # The crate split lands separately; until `tty7-server` exists as a
# workspace member this job has nothing to build. Skip cleanly rather than
# fail, so the workflow can land before the split and simply start working
# once it arrives. `--no-deps` keeps this to a manifest parse — no
@@ -167,7 +166,7 @@ jobs:
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
- echo "::notice::tty7-server is not a workspace member yet (crate split, design §11 / M1) — nothing to build"
+ echo "::notice::tty7-server is not a workspace member yet (crate split, M1) — nothing to build"
fi
# `-p tty7-server` addresses the package by name, so this survives whatever
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 4a115ed5..43baa178 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -167,7 +167,7 @@ jobs:
if-no-files-found: error
# Mirrors release.yml's server-musl job; keep the two in sync when editing.
- # Nightly carries the server binaries too so the remote-install path (§12) can
+ # Nightly carries the server binaries too so the remote-install path can
# be exercised against the rolling channel instead of waiting for a tag.
server-musl:
needs: plan
@@ -272,7 +272,7 @@ jobs:
path: dist
merge-multiple: true
- # Same contract as release.yml — see docs/remote-server-assets.md. Written
+ # Same contract as release.yml — see `install::asset`. Written
# into dist/ before the upload below so it ships as an asset like any
# other, and so the prune step at the end sees it as current.
- name: Generate checksums.txt
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6f1fe16e..5fbc5f20 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -12,7 +12,7 @@ jobs:
build:
# The Windows installer embeds the Linux musl `tty7-server` so a WSL distro
# can be served the binary the client already shipped with, instead of
- # downloading one (design §12: WSL installs nothing over the network). That
+ # downloading one (WSL installs nothing over the network). That
# binary comes from `server-musl`, so the two jobs can no longer run in
# parallel. Serialising all four platforms behind it costs a few minutes on
# a release — cheap next to splitting the Windows entry into its own job and
@@ -150,10 +150,11 @@ jobs:
if-no-files-found: error
# The headless server binary remote workspaces install on the far machine
- # (design doc D10/§12). Statically linked against musl so a single binary runs
+ # (decision D10). Statically linked against musl so a single binary runs
# on any distro whatever its glibc vintage, and shipped as a bare executable
# rather than an archive so the client can fetch exactly one file and verify it
- # against checksums.txt. Asset naming contract: docs/remote-server-assets.md.
+ # against checksums.txt. The asset names are a contract with the installer:
+ # `tty7_core::daemon::install::asset` derives them from `uname -sm`.
#
# Separate from the `build` matrix above because it shares nothing with it: no
# GUI toolchain, no bundling, no code signing, two targets off one runner.
@@ -193,7 +194,7 @@ jobs:
workspaces: tty7
key: ${{ matrix.target }}
- # Until the crate split (§11) lands there is no tty7-server to build. Skip
+ # Until the crate split lands there is no tty7-server to build. Skip
# rather than fail, so this workflow can ship ahead of the split; the
# release simply carries no server assets until it arrives.
- name: Look for the tty7-server package
@@ -224,7 +225,7 @@ jobs:
run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server"
# Flat, version-free asset name — the tag in the download URL carries the
- # version. See docs/remote-server-assets.md for the contract the client
+ # version. See `install::asset` for the contract the client
# installer derives this name from.
- name: Stage the asset
if: steps.probe.outputs.present == 'true'
@@ -262,12 +263,12 @@ jobs:
merge-multiple: true
# sha256 over every asset, so the remote-server installer can verify what
- # it downloaded before writing it to someone else's machine (design §16 —
- # a mismatch aborts the install outright). Generated here rather than in
+ # it downloaded before writing it to someone else's machine (a mismatch
+ # aborts the install outright). Generated here rather than in
# the build jobs because only this job sees the complete asset set, and a
# per-job fragment would have to be concatenated in a deterministic order
# anyway. GNU coreutils format (" "), bare filenames, sorted —
- # see docs/remote-server-assets.md for the format the client parses.
+ # see `install::checksums` for the format the client parses.
- name: Generate checksums.txt
run: |
set -euo pipefail
diff --git a/Cargo.toml b/Cargo.toml
index e54f107b..c81f0504 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,8 +16,7 @@ path = "src/main.rs"
[dependencies]
# The framework-free half of tty7: wire protocol, session daemon, PTY, the
# native SSH engine, and the domain model the headless `tty7-server` shares with
-# this GUI. Everything that does *not* need gpui lives there — see
-# `docs/2026-07-27-remote-workspace-design.md` §11.
+# this GUI. Everything that does *not* need gpui lives there.
# `gssapi` is off in tty7-core's defaults (a static musl `tty7-server` cannot
# link the system krb5 it binds); the GUI, which builds against a real desktop
# toolchain, turns it on so managed SSH connections keep offering
@@ -25,7 +24,7 @@ path = "src/main.rs"
#
# `remote-install` is off there for the same shape of reason: it pulls an HTTPS
# client used only to *download* a `tty7-server` onto a remote machine
-# (design §12, D5). The server binary is the thing being downloaded, so it can
+# (decision D5). The server binary is the thing being downloaded, so it can
# never take that path; the GUI, which is the client that pushes it, can.
tty7-core = { path = "crates/tty7-core", features = ["gssapi", "remote-install"] }
diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml
index 34b32e8a..bc262270 100644
--- a/crates/tty7-core/Cargo.toml
+++ b/crates/tty7-core/Cargo.toml
@@ -10,7 +10,7 @@ publish = false
# The whole point of this crate is that it does *not* depend on gpui. Everything
# here has to compile and run on a headless Linux box (that is what
# `tty7-server` is), so nothing windowing-, rendering- or GUI-shaped belongs in
-# these dependencies — see `docs/2026-07-27-remote-workspace-design.md` §11.
+# these dependencies.
[dependencies]
anyhow.workspace = true
log.workspace = true
@@ -155,7 +155,7 @@ tokio = { version = "1", features = ["test-util", "macros", "rt"] }
# unchanged. It has to be optional because `libgssapi` binds the *system* MIT /
# Heimdal krb5 through bindgen: a machine without krb5 headers cannot build it
# at all, and a static musl `tty7-server` — the binary remote workspaces push
-# onto arbitrary hosts (design §12, D10) — cannot link it under any
+# onto arbitrary hosts (decision D10) — cannot link it under any
# circumstances. Without the feature, `SshAuthMode::Gssapi` reports that the
# method is unavailable in this build and the other auth families are untouched.
#
@@ -170,7 +170,7 @@ tokio = { version = "1", features = ["test-util", "macros", "rt"] }
default = []
gssapi = ["dep:libgssapi"]
# Lets this build download a `tty7-server` release asset over HTTPS and push it
-# onto a remote machine (design §12). Off by default so `tty7-server` — which is
+# onto a remote machine. Off by default so `tty7-server` — which is
# the thing being downloaded, and never the thing doing the downloading — links
# no HTTP client. Without it, `daemon::install` still installs from bytes it is
# handed and still launches/probes a remote daemon; only the fetch fails, with a
diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs
index 0bfae6f5..aa517f36 100644
--- a/crates/tty7-core/src/core/agent_hooks.rs
+++ b/crates/tty7-core/src/core/agent_hooks.rs
@@ -1513,6 +1513,9 @@ mod tests {
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result {
self.0.git(cwd, args)
}
+ fn shells(&self) -> io::Result {
+ self.0.shells()
+ }
fn watch(&self, dirs: &[PathBuf]) -> io::Result {
self.0.watch(dirs)
}
diff --git a/crates/tty7-core/src/core/keychain.rs b/crates/tty7-core/src/core/keychain.rs
index b0723742..ed341421 100644
--- a/crates/tty7-core/src/core/keychain.rs
+++ b/crates/tty7-core/src/core/keychain.rs
@@ -20,7 +20,7 @@
//! `daemon::protocol`'s `NativeSshSpec`) and the headless `tty7-server` runs on
//! boxes that have no OS keychain at all. Keeping `keyring` out of this crate's
//! manifest is what keeps a static `tty7-server` from linking the whole
-//! `zbus`/`secret-service` stack it can never use — see the design doc §11.
+//! `zbus`/`secret-service` stack it can never use.
//!
//! What has to stay is exactly what `Config` needs to parse `config.json`
//! identically on the server: the account-naming scheme and [`CredentialRef`].
diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs
index 7c2a7316..8efe0e6d 100644
--- a/crates/tty7-core/src/core/session.rs
+++ b/crates/tty7-core/src/core/session.rs
@@ -93,8 +93,8 @@ pub struct SessionTab {
/// is deliberately not persistable. The qualifier is not missing, it is
/// factored out: a tab always belongs to exactly one [`Workspace`], a
/// workspace names exactly one machine in [`Workspace::host`], and a
- /// window shows exactly one workspace (design §2, and §3's "一个窗口里既
- /// 有本地又有远程 —— 这个**永远不做**"). So the fully-qualified group key
+ /// window shows exactly one workspace — mixing local and remote tabs in one
+ /// window is the thing tty7 never does. So the fully-qualified group key
/// is `(workspace.host_id(), tab.sidebar_group)`, with the host half
/// stored once per workspace instead of once per tab. Two machines whose
/// repos share a root path can only collide inside one window, which the
@@ -153,14 +153,14 @@ impl std::fmt::Display for WorkspaceId {
/// The machine a remote workspace lives on, named the way the user already
/// named it.
///
-/// **This is a pointer, never a configuration.** Design §2 is explicit that a
+/// **This is a pointer, never a configuration.** It is a hard rule that a
/// machine is configured once and that remote workspaces reuse what is already
/// there — the profile's keys, its jump host, its `ProxyCommand` — so this type
/// has exactly one job: say *which* existing entry to connect through. The
/// three variants are the three places an SSH target can already have been
/// spelled out in tty7 today.
///
-/// | Variant | Where it came from | Connection key (contract §4.2) |
+/// | Variant | Where it came from | Connection key |
/// |---|---|---|
/// | [`Profile`](RemoteTarget::Profile) | A saved [`SshProfile`](crate::core::ssh_profile::SshProfile), by its stable uuid | `ssh-profile:` |
/// | [`Alias`](RemoteTarget::Alias) | A `Host` stanza in `~/.ssh/config` | `ssh-alias:` |
@@ -192,7 +192,7 @@ pub enum RemoteTarget {
},
/// A WSL distribution. **M8 owns the behaviour**; the variant exists now so
/// that [`connection_key`](RemoteTarget::connection_key) is a total function
- /// over contract §4.2's table rather than one that grows a case later.
+ /// over the table rather than one that grows a case later.
Wsl { distro: String },
/// A `tty7-server --stdio` child process on *this* machine — the workspace
/// mirror of [`RouteTarget::LocalStdio`](crate::daemon::router::RouteTarget::LocalStdio),
@@ -245,12 +245,11 @@ impl RemoteTarget {
))
}
- /// The canonical connection string this target hashes to (contract §4.2).
+ /// The canonical connection string this target hashes to.
///
/// **Contains no workspace id.** Several workspaces on one box share a key,
/// and therefore share a [`HostId`](crate::host::HostId) and the one SSH
- /// connection underneath it — the granularity the whole design assumes
- /// (design §10).
+ /// connection underneath it — the granularity the whole design assumes.
///
/// One conservative case worth knowing: `me@box` and a bare `box` are
/// different keys even when the client's SSH would resolve them to the same
@@ -323,7 +322,7 @@ impl std::fmt::Display for RemoteTarget {
/// into that machine's `~/.local/share/tty7/workspaces.json`
/// ([`crate::core::workspace_store`]). A client-side [`Workspace`] carrying one
/// of these is a *view*, not the record: its `session` is left empty until the
-/// layout is pulled from the remote, which owns it (design §10).
+/// layout is pulled from the remote, which owns it.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RemoteRef {
/// Which machine, in terms of a configuration that already exists.
@@ -381,7 +380,7 @@ pub struct Workspace {
/// The machine this workspace's panes and files live on. `None` means this
/// one, **and means it identically to every build that predates the field**:
/// a `session.json` written before this existed decodes with `None`
- /// throughout, i.e. all-local, which is the behaviour it had (design §10).
+ /// throughout, i.e. all-local, which is the behaviour it had.
///
/// A `Some` entry is a *view* of a record that lives over there. Its
/// `session` is empty until the layout is pulled from the remote's own
@@ -390,6 +389,32 @@ pub struct Workspace {
/// workspace from the laptop at home.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option,
+ /// Identity of the daemon *process* the pane ids in `session` refer to
+ /// (see `daemon::protocol::DaemonVersion::instance`). One field for the
+ /// whole workspace, not one per leaf, because a workspace's panes all live
+ /// in one daemon (one window, one machine).
+ ///
+ /// This is what makes a saved pane id safe to trust: daemon ids restart
+ /// from 1, so after a reboot every saved id points at whatever unrelated
+ /// shell happens to hold the number now — and restore's aliveness check
+ /// cannot tell a survivor from a squatter. A claim whose instance differs
+ /// from the daemon now serving blanks its ids instead
+ /// ([`Workspace::forget_stale_pane_ids`]) and takes the fresh-spawn path,
+ /// agent resume included, which is the correct reading of "the daemon
+ /// those panes lived in is gone".
+ ///
+ /// A remote workspace records its machine's `tty7-server` instance here,
+ /// for exactly the same reason and read by exactly the same check. The live
+ /// per-connection tracking on the client (`note_instance`) does not replace
+ /// this: that map is in memory, so it is empty on the launch where it would
+ /// matter most — the one after a client restart that spanned a server
+ /// replacement.
+ ///
+ /// `None` for records written before the field, and whenever the serving
+ /// process cannot be named (an older peer, a machine not connected). `None`
+ /// disables the check, never fails it.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub daemon_instance: Option,
}
impl Default for Workspace {
@@ -402,6 +427,7 @@ impl Default for Workspace {
open: true,
last_active: now_secs(),
host: None,
+ daemon_instance: None,
}
}
}
@@ -488,6 +514,48 @@ impl Workspace {
out
}
+ /// Drop every saved pane id, keeping the layout. Answers how many were
+ /// dropped, so a caller with nothing to forget can skip the write.
+ ///
+ /// For the one caller that *knows* the panes are gone: ending a workspace's
+ /// sessions kills them and then leaves the record on file to be reopened.
+ /// The ids in it are ours to invalidate — we are what killed them — and a
+ /// leaf with no id is exactly what restore needs to see, because that is
+ /// the path that spawns a fresh shell in the saved cwd and hands a coding
+ /// agent its `--resume`. Left in place they are a promise the machine
+ /// cannot keep: the reattach finds nothing, and on a remote workspace it
+ /// used to have no way to say so.
+ pub fn forget_pane_ids(&mut self) -> usize {
+ let mut forgotten = 0;
+ for tab in &mut self.session.tabs {
+ forgotten += blank_pane_ids(&mut tab.pane);
+ }
+ forgotten
+ }
+
+ /// Blank every saved pane id if it was recorded against a *different*
+ /// daemon process than `current` — see [`Workspace::daemon_instance`] for
+ /// the id-reuse failure this closes. Answers how many ids were dropped.
+ ///
+ /// Only a **known, differing** instance pair trips it. `None` on either
+ /// side means "cannot tell" (an old record, an old daemon), and treating
+ /// that as stale would respawn every pane on the first launch after an
+ /// upgrade — exactly the sessions persistence exists to keep.
+ ///
+ /// The agent fields stay, deliberately: unlike a *duplicate* claim (see
+ /// `drop_duplicate_pane_ids`), a stale-instance claim means the pane is
+ /// genuinely gone with its daemon, nothing else is running the
+ /// conversation, and the fresh shell resuming it is the feature.
+ pub fn forget_stale_pane_ids(&mut self, current: Option<&str>) -> usize {
+ let (Some(recorded), Some(current)) = (self.daemon_instance.as_deref(), current) else {
+ return 0;
+ };
+ if recorded == current {
+ return 0;
+ }
+ self.forget_pane_ids()
+ }
+
/// Stamp this workspace as just-focused.
pub fn touch(&mut self) {
self.last_active = now_secs();
@@ -531,7 +599,7 @@ impl Workspace {
/// The record the **remote** owns, as the JSON that crosses the wire in a
/// [`WorkspacePut`](crate::daemon::control::ControlRequest::WorkspacePut).
///
- /// Design §10's storage split, executable rather than aspirational: what
+ /// The storage split, executable rather than aspirational: what
/// stays here is `window`, `open` and `host` — this client's view state —
/// and what goes over there is everything that is a fact about the machine.
/// [`REMOTE_OWNED_FIELDS`] pins the split, and a test fails if a new field
@@ -559,7 +627,7 @@ impl Workspace {
}
}
-/// The `Workspace` fields the **remote** is the authority for (design §10).
+/// The `Workspace` fields the **remote** is the authority for.
/// Everything else is client-side view state and never leaves this machine.
///
/// A `Workspace` field that is in neither list is a bug: it would be dropped by
@@ -569,7 +637,11 @@ impl Workspace {
pub const REMOTE_OWNED_FIELDS: &[&str] = &["id", "name", "session", "last_active"];
/// The client-side view state, which stays in this machine's `session.json`.
-pub const CLIENT_OWNED_FIELDS: &[&str] = &["window", "open", "host"];
+/// `daemon_instance` is client-owned because it records **which serving process
+/// this client last saw** — an observation, not a property of the workspace. Two
+/// clients open on one remote workspace each keep their own, and neither may
+/// overwrite the other's; a remote record that carried it would do exactly that.
+pub const CLIENT_OWNED_FIELDS: &[&str] = &["window", "open", "host", "daemon_instance"];
/// The remote-owned half of a [`Workspace`], for reading a record back.
///
@@ -804,18 +876,45 @@ fn collect_pane_ids(pane: &SessionPane, out: &mut Vec) {
}
}
+/// Blank every leaf's `pane_id` under `pane`, answering how many were set.
+/// See [`Workspace::forget_pane_ids`].
+pub fn blank_pane_ids(pane: &mut SessionPane) -> usize {
+ match pane {
+ SessionPane::Leaf { pane_id, .. } => usize::from(pane_id.take().is_some()),
+ SessionPane::Split { a, b, .. } => blank_pane_ids(a) + blank_pane_ids(b),
+ }
+}
+
/// Blank any `pane_id` already claimed by an earlier-visited workspace. A
/// blanked leaf still restores — it just spawns a fresh shell in its saved cwd,
/// the same path a session from before the daemon existed takes.
+///
+/// The agent resume fields go with it. A blanked leaf takes restore's
+/// spawn-fresh path, and that path auto-types the agent's resume command —
+/// but the pane this claim duplicated is still running that very agent under
+/// its winning workspace, so "recovering" the loser would start a second
+/// process on the same agent session id. The duplicate claim is the evidence
+/// of a corrupted record, not of a lost conversation; the conversation lives
+/// with the winner.
fn drop_duplicate_pane_ids(
pane: &mut SessionPane,
seen: &mut std::collections::HashSet,
) -> usize {
match pane {
- SessionPane::Leaf { pane_id, .. } => match *pane_id {
+ SessionPane::Leaf {
+ pane_id,
+ agent_session_id,
+ agent_launch_argv,
+ ..
+ } => match *pane_id {
Some(id) if !seen.insert(id) => {
- log::warn!("workspace claims pane {id} twice; dropping the duplicate claim");
+ log::warn!(
+ "workspace claims pane {id} twice; dropping the duplicate claim \
+ (and its agent resume, which the winning claim still owns)"
+ );
*pane_id = None;
+ *agent_session_id = None;
+ *agent_launch_argv = None;
1
}
_ => 0,
@@ -1116,6 +1215,110 @@ mod tests {
assert_eq!(back.active, Some(id));
}
+ /// Ending a workspace's sessions leaves the layout and drops the ids — the
+ /// cwds are what reopening rebuilds from, and a kept id would send restore
+ /// down the reattach path to a pane that no longer exists.
+ #[test]
+ fn forgetting_pane_ids_keeps_the_layout_and_the_cwds() {
+ let mut ws = workspace(vec![
+ tab(
+ SessionPane::Split {
+ axis: SessionAxis::Horizontal,
+ ratio: 0.5,
+ a: Box::new(leaf(Some("/work"), Some(1))),
+ b: Box::new(leaf(Some("/work/api"), Some(2))),
+ },
+ Some("/work"),
+ ),
+ tab(leaf(Some("/tmp"), None), None),
+ ]);
+
+ assert_eq!(
+ ws.forget_pane_ids(),
+ 2,
+ "only the claims that existed count"
+ );
+ assert!(ws.pane_ids().is_empty());
+ assert_eq!(ws.session.tabs.len(), 2, "the tabs are what survives");
+ assert_eq!(ws.pane_count(), 3, "and so is the split");
+ assert_eq!(
+ ws.first_cwd(),
+ Some(PathBuf::from("/work")),
+ "reopening respawns in the saved directory, so it must still be there"
+ );
+ assert_eq!(
+ ws.forget_pane_ids(),
+ 0,
+ "a second pass has nothing to do, so the caller can skip its write"
+ );
+ }
+
+ /// The stale-instance check: ids recorded against a *different* daemon
+ /// process are blanked (they now name unrelated shells at best), ids
+ /// recorded against the *same* one are kept, and an unknown on either side
+ /// changes nothing — treating "cannot tell" as stale would respawn every
+ /// pane on the first launch after an upgrade.
+ #[test]
+ fn stale_instance_blanks_pane_ids_and_matching_or_unknown_keeps_them() {
+ let fresh = |instance: Option<&str>| {
+ let mut ws = workspace(vec![tab(leaf(Some("/work"), Some(7)), None)]);
+ ws.daemon_instance = instance.map(str::to_string);
+ ws
+ };
+
+ let mut ws = fresh(Some("daemon-a"));
+ assert_eq!(ws.forget_stale_pane_ids(Some("daemon-b")), 1);
+ assert!(ws.pane_ids().is_empty());
+ assert_eq!(
+ ws.first_cwd(),
+ Some(PathBuf::from("/work")),
+ "the layout survives; only the claims go"
+ );
+
+ let mut ws = fresh(Some("daemon-a"));
+ assert_eq!(ws.forget_stale_pane_ids(Some("daemon-a")), 0);
+ assert_eq!(ws.pane_ids(), vec![7], "same process, ids stay attachable");
+
+ let mut ws = fresh(None);
+ assert_eq!(ws.forget_stale_pane_ids(Some("daemon-b")), 0);
+ assert_eq!(ws.pane_ids(), vec![7], "an old record is not judged");
+
+ let mut ws = fresh(Some("daemon-a"));
+ assert_eq!(ws.forget_stale_pane_ids(None), 0);
+ assert_eq!(ws.pane_ids(), vec![7], "an unknown daemon is not judged");
+ }
+
+ /// Unlike a duplicate claim, a stale-instance claim keeps its agent resume:
+ /// the daemon those panes lived in is gone, nothing else runs the
+ /// conversation, and the fresh shell resuming it is the feature working.
+ #[test]
+ fn stale_instance_keeps_the_agent_resume() {
+ let mut ws = workspace(vec![tab(
+ SessionPane::Leaf {
+ cwd: Some(PathBuf::from("/work")),
+ pane_id: Some(7),
+ ssh_spec: None,
+ agent: Some(crate::core::cli_agent::CLIAgent::Claude),
+ agent_session_id: Some("sid".into()),
+ agent_launch_argv: None,
+ },
+ None,
+ )]);
+ ws.daemon_instance = Some("daemon-a".into());
+ assert_eq!(ws.forget_stale_pane_ids(Some("daemon-b")), 1);
+ match &ws.session.tabs[0].pane {
+ SessionPane::Leaf {
+ pane_id,
+ agent_session_id,
+ ..
+ } => {
+ assert!(pane_id.is_none());
+ assert_eq!(agent_session_id.as_deref(), Some("sid"));
+ }
+ SessionPane::Split { .. } => panic!("leaf stays a leaf"),
+ }
+ }
+
#[test]
fn display_name_prefers_user_name_then_repo_then_cwd() {
// No name, no repo group: fall back to the first leaf's directory.
@@ -1199,6 +1402,71 @@ mod tests {
);
}
+ /// The duplicate claim loses its agent resume along with its pane id.
+ /// Restore's spawn-fresh path auto-types the agent's resume command, and
+ /// the winning workspace's pane is still *running* that agent — a loser
+ /// that kept `agent_session_id` would come back as a second process on
+ /// the same conversation (double `claude --resume `, both live).
+ #[test]
+ fn dedupe_pane_ids_disarms_the_duplicate_claims_agent_resume() {
+ let agent_leaf = |pane_id| SessionPane::Leaf {
+ cwd: Some(PathBuf::from("/work")),
+ pane_id: Some(pane_id),
+ ssh_spec: None,
+ agent: Some(crate::core::cli_agent::CLIAgent::Claude),
+ agent_session_id: Some("362f9261".into()),
+ agent_launch_argv: Some(vec!["claude".into(), "--continue".into()]),
+ };
+ let mut stale = workspace(vec![tab(agent_leaf(5), None)]);
+ stale.last_active = 100;
+ let mut fresh = workspace(vec![tab(agent_leaf(5), None)]);
+ fresh.last_active = 200;
+ let (stale_id, fresh_id) = (stale.id, fresh.id);
+
+ let mut all = Workspaces {
+ active: Some(fresh_id),
+ workspaces: vec![stale, fresh],
+ };
+ assert_eq!(all.dedupe_pane_ids(), 1);
+
+ let loser = &all.get(stale_id).unwrap().session.tabs[0].pane;
+ match loser {
+ SessionPane::Leaf {
+ pane_id,
+ cwd,
+ agent_session_id,
+ agent_launch_argv,
+ ..
+ } => {
+ assert!(pane_id.is_none());
+ assert_eq!(
+ cwd.as_deref(),
+ Some(std::path::Path::new("/work")),
+ "the layout survives — only the claim and its resume go"
+ );
+ assert!(
+ agent_session_id.is_none(),
+ "no second resume of one conversation"
+ );
+ assert!(agent_launch_argv.is_none());
+ }
+ SessionPane::Split { .. } => panic!("the leaf must survive as a leaf"),
+ }
+
+ // The winner is untouched: its pane is the one actually running the agent.
+ match &all.get(fresh_id).unwrap().session.tabs[0].pane {
+ SessionPane::Leaf {
+ pane_id,
+ agent_session_id,
+ ..
+ } => {
+ assert_eq!(*pane_id, Some(5));
+ assert_eq!(agent_session_id.as_deref(), Some("362f9261"));
+ }
+ SessionPane::Split { .. } => panic!("the leaf must survive as a leaf"),
+ }
+ }
+
/// A pane id is only unique within one daemon, so the same number on two
/// machines is not a collision. Deduping globally would make the remote
/// workspace forfeit a claim on a pane that is alive on its own box —
@@ -1383,7 +1651,7 @@ mod tests {
assert_eq!(loaded.workspaces[0].host_id(), crate::host::HostId::LOCAL);
}
- /// The four key formats of contract §4.2, verbatim. These strings are a
+ /// The four key formats of the connection key, verbatim. These strings are a
/// wire contract in all but name: change one and every workspace on that
/// machine gets a different `HostId` than the connection pool minted.
#[test]
@@ -1540,7 +1808,7 @@ mod tests {
assert_eq!(host.target.connection_key(), "ssh-direct:me@box.local:2222");
}
- /// Every `Workspace` field belongs to exactly one side of design §10's
+ /// Every `Workspace` field belongs to exactly one side of the storage
/// split. A new field that is in neither list would be silently dropped by
/// `to_remote_json` and lost on the next pull, which is data loss that no
/// other test would notice.
@@ -1560,6 +1828,9 @@ mod tests {
},
WorkspaceId::new(),
));
+ // Every skip-when-`None` field must be populated here, or it never
+ // serializes and this census can't see it.
+ ws.daemon_instance = Some("daemon-uuid".into());
let value = serde_json::to_value(&ws).unwrap();
let mut present: Vec = value
diff --git a/crates/tty7-core/src/core/shells.rs b/crates/tty7-core/src/core/shells.rs
index 581023b2..d39be9db 100644
--- a/crates/tty7-core/src/core/shells.rs
+++ b/crates/tty7-core/src/core/shells.rs
@@ -26,10 +26,16 @@ use std::path::Path;
#[cfg(windows)]
use std::path::PathBuf;
+use serde::{Deserialize, Serialize};
+
/// One launchable shell surfaced in the new-tab dropdown. `program` + `args`
/// have the same shape as `config::ShellConfig` / `protocol::ShellSpec`: a
/// bare name resolved via `PATH` or an absolute path, plus launch arguments.
-#[derive(Debug, Clone, PartialEq, Eq)]
+///
+/// Serializable because the dropdown of a **remote** workspace's window lists
+/// the shells of the machine that workspace lives on, not this one's: the list
+/// crosses the control dialect as [`ShellInventory`].
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DetectedShell {
/// Human-readable menu label, e.g. `zsh`, `PowerShell 7`, `WSL · Ubuntu`.
pub label: String,
@@ -47,6 +53,37 @@ impl DetectedShell {
}
}
+/// What one machine can launch: its shells, plus which of them a plain new tab
+/// lands on. The unit the new-tab dropdown is built from.
+///
+/// Both halves have to come from the *same* machine. A remote workspace's
+/// window that listed this computer's shells would offer `/bin/zsh` on a box
+/// whose zsh is at `/usr/bin/zsh` — a picker whose every entry fails to spawn.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ShellInventory {
+ pub shells: Vec,
+ /// Short name of the shell a *default* spawn resolves to (`zsh`,
+ /// `PowerShell 7`), for the menu's `default` tag.
+ pub default_name: String,
+}
+
+/// This machine's [`ShellInventory`], honoring the `shell` override in the
+/// config file *this process* reads.
+///
+/// The config lookup goes through [`crate::core::config::shell_command`] rather
+/// than a GPUI global on purpose: the remote `tty7-server` answers this on the
+/// far side of an SSH connection with no GUI in the process, and the override
+/// that matters there is the one in *its* `config.json`.
+///
+/// Runs filesystem probes — call off the UI thread.
+pub fn inventory() -> ShellInventory {
+ let configured = crate::core::config::shell_command();
+ ShellInventory {
+ shells: detect_shells(),
+ default_name: default_shell_name(configured.as_ref().map(|(p, _)| p.as_str())),
+ }
+}
+
/// Enumerate the shells installed on this machine, best-effort. Order is
/// meaningful: the entry most likely to be the user's default comes first.
/// Runs filesystem probes (and `wsl.exe` on Windows) — call off the UI thread.
diff --git a/crates/tty7-core/src/core/workspace_store.rs b/crates/tty7-core/src/core/workspace_store.rs
index 6221695a..c4ad6f11 100644
--- a/crates/tty7-core/src/core/workspace_store.rs
+++ b/crates/tty7-core/src/core/workspace_store.rs
@@ -1,4 +1,4 @@
-//! The **remote** side of design §10's storage split: the machine's own
+//! The **remote** side of the storage split: the machine's own
//! `~/.local/share/tty7/workspaces.json`, and the one writer to it.
//!
//! # Which half of the split this is
@@ -17,7 +17,7 @@
//! [`Workspace`](crate::core::session::Workspace). The server is a store, not a
//! participant: the client owns the schema, and a client newer than the server
//! it is talking to is the *normal* case (the server is installed once and then
-//! left alone for months, §12's auto-install notwithstanding). Parsing here
+//! left alone for months, auto-install notwithstanding). Parsing here
//! would mean a field the server has never heard of is dropped on the next
//! write — silent data loss whose only symptom is a setting that will not
//! stick.
@@ -96,7 +96,7 @@ const MAX_ID_BYTES: usize = 128;
/// Who is currently attached to a workspace.
///
-/// **Data only.** Design §10's takeover — push `Preempted { by }` to the old
+/// **Data only.** The takeover — push `Preempted { by }` to the old
/// session, close its streams, offer a [抢回] button — is M6's, and none of it
/// is here. What is here is the record that machinery needs to exist before it
/// can be written: the random token that tells two connections from the same
@@ -643,7 +643,7 @@ fn quarantine(path: &Path) {
/// |---|---|---|
/// | 1 | `$TTY7_DATA_DIR` | Explicit wins; how tests and a second server get their own file |
/// | 2 | `$XDG_DATA_HOME/tty7` | The location the design names, spelled the way XDG spells it |
-/// | 3 | `$HOME/.local/share/tty7` | No `XDG_DATA_HOME` — the literal path in design §10 |
+/// | 3 | `$HOME/.local/share/tty7` | No `XDG_DATA_HOME` — the literal fallback path |
///
/// Deliberately **not** under the config dir. `session.json` there is the
/// *client's* view state, and a box that is both someone's laptop and someone
diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs
index 2c8fc6b4..55e62a78 100644
--- a/crates/tty7-core/src/daemon/control.rs
+++ b/crates/tty7-core/src/daemon/control.rs
@@ -71,7 +71,7 @@ use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{RecvTimeoutError, SyncSender, sync_channel};
-use std::sync::{Arc, Condvar, Mutex};
+use std::sync::{Arc, Condvar, Mutex, OnceLock};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
@@ -82,7 +82,44 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame};
/// independent of [`crate::daemon::protocol::PROTOCOL_VERSION`]: the pane
/// protocol and the control dialect evolve on separate clocks, and a remote
/// `tty7-server` speaks control without necessarily serving panes at all.
-pub const CONTROL_VERSION: u32 = 1;
+///
+/// Bump this when a new [`ControlRequest`] / [`ReplyOk`] variant lands. That is
+/// stricter than the pane protocol's rule, and deliberately so: these enums have
+/// no `#[serde(other)]` fallback either, but the consequence here is worse —
+/// [`crate::daemon::install`] decides whether to *upgrade the remote binary* by
+/// comparing dialect numbers, so a capability that doesn't move the number is a
+/// capability the far machine never gets. A [`feature`] string is the right
+/// answer only for something two current servers can genuinely disagree about
+/// (the workspace store, which depends on how the server was started); "this
+/// build knows the request and older ones don't" is what the number is for.
+///
+/// ## History
+///
+/// - **v2** — [`ControlRequest::Shells`], which backs a remote window's new-tab
+/// dropdown. Not a `feature` string: every server from this build on answers
+/// it, so the only thing a capability bit would have bought is that a machine
+/// running an older server keeps running it forever, silently serving an empty
+/// menu. The bump makes `RemoteProtocol::serves` refuse to adopt that server
+/// and install this build's instead, which is the actual fix.
+/// - **v1** — the dialect at the time remote workspaces landed.
+pub const CONTROL_VERSION: u32 = 2;
+
+/// This process's identity as a control server, minted once on first use.
+///
+/// Answers "am I still talking to the same server?" — the question no other
+/// field in [`ControlHelloOk`] can answer, because `build` and both version
+/// numbers survive a restart unchanged (the remote's binary is replaced in
+/// place, keeping its name). A client that reconnects and sees a different value
+/// here *knows* every `pane_id` it holds names a pane in a process that no
+/// longer exists.
+///
+/// Per **process**, not per connection: a server serves many connections and
+/// they must all report the same instance, or the client would read every new
+/// connection as a restart.
+pub fn server_instance() -> &'static str {
+ static INSTANCE: OnceLock = OnceLock::new();
+ INSTANCE.get_or_init(|| uuid::Uuid::new_v4().to_string())
+}
/// Paths coalesced into one [`ControlEvent::Watch`] window before the server
/// gives up on precision and sends [`ControlEvent::WatchOverflow`] instead,
@@ -168,6 +205,10 @@ pub mod feature {
// mistranslation rather than a compile error.
pub use crate::host::{Entry, MTime, Meta, Output, SearchHit};
+// Same rule for the machine's shell inventory: the dropdown's own type crosses
+// the wire, not a wire-only copy of it.
+pub use crate::core::shells::{DetectedShell, ShellInventory};
+
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
@@ -267,6 +308,12 @@ pub enum ControlRequest {
args: Vec,
},
+ // ----- machine inventory -------------------------------------------------
+ /// The shells installed on the server, for the new-tab dropdown of a window
+ /// bound to it. Answered by every server speaking [`CONTROL_VERSION`] ≥ 2;
+ /// an older one is replaced rather than asked (see there).
+ Shells,
+
// ----- watch ------------------------------------------------------------
/// Open a subscription; the server answers with a [`ReplyOk::WatchId`].
WatchOpen {
@@ -294,7 +341,7 @@ pub enum ControlRequest {
id: String,
},
- // ----- attachment (M6's takeover, design §10) ---------------------------
+ // ----- attachment (M6's takeover) ---------------------------------------
/// Claim a workspace for this connection's session, taking it over from
/// whoever held it. The server answers
/// [`ReplyOk::Attached`] and pushes [`ControlEvent::Preempted`] to the
@@ -327,7 +374,7 @@ impl ControlRequest {
/// conservative timeout would make the fast paths feel broken; a single
/// aggressive one would break the slow paths.
///
- /// A timeout **never drops the connection** (§6.8): the request fails with
+ /// A timeout **never drops the connection**: the request fails with
/// `TimedOut`, a [`kind::CANCEL`] goes out, and every other in-flight
/// request is untouched.
pub fn deadline(&self) -> Duration {
@@ -347,6 +394,10 @@ impl ControlRequest {
Duration::from_secs(10)
}
Git { .. } | Search { .. } => Duration::from_secs(20),
+ // Filesystem probes on Unix, but on Windows the WSL enumeration
+ // spawns `wsl.exe -l -q`, which is slow enough to deserve the same
+ // budget as git.
+ Shells => Duration::from_secs(20),
WorkspaceList | WorkspaceGet { .. } | WorkspacePut { .. } | WorkspaceDelete { .. } => {
Duration::from_secs(10)
}
@@ -412,11 +463,13 @@ pub enum ReplyOk {
Hits(Vec),
Output(Output),
WatchId(u64),
+ /// [`ControlRequest::Shells`]: what that machine can launch.
+ Shells(ShellInventory),
/// The workspace store's payload (M5).
Json(serde_json::Value),
/// [`ControlRequest::WorkspaceAttach`] succeeded. `took_over_from` names the
/// machine whose session was displaced, so the client that *did* the taking
- /// can say so — design §10 only specifies the notice going the other way,
+ /// can say so — only the notice going the other way is specified,
/// but a takeover the new client cannot see is one the user cannot explain.
Attached {
took_over_from: Option,
@@ -551,7 +604,7 @@ pub enum ControlEvent {
pane_id: u64,
json: serde_json::Value,
},
- /// Design §10's takeover: someone else attached to `workspace`, so this
+ /// The takeover: someone else attached to `workspace`, so this
/// session no longer holds it.
///
/// **`workspace` is not redundant.** One control connection carries a whole
@@ -658,6 +711,23 @@ pub struct ControlHelloOk {
/// Capability bits; see [`feature`].
#[serde(default)]
pub features: Vec,
+ /// Which *process* answered — see [`server_instance`].
+ ///
+ /// The one field here that changes without anything else changing. `build`
+ /// is the same across a restart, and so are both version numbers, so before
+ /// this a client that came back to a machine had no way to tell "the link
+ /// blinked" from "the server is a different process now and every pane it
+ /// held is gone". That question decides whether a reconnect re-attaches or
+ /// rebuilds, and guessing it wrong either throws away live shells or leaves
+ /// dead ones on screen.
+ ///
+ /// `#[serde(default)]` for the same reason every other added field has it,
+ /// though nothing can currently send an empty one: control v2 is the floor
+ /// and this landed with it. An empty value therefore means *unknown*, never
+ /// "a server that restarted" — a client that cannot tell must not act as if
+ /// it could.
+ #[serde(default)]
+ pub instance: String,
}
impl ControlHelloOk {
@@ -1019,7 +1089,7 @@ pub type EventSink = Box;
/// `shutdown`, a child process has `kill`, an SSH channel has `close` — hence
/// this one-method abstraction rather than a bound on the stream type.
///
-/// **Note for the server side** (contract §7.4's `Duplex`): the same problem
+/// **Note for the server side** (the `Duplex`): the same problem
/// exists there in mirror image, so `Duplex` will want the same capability.
/// Aligning is a matter of `Duplex` either requiring `LinkShutdown` as a
/// supertrait or exposing an equivalent method; this trait is deliberately
@@ -1218,7 +1288,7 @@ impl ControlClient {
/// Issue a request and block until its reply, `deadline` elapses, or the
/// link drops.
///
- /// Blocking is deliberate (contract §1): `Host` is a blocking, object-safe
+ /// Blocking is deliberate: `Host` is a blocking, object-safe
/// trait, and every caller is already on a background thread. Blocking here
/// blocks exactly one of them.
pub fn call(&self, req: ControlRequest) -> io::Result {
@@ -1566,6 +1636,7 @@ mod tests {
separator: '/',
home: "/home/me".into(),
features: Vec::new(),
+ instance: "test-instance".into(),
},
shutdown: None,
reader_done: Mutex::new(false),
@@ -1864,6 +1935,28 @@ mod tests {
}
}
+ /// One id for the whole process. A per-connection id would make every
+ /// reconnect look like a restart, which is the failure this whole mechanism
+ /// exists to avoid — in the *expensive* direction, since the client answers
+ /// a restart by rebuilding the window.
+ #[test]
+ fn the_server_instance_is_one_value_per_process() {
+ let first = server_instance();
+ assert!(!first.is_empty(), "an empty instance means \"unknown\"");
+ assert_eq!(first, server_instance());
+ }
+
+ /// A client on an older v2 build decodes a hello that has no `instance` as
+ /// "unknown" rather than failing the whole handshake.
+ #[test]
+ fn a_hello_without_an_instance_still_decodes() {
+ let json = r#"{"control_version":2,"protocol_version":3,"build":"26.7.6",
+ "separator":"/","home":"/home/me"}"#;
+ let ok: ControlHelloOk = serde_json::from_str(json).expect("decodes without instance");
+ assert_eq!(ok.instance, "");
+ assert!(ok.features.is_empty());
+ }
+
fn hello_ok() -> ControlHelloOk {
ControlHelloOk {
control_version: CONTROL_VERSION,
@@ -1872,6 +1965,7 @@ mod tests {
separator: '/',
home: "/home/me".into(),
features: vec![feature::CONTROL.into(), feature::HOST_RPC.into()],
+ instance: "test-instance".into(),
}
}
diff --git a/crates/tty7-core/src/daemon/install/asset.rs b/crates/tty7-core/src/daemon/install/asset.rs
index 8828a95b..adaa30e3 100644
--- a/crates/tty7-core/src/daemon/install/asset.rs
+++ b/crates/tty7-core/src/daemon/install/asset.rs
@@ -2,11 +2,10 @@
//! release tag → download URL, and the remote paths a server binary lives at.
//!
//! Everything here is a total function of its arguments — no network, no SFTP, no
-//! clock — which is the point: [`docs/remote-server-assets.md`] is a *literal*
-//! contract with the release workflow, and a contract is only worth having if
-//! both sides can be tested without standing up the other one.
-//!
-//! [`docs/remote-server-assets.md`]: ../../../../../docs/remote-server-assets.md
+//! clock — which is the point: the asset naming here is a *literal* contract
+//! with the release workflow (`.github/workflows/release.yml`), and a contract
+//! is only worth having if both sides can be tested without standing up the
+//! other one.
use std::fmt;
@@ -19,14 +18,13 @@ pub const CHECKSUMS_ASSET: &str = "checksums.txt";
/// Where release assets are downloaded from. The tag and asset name are appended
/// (`{RELEASE_BASE}/{tag}/{asset}`); HTTPS to github.com is the trust anchor for
-/// the checksum file itself (§16).
+/// the checksum file itself.
pub const RELEASE_BASE: &str = "https://github.com/l0ng-ai/tty7/releases/download";
/// The `XDG_DATA_HOME`-shaped directory tty7 owns on a remote machine, relative
/// to `$HOME`. Split into components because the installer has to `mkdir` each
/// level (SFTP has no `mkdir -p`) and because joining is `/`-only regardless of
-/// the *client's* OS — a Windows client must not produce `.local\share`
-/// (contract §4.3).
+/// the *client's* OS — a Windows client must not produce `.local\share`.
pub const INSTALL_DIR_COMPONENTS: [&str; 4] = [".local", "share", "tty7", "bin"];
/// Why a machine cannot be served a `tty7-server`.
@@ -89,7 +87,7 @@ impl std::error::Error for UnsupportedTarget {}
/// that dies with `Exec format error` at first exec — an error with no visible
/// connection to the architecture detection that caused it, on a machine the user
/// may not be able to inspect. An unknown machine string is a clean, explainable
-/// refusal that names itself (`docs/remote-server-assets.md`).
+/// refusal that names itself.
///
/// `amd64` / `arm64` are accepted alongside the values Linux actually reports
/// because some container images and BSD-flavoured userlands normalise to them.
@@ -154,7 +152,7 @@ pub fn download_url(tag: &str, asset: &str) -> String {
///
/// Built with explicit `/` joins from an absolute `$HOME` the remote resolved for
/// us (SFTP does not expand `~`, and `PathBuf::join` would emit `\` on a Windows
-/// client — contract §4.3).
+/// client).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePaths {
/// `$HOME/.local/share/tty7/bin`.
@@ -199,6 +197,26 @@ pub fn binary_name(version: &str) -> String {
format!("tty7-server-{version}")
}
+/// [`RemotePaths`] pointing at a binary that is **already on the machine**,
+/// found rather than named — the server a connect adopted because it speaks our
+/// dialects (`Installer::adoptable_running_server`).
+///
+/// `binary` is the path as the remote reported it, verbatim: it is what the
+/// transport must connect to, and rebuilding it from a version parsed out of the
+/// filename would turn a binary installed somewhere unexpected into a path that
+/// does not exist.
+///
+/// `temp` and `dir_chain` still describe *our* install location, because that is
+/// where a later install would write. Nothing writes anything on the adoption
+/// path, so they are unused there; keeping them well-formed means a caller that
+/// falls back to installing does not need a second `RemotePaths`.
+pub fn remote_paths_for_binary(home: &str, binary: &str) -> RemotePaths {
+ let version = version_from_path(binary);
+ let mut paths = remote_paths(home, version.as_deref().unwrap_or("unknown"));
+ paths.binary = binary.to_string();
+ paths
+}
+
/// The version encoded in an installed binary's *path*, if it is one of ours.
///
/// This is how the running daemon's build is identified without asking it: the
@@ -220,7 +238,7 @@ mod tests {
use super::*;
/// The contract's mapping table, row for row. This test *is* the client half
- /// of `docs/remote-server-assets.md`: if the release workflow ever renames an
+ /// of the asset naming contract: if the release workflow ever renames an
/// asset, this is where the two sides stop agreeing.
#[test]
fn uname_maps_to_the_published_assets() {
diff --git a/crates/tty7-core/src/daemon/install/checksums.rs b/crates/tty7-core/src/daemon/install/checksums.rs
index 1699a55a..8a9960a9 100644
--- a/crates/tty7-core/src/daemon/install/checksums.rs
+++ b/crates/tty7-core/src/daemon/install/checksums.rs
@@ -1,4 +1,4 @@
-//! `checksums.txt` parsing and asset verification (§16).
+//! `checksums.txt` parsing and asset verification.
//!
//! The release publishes one GNU coreutils `sha256sum`-format manifest covering
//! every asset. HTTPS to github.com is the trust anchor — the manifest is not
@@ -18,7 +18,7 @@ use sha2::{Digest as _, Sha256};
pub type Digest = [u8; 32];
/// Why an asset failed verification. Every variant aborts the install; none of
-/// them is retried, and there is no unverified fallback (§17).
+/// them is retried, and there is no unverified fallback.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChecksumError {
/// The manifest has no line for this asset. Either the release is
@@ -80,7 +80,7 @@ pub fn hex(digest: &Digest) -> String {
})
}
-/// Parse 64 hex characters into a digest. Case-insensitive (§16 step 3); any
+/// Parse 64 hex characters into a digest. Case-insensitive; any
/// other length or a non-hex character is a parse failure.
fn parse_hex(s: &str) -> Option {
if s.len() != 64 {
@@ -95,7 +95,7 @@ fn parse_hex(s: &str) -> Option {
/// The digest `manifest` records for `asset`.
///
-/// **The filename field is matched whole, never by substring** (§16 step 2).
+/// **The filename field is matched whole, never by substring.**
/// `tty7-server-x86_64-unknown-linux-musl` happens not to be a substring of any
/// other asset today, but that is an accident of the current release contents,
/// not a property anyone maintains — and a substring match that drifted would
@@ -165,7 +165,7 @@ mod tests {
verify(&manifest, ASSET_X86_64, bytes).expect("the published bytes must verify");
}
- /// Uppercase hex in the manifest is still the same digest (§16 step 3).
+ /// Uppercase hex in the manifest is still the same digest.
#[test]
fn digest_comparison_is_case_insensitive() {
let bytes = b"payload".as_slice();
@@ -174,7 +174,7 @@ mod tests {
verify(&manifest, ASSET_X86_64, bytes).expect("case must not matter");
}
- /// **The failure path §18 names.** Bytes that do not match must abort with
+ /// **The failure path.** Bytes that do not match must abort with
/// both digests reported — not retry, not install anyway.
#[test]
fn mismatched_bytes_abort_with_both_digests() {
@@ -255,8 +255,8 @@ mod tests {
}
/// **Whole-field match, not substring.** A manifest carrying a longer name
- /// that *contains* ours must not satisfy the lookup — this is the guard §16
- /// step 2 asks for.
+ /// that *contains* ours must not satisfy the lookup — this is the guard
+ /// whole-field matching exists for.
#[test]
fn filename_matching_is_exact_not_substring() {
let payload = b"decoy".as_slice();
diff --git a/crates/tty7-core/src/daemon/install/download.rs b/crates/tty7-core/src/daemon/install/download.rs
index b4825da0..1a83c1ab 100644
--- a/crates/tty7-core/src/daemon/install/download.rs
+++ b/crates/tty7-core/src/daemon/install/download.rs
@@ -33,6 +33,12 @@ const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
/// buffering it in memory before finding out is not a good trade.
const MAX_ASSET_BYTES: u64 = 128 * 1024 * 1024;
+/// How much body to take per read, and therefore how often progress is
+/// reported: ~130 updates over a 8 MB asset. Large enough that the syscall
+/// overhead stays irrelevant, small enough that a bar moves smoothly rather
+/// than in visible jumps.
+const READ_CHUNK: usize = 64 * 1024;
+
/// Downloads release assets over HTTPS.
pub struct HttpsFetcher {
agent: ureq::Agent,
@@ -52,6 +58,14 @@ impl Default for HttpsFetcher {
impl AssetFetcher for HttpsFetcher {
fn get(&self, url: &str) -> Result, String> {
+ self.get_with_progress(url, &|_, _| {})
+ }
+
+ fn get_with_progress(
+ &self,
+ url: &str,
+ on_progress: &dyn Fn(u64, Option),
+ ) -> Result, String> {
let response = self
.agent
.get(url)
@@ -73,16 +87,36 @@ impl AssetFetcher for HttpsFetcher {
return Err(format!("{url} returned HTTP {status}"));
}
+ // Only a hint: it is what the *server* claims, so it sizes the
+ // allocation and the progress bar but never the ceiling check below.
+ let declared = response
+ .headers()
+ .get("content-length")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|v| v.trim().parse::().ok())
+ .filter(|n| *n <= MAX_ASSET_BYTES);
+
let mut body = response.into_body();
- let mut bytes = Vec::new();
- body.as_reader()
- .take(MAX_ASSET_BYTES + 1)
- .read_to_end(&mut bytes)
- .map_err(|e| describe(url, &e.to_string()))?;
- if bytes.len() as u64 > MAX_ASSET_BYTES {
- return Err(format!(
- "{url} is larger than the {MAX_ASSET_BYTES} byte ceiling for a release asset"
- ));
+ // `take` still caps the read, so a lying (or absent) Content-Length
+ // cannot make this buffer more than the ceiling — one byte over is
+ // enough to detect it, which is why the limit is `+ 1`.
+ let mut reader = body.as_reader().take(MAX_ASSET_BYTES + 1);
+ let mut bytes = Vec::with_capacity(declared.unwrap_or(0) as usize);
+ let mut buf = vec![0u8; READ_CHUNK];
+ loop {
+ let n = reader
+ .read(&mut buf)
+ .map_err(|e| describe(url, &e.to_string()))?;
+ if n == 0 {
+ break;
+ }
+ bytes.extend_from_slice(&buf[..n]);
+ if bytes.len() as u64 > MAX_ASSET_BYTES {
+ return Err(format!(
+ "{url} is larger than the {MAX_ASSET_BYTES} byte ceiling for a release asset"
+ ));
+ }
+ on_progress(bytes.len() as u64, declared);
}
Ok(bytes)
}
@@ -130,7 +164,7 @@ mod tests {
/// of nothing. Asserting real content is what catches that.
/// - **The TLS trust anchor works.** ureq's webpki roots must accept
/// github.com's chain; that HTTPS connection *is* the security model here
- /// (§16 — `checksums.txt` is not separately signed).
+ /// (`checksums.txt` is not separately signed).
///
/// Deliberately a small file rather than a release asset: assets are ~20 MB
/// and this is a correctness check, not a bandwidth test.
diff --git a/crates/tty7-core/src/daemon/install/mod.rs b/crates/tty7-core/src/daemon/install/mod.rs
index fb897797..c7c30954 100644
--- a/crates/tty7-core/src/daemon/install/mod.rs
+++ b/crates/tty7-core/src/daemon/install/mod.rs
@@ -1,5 +1,4 @@
-//! Installing, launching and version-matching `tty7-server` on a remote machine
-//! (design §12, §16, §17).
+//! Installing, launching and version-matching `tty7-server` on a remote machine.
//!
//! The six steps, in order:
//!
@@ -23,7 +22,7 @@
//!
//! ## …except for WSL, which downloads nothing
//!
-//! Design §12's last paragraph: a WSL distro is served the Linux binary the
+//! A WSL distro is served the Linux binary the
//! *Windows client already shipped with*, not one fetched from a release. Both
//! paths meet at [`ServerBinarySource`] — [`ReleaseDownload`] for a real remote,
//! [`wsl::BundledServerBinary`] for a distro on this machine — so steps 2 and
@@ -40,7 +39,7 @@
//!
//! ## Scope
//!
-//! Nothing here uses `sudo` or writes outside `$HOME` (§16). Nothing here opens
+//! Nothing here uses `sudo` or writes outside `$HOME`. Nothing here opens
//! the workspace link either: this module's contract with the transport
//! (`remote_link` / the SSH router) is exactly [`ensure_remote_server`] — call it,
//! and on `Ok` the far end has the right binary installed and a daemon serving.
@@ -72,7 +71,7 @@ pub fn client_version() -> &'static str {
/// 0700 — the *directory* is 0700, which is what actually scopes access, and a
/// 0755 binary matches what every other user-local install looks like.
const BINARY_MODE: u32 = 0o755;
-/// Mode bits for every directory we create (§16: directories 0700).
+/// Mode bits for every directory we create (directories 0700).
const DIR_MODE: u32 = 0o700;
/// How long a freshly launched remote daemon gets to start answering on its
@@ -154,6 +153,22 @@ pub trait RemoteOps: Send + Sync {
fn chmod(&self, path: &str, mode: u32) -> Result<(), String>;
/// Write `bytes` to `path`, truncating anything already there.
fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String>;
+ /// [`put`](Self::put), calling `on_progress(written)` as the write
+ /// advances. Defaulted to plain `put` for the same reason as
+ /// [`AssetFetcher::get_with_progress`]: an in-memory fake writes all of it
+ /// at once and has no intermediate state to report.
+ fn put_with_progress(
+ &self,
+ path: &str,
+ bytes: &[u8],
+ on_progress: &(dyn Fn(u64) + Send + Sync),
+ ) -> Result<(), String> {
+ let result = self.put(path, bytes);
+ if result.is_ok() {
+ on_progress(bytes.len() as u64);
+ }
+ result
+ }
/// Rename `from` over `to`. Same directory, so same filesystem, so atomic.
fn rename(&self, from: &str, to: &str) -> Result<(), String>;
fn remove_file(&self, path: &str) -> Result<(), String>;
@@ -167,6 +182,22 @@ pub trait RemoteOps: Send + Sync {
/// feature).
pub trait AssetFetcher: Send + Sync {
fn get(&self, url: &str) -> Result, String>;
+
+ /// [`get`](Self::get), calling `on_progress(done, total)` as the body
+ /// arrives. `total` is the `Content-Length` when the server sent one.
+ ///
+ /// Defaulted to plain `get` so a fetcher that has nothing useful to say
+ /// mid-transfer — every fake in the tests, and the checksums fetch, which is
+ /// under a kilobyte — implements one method, not two. The real
+ /// [`HttpsFetcher`](download::HttpsFetcher) overrides it.
+ fn get_with_progress(
+ &self,
+ url: &str,
+ on_progress: &dyn Fn(u64, Option),
+ ) -> Result, String> {
+ let _ = on_progress;
+ self.get(url)
+ }
}
/// A verified server binary, and where it came from.
@@ -206,6 +237,21 @@ impl std::fmt::Debug for LoadedBinary {
/// ourselves would verify nothing that the client's own signature did not.
pub trait ServerBinarySource: Send + Sync {
fn load(&self, version: &str, asset: &'static str) -> Result;
+
+ /// [`load`](Self::load), reporting bytes as they arrive.
+ ///
+ /// Defaulted to plain `load` because only one of the three sources has a
+ /// transfer worth watching: [`wsl::BundledServerBinary`] reads a local file
+ /// and is done before a bar could paint.
+ fn load_with_progress(
+ &self,
+ version: &str,
+ asset: &'static str,
+ on_progress: &dyn Fn(u64, Option),
+ ) -> Result {
+ let _ = on_progress;
+ self.load(version, asset)
+ }
}
/// A local binary if [`wsl::BUNDLED_DIR_ENV`] names a directory holding one,
@@ -243,28 +289,53 @@ impl<'a> BundledOrRelease<'a> {
impl ServerBinarySource for BundledOrRelease<'_> {
fn load(&self, version: &str, asset: &'static str) -> Result {
+ self.load_with_progress(version, asset, &|_, _| {})
+ }
+
+ fn load_with_progress(
+ &self,
+ version: &str,
+ asset: &'static str,
+ on_progress: &dyn Fn(u64, Option),
+ ) -> Result {
match &self.bundled {
// A named directory that does *not* hold this asset is an error, not
// a reason to fall back: someone who set the variable meant to
// install from it, and quietly downloading instead would defeat
// whichever of the reasons above they set it for.
Some(bundled) => bundled.load(version, asset),
- None => ReleaseDownload { fetch: self.fetch }.load(version, asset),
+ None => ReleaseDownload { fetch: self.fetch }.load_with_progress(
+ version,
+ asset,
+ on_progress,
+ ),
}
}
}
/// The default source: fetch the release asset and its `checksums.txt` over
/// HTTPS, and verify one against the other before anything is written or the
-/// user is asked (§16, §17).
+/// user is asked.
pub struct ReleaseDownload<'a> {
pub fetch: &'a dyn AssetFetcher,
}
impl ServerBinarySource for ReleaseDownload<'_> {
fn load(&self, version: &str, asset: &'static str) -> Result {
+ self.load_with_progress(version, asset, &|_, _| {})
+ }
+
+ fn load_with_progress(
+ &self,
+ version: &str,
+ asset: &'static str,
+ on_progress: &dyn Fn(u64, Option),
+ ) -> Result {
let tag = asset::release_tag(version);
let manifest_url = asset::download_url(&tag, asset::CHECKSUMS_ASSET);
+ // Not reported: `checksums.txt` is under a kilobyte, and a bar that
+ // jumped to 100% for it before restarting for the real asset would read
+ // as a stall rather than as two files.
let manifest = self
.fetch
.get(&manifest_url)
@@ -280,7 +351,7 @@ impl ServerBinarySource for ReleaseDownload<'_> {
let asset_url = asset::download_url(&tag, asset);
let bytes = self
.fetch
- .get(&asset_url)
+ .get_with_progress(&asset_url, on_progress)
.map_err(|reason| InstallError::Download {
url: asset_url.clone(),
reason,
@@ -295,7 +366,7 @@ impl ServerBinarySource for ReleaseDownload<'_> {
}
// ---------------------------------------------------------------------------
-// Consent (§12, §16) — the decision point M5's UI plugs into.
+// Consent — the decision point M5's UI plugs into.
// ---------------------------------------------------------------------------
/// Everything the user needs to answer "may tty7 write a binary onto this
@@ -332,7 +403,7 @@ pub enum InstallDecision {
}
/// Asks the user whether to write a server binary onto a machine for the first
-/// time (§12: "往别人机器上写二进制值得问一次").
+/// time ("往别人机器上写二进制值得问一次").
///
/// **Only the first install on a given machine asks.** "First" is decided from
/// evidence on the remote itself — an empty (or absent)
@@ -418,7 +489,219 @@ pub fn install_confirm() -> Arc {
}
// ---------------------------------------------------------------------------
-// Version negotiation (§12, mirroring `spawn::ensure_running`).
+// Install progress
+// ---------------------------------------------------------------------------
+
+/// How far a first install has got, in bytes.
+///
+/// Only the two steps that take real time appear. `uname`, `stat`, `mkdir`,
+/// `chmod` and the rename are single round trips: a phase for each would flicker
+/// past faster than it could be read, and a progress display that spends most of
+/// its life on two steps is better off saying which of the two it is on.
+///
+/// The byte counts are of the *asset*, so `Uploading` restarts at zero rather
+/// than continuing where `Downloading` left off. Two bars' worth of work shown
+/// as one 0-200% sweep would be worse; two named phases each running 0-100% is
+/// what the user is actually waiting through.
+///
+/// Serialisable because the install runs in the **daemon** and the user is in
+/// the GUI: this crosses the routed connection as a
+/// [`RoutePrompt::InstallProgress`](crate::daemon::router::RoutePrompt) frame.
+/// Unlike [`InstallRequest`] it needs no wire twin — every field is already a
+/// plain number.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum InstallPhase {
+ /// Fetching the asset onto *this* machine over HTTPS.
+ ///
+ /// `total` is `None` when the server sent no `Content-Length` — rare for a
+ /// release asset, but a chunked response is legal and a progress sink that
+ /// cannot represent "unknown total" would have to invent one.
+ Downloading { done: u64, total: Option },
+ /// Writing the verified bytes to the remote over SFTP. `total` is exact:
+ /// the bytes are in memory by now.
+ Uploading { done: u64, total: u64 },
+}
+
+impl InstallPhase {
+ /// Fraction complete in `0.0..=1.0`, or `None` when the total is unknown.
+ pub fn fraction(&self) -> Option {
+ let (done, total) = match *self {
+ InstallPhase::Downloading { done, total } => (done, total?),
+ InstallPhase::Uploading { done, total } => (done, total),
+ };
+ if total == 0 {
+ return None;
+ }
+ Some((done as f32 / total as f32).clamp(0.0, 1.0))
+ }
+}
+
+/// Watches an install go by (the first install writes ~8 MB across
+/// two network hops, and a client that says only "connecting…" for the length of
+/// both is indistinguishable from one that has hung).
+///
+/// **Reports are frequent and must be cheap.** One arrives per transfer chunk —
+/// hundreds over a single install — so an implementation stores the latest and
+/// returns. It must not block, lock anything a UI thread holds, or do IO: the
+/// thread calling this is the one moving the bytes.
+///
+/// **Nothing here affects the install.** It is a side channel, which is why
+/// [`Installer`] reaches for it through the global rather than carrying it as a
+/// field the way it carries [`InstallConfirm`] — a sink cannot change what gets
+/// written, so it does not belong in the constructor every caller and fake has
+/// to satisfy.
+///
+/// The default ([`SilentProgress`]) drops everything, which is the right
+/// behaviour for a headless daemon with nobody watching.
+pub trait InstallProgress: Send + Sync {
+ /// `host` is the same label [`InstallRequest::host`] carries, so a sink
+ /// serving several machines can tell them apart.
+ fn report(&self, host: &str, phase: InstallPhase);
+}
+
+/// The default: nobody is watching, so nothing is recorded.
+pub struct SilentProgress;
+
+impl InstallProgress for SilentProgress {
+ fn report(&self, _host: &str, _phase: InstallPhase) {}
+}
+
+static PROGRESS: OnceLock>> = OnceLock::new();
+
+fn progress_slot() -> &'static Mutex> {
+ PROGRESS.get_or_init(|| Mutex::new(Arc::new(SilentProgress)))
+}
+
+/// Register the process-wide progress sink. Called once by the GUI at startup;
+/// last call wins.
+pub fn set_install_progress(progress: Arc) {
+ if let Ok(mut slot) = progress_slot().lock() {
+ *slot = progress;
+ }
+}
+
+thread_local! {
+ /// A sink that outranks [`PROGRESS`] for the duration of one call, on one
+ /// thread. See [`with_install_progress`].
+ static SCOPED_PROGRESS: std::cell::RefCell>> =
+ const { std::cell::RefCell::new(None) };
+}
+
+/// Run `f` with `progress` receiving any install it drives, then put the
+/// previous sink back.
+///
+/// The same shape, and the same reason, as [`with_install_confirm`]: in the
+/// daemon the only sink that can reach a user is one bound to a particular
+/// routed connection, and two machines installing at once through a global would
+/// report both machines' bytes to whichever client asked last.
+pub fn with_install_progress(progress: Arc, f: impl FnOnce() -> T) -> T {
+ let previous = SCOPED_PROGRESS.with(|slot| slot.borrow_mut().replace(progress));
+ let out = f();
+ SCOPED_PROGRESS.with(|slot| *slot.borrow_mut() = previous);
+ out
+}
+
+/// The progress sink in force: this thread's scoped one, else the process-wide
+/// one, else [`SilentProgress`].
+pub fn install_progress() -> Arc {
+ if let Some(scoped) = SCOPED_PROGRESS.with(|slot| slot.borrow().clone()) {
+ return scoped;
+ }
+ progress_slot()
+ .lock()
+ .map(|slot| slot.clone())
+ .unwrap_or_else(|_| Arc::new(SilentProgress))
+}
+
+// ---------------------------------------------------------------------------
+// Asking a server binary what it speaks
+// ---------------------------------------------------------------------------
+
+/// The flag that makes a `tty7-server` print [`RemoteProtocol`] and exit.
+///
+/// A *file*, not a running daemon: the numbers are compile-time constants, so
+/// this answers "what would this binary speak" without a socket, a handshake, or
+/// anything already being up. That is what lets the installer decide whether to
+/// write 8 MB **before** writing it.
+///
+/// Servers older than this flag print usage to stderr and exit non-zero, which
+/// [`Installer::probe_protocol`] reads as "no opinion" — the same conservative
+/// answer an unreadable `/proc` gets.
+pub const PROTOCOL_FLAG: &str = "--protocol";
+
+/// What a `tty7-server` binary speaks, as it reports itself.
+///
+/// The remote counterpart of [`DaemonVersion`](crate::daemon::protocol::DaemonVersion),
+/// and deliberately the same shape: two dialect numbers that decide
+/// compatibility, plus a build string that decides nothing.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct RemoteProtocol {
+ /// [`crate::daemon::control::CONTROL_VERSION`] — the control dialect, which
+ /// is what a remote *workspace* runs on.
+ pub control: u32,
+ /// [`crate::daemon::protocol::PROTOCOL_VERSION`] — the pane dialect, which
+ /// is what a routed *pane* on that machine runs on.
+ pub protocol: u32,
+ /// `CARGO_PKG_VERSION`. **Display only** — same rule as
+ /// [`DaemonVersion::build`](crate::daemon::protocol::DaemonVersion::build)
+ /// and [`ControlHelloOk::build`](crate::daemon::control::ControlHelloOk::build).
+ /// Two builds that speak the same numbers are interchangeable no matter what
+ /// their version strings say, and treating a version string as a dialect is
+ /// exactly the bug this type exists to end.
+ pub build: String,
+}
+
+impl RemoteProtocol {
+ /// What this client speaks.
+ pub fn of_this_build() -> RemoteProtocol {
+ RemoteProtocol {
+ control: crate::daemon::control::CONTROL_VERSION,
+ protocol: crate::daemon::protocol::PROTOCOL_VERSION,
+ build: client_version().to_string(),
+ }
+ }
+
+ /// Whether a server speaking `self` can serve a client speaking `other`.
+ ///
+ /// **Both numbers, both exactly equal** — the same judgement
+ /// `spawn::ensure_running` makes locally (`v.protocol == PROTOCOL_VERSION`),
+ /// applied to both dialects because a remote workspace uses both: control
+ /// for the workspace itself, pane for every terminal in it.
+ ///
+ /// Equality rather than `>=` deliberately. A newer server is not
+ /// automatically able to speak an older client's dialect, and guessing that
+ /// it can turns a clean prompt into a wire error halfway through a session.
+ pub fn serves(&self, other: &RemoteProtocol) -> bool {
+ self.control == other.control && self.protocol == other.protocol
+ }
+
+ /// The single line a server prints for [`PROTOCOL_FLAG`].
+ ///
+ /// Paired with [`parse`](Self::parse) here rather than left to each side's
+ /// own `serde_json` call: the writer is `tty7-server` and the reader is the
+ /// client, they ship separately and meet over SSH, and one shared function
+ /// is what stops the format drifting between them.
+ pub fn to_line(&self) -> String {
+ // Infallible in practice — three plain fields — and a server that could
+ // not describe itself should still exit cleanly rather than make the
+ // caller handle an error that cannot happen.
+ serde_json::to_string(self).unwrap_or_default()
+ }
+
+ /// Parse one from a probe's stdout.
+ ///
+ /// Takes the **last** non-blank line: a login shell that prints a banner
+ /// from `.bashrc` would otherwise poison an otherwise fine answer, and the
+ /// server writes its line last because it writes it at exit.
+ pub fn parse(stdout: &str) -> Option {
+ let line = stdout.lines().rev().find(|l| !l.trim().is_empty())?;
+ serde_json::from_str(line.trim()).ok()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Version negotiation (mirroring `spawn::ensure_running`).
// ---------------------------------------------------------------------------
/// A remote daemon that is serving a machine at a *different* build than the
@@ -498,7 +781,7 @@ fn record_mismatch(entry: MismatchedRemoteDaemon) {
/// The relay's landing point (`daemon::router`): the daemon finds the mismatch,
/// the GUI is the process with the keep-or-restart prompt, and
/// [`take_mismatched_remote_daemons`] only ever reads a local static. Without
-/// this the prompt design §12 specifies could not fire at all.
+/// this the consent prompt could not fire at all.
pub fn record_remote_mismatches(entries: Vec) {
for entry in entries {
record_mismatch(entry);
@@ -516,7 +799,7 @@ pub fn take_mismatched_remote_daemons() -> Vec {
}
// ---------------------------------------------------------------------------
-// Errors (§17: specific, path-bearing, never retried into a different path).
+// Errors (specific, path-bearing, never retried into a different path).
// ---------------------------------------------------------------------------
#[derive(Debug)]
@@ -531,12 +814,11 @@ pub enum InstallError {
/// proxy). Carries the URL, because "which release did it even look for" is
/// the first question.
Download { url: String, reason: String },
- /// sha256 verification failed. Terminal: no retry, no unverified fallback
- /// (§16, §17).
+ /// sha256 verification failed. Terminal: no retry, no unverified fallback.
Checksum(ChecksumError),
/// A WSL install found no bundled Linux server binary in this client's own
/// installation. Terminal, and deliberately **not** downgraded to a
- /// download: design §12 says a WSL distro is served the binary the client
+ /// download: a WSL distro is served the binary the client
/// shipped with, and silently reaching for GitHub instead would turn a
/// packaging bug into an intermittent network failure on someone else's
/// machine. Names every directory that was looked in, because the fix is
@@ -549,7 +831,7 @@ pub enum InstallError {
Declined { host: String, path: String },
/// A write to the remote failed — full disk, read-only home, no permission.
/// Reports the exact path and the server's own reason, and is **not**
- /// retried anywhere else (§17: "不重试,不降级到别的路径").
+ /// retried anywhere else ("不重试,不降级到别的路径").
Write { path: String, reason: String },
/// The daemon would not start, or would not answer after starting.
Launch { reason: String },
@@ -623,8 +905,19 @@ pub struct InstallReport {
pub confirmed: bool,
/// Whether a daemon had to be launched (false when one was already serving).
pub launched: bool,
- /// Set when a daemon of another build is serving this machine.
+ /// Set when a daemon that **cannot serve this client** is on the machine.
+ ///
+ /// A different build is not a mismatch — a different *dialect* is. See
+ /// [`Installer::check_running_build`].
pub mismatch: Option,
+ /// The already-running server this connect adopted instead of installing,
+ /// when its dialects matched ours despite a different build.
+ ///
+ /// `Some` is the case always intended and the implementation
+ /// missed: a 26.7.6 client meeting a 26.7.7 server they both speak. Recorded
+ /// because "we deliberately did not install" is otherwise indistinguishable
+ /// in a log from "we forgot to".
+ pub reused: Option,
}
// ---------------------------------------------------------------------------
@@ -739,6 +1032,7 @@ impl<'a> Installer<'a> {
confirmed: false,
launched: false,
mismatch: None,
+ reused: None,
};
// A file that exists but is not executable is a half-finished install
@@ -746,18 +1040,84 @@ impl<'a> Installer<'a> {
// than launching something the kernel will refuse.
let usable = already.is_some_and(|stat| !stat.is_dir && stat.mode & 0o100 != 0);
if !usable {
- let (confirmed, _) = self.install(asset, &paths)?;
- report.installed = true;
- report.confirmed = confirmed;
+ // --- 3. before writing 8 MB, ask what is already serving ----------
+ //
+ // Version skew is settled by comparing dialects, not
+ // build strings. Only the *running* server is asked: the socket is
+ // singular, so a compatible binary that is merely present on disk
+ // would still have to be started — and starting our own is simpler
+ // and more predictable than adopting a stranger's file.
+ match self.adoptable_running_server()? {
+ Some((exe, spoken)) => {
+ log::info!(
+ "remote {}: adopting the running {} (control {}, protocol {}) \
+ instead of installing {} — same dialects",
+ self.host,
+ spoken.build,
+ spoken.control,
+ spoken.protocol,
+ self.version,
+ );
+ report.paths = asset::remote_paths_for_binary(&home, &exe);
+ report.reused = Some(spoken);
+ }
+ None => {
+ let (confirmed, _) = self.install(asset, &paths)?;
+ report.installed = true;
+ report.confirmed = confirmed;
+ }
+ }
}
// --- 6. make sure a daemon is serving --------------------------------
- let (launched, mismatch) = self.ensure_daemon(&paths)?;
+ let (launched, mismatch) = self.ensure_daemon(&report.paths)?;
report.launched = launched;
report.mismatch = mismatch;
Ok(report)
}
+ /// The running `tty7-server` on this machine, when it speaks our dialects.
+ ///
+ /// `None` covers every reason not to adopt one, and they are deliberately
+ /// indistinguishable to the caller: nothing running, an unreadable `/proc`,
+ /// a binary too old to know [`PROTOCOL_FLAG`], or one that answered with
+ /// dialects we cannot speak. All four mean "install ours", and none of them
+ /// is an error — a machine we cannot interrogate is a machine we install on,
+ /// exactly as before this existed.
+ fn adoptable_running_server(&self) -> Result, InstallError> {
+ let Some(exe) = self.running_server_exe() else {
+ return Ok(None);
+ };
+ let Some(spoken) = self.probe_protocol(&exe) else {
+ return Ok(None);
+ };
+ if !spoken.serves(&RemoteProtocol::of_this_build()) {
+ return Ok(None);
+ }
+ Ok(Some((exe, spoken)))
+ }
+
+ /// Ask a server *binary* what it speaks. `None` if it cannot say.
+ ///
+ /// Cheap by design: one SSH command against a file, no socket and no daemon,
+ /// so it can be asked before deciding whether to transfer anything.
+ fn probe_protocol(&self, exe: &str) -> Option {
+ let cmd = format!("{} {PROTOCOL_FLAG}", shell_quote(exe));
+ let out = self.ops.run(&cmd).ok()?;
+ if !out.success() {
+ // A server older than the flag prints usage and exits non-zero.
+ return None;
+ }
+ RemoteProtocol::parse(&out.stdout)
+ }
+
+ /// The executable path of this user's running `tty7-server`, if any.
+ fn running_server_exe(&self) -> Option {
+ let out = self.ops.run(RUNNING_EXE_COMMAND).ok()?;
+ let exe = out.stdout.trim();
+ (!exe.is_empty()).then(|| exe.to_string())
+ }
+
/// Steps 3–5: download, verify, confirm, upload, publish.
fn install(
&self,
@@ -808,8 +1168,12 @@ impl<'a> Installer<'a> {
// refuses SETSTAT) must not block an install that will otherwise work.
let _ = self.ops.chmod(&paths.bin_dir, DIR_MODE);
+ let sink = install_progress();
+ let total = bytes.len() as u64;
self.ops
- .put(&paths.temp, &bytes)
+ .put_with_progress(&paths.temp, &bytes, &|done| {
+ sink.report(&self.host, InstallPhase::Uploading { done, total });
+ })
.map_err(|reason| InstallError::Write {
path: paths.temp.clone(),
reason,
@@ -847,8 +1211,12 @@ impl<'a> Installer<'a> {
/// Where step 3's bytes come from: the injected source if there is one,
/// otherwise a [`ReleaseDownload`] over the injected fetcher.
fn load_binary(&self, asset: &'static str) -> Result {
+ let sink = install_progress();
+ let on_progress = |done: u64, total: Option| {
+ sink.report(&self.host, InstallPhase::Downloading { done, total });
+ };
if let Some(source) = self.source {
- return source.load(&self.version, asset);
+ return source.load_with_progress(&self.version, asset, &on_progress);
}
let Some(fetch) = self.fetch else {
// Unreachable through either constructor; a plain error rather than
@@ -859,7 +1227,7 @@ impl<'a> Installer<'a> {
reason: "no binary source was configured".to_string(),
});
};
- ReleaseDownload { fetch }.load(&self.version, asset)
+ ReleaseDownload { fetch }.load_with_progress(&self.version, asset, &on_progress)
}
/// Whether tty7 has ever written to this machine, decided from the remote's
@@ -884,7 +1252,7 @@ impl<'a> Installer<'a> {
/// so its exit status *is* the answer, and a socket file a crash left behind
/// reads as "nothing there" rather than as a live server. Nothing here
/// parses a frame — the protocol handshake is end-to-end between the GUI and
- /// the far server (contract §6.9), and a second opinion about the version
+ /// the far server, and a second opinion about the version
/// living down here is exactly the coupling that design forbids.
fn ensure_daemon(
&self,
@@ -930,24 +1298,41 @@ impl<'a> Installer<'a> {
.map_err(|reason| InstallError::Launch { reason })
}
- /// Identify the build of the daemon that is actually serving, and record a
- /// mismatch if it is not ours.
+ /// Identify the daemon that is actually serving, and record a mismatch only
+ /// if it **cannot speak to us**.
///
- /// The install path carries the version by construction, so reading the
- /// running process's executable link answers this for *every* build we have
- /// shipped — including ones older than any handshake we could send them.
- /// Failing to read it is not an error: an unreadable `/proc` means we simply
- /// have no opinion, and no opinion must never be reported as a mismatch.
+ /// **A different build is not a mismatch.** The rule is to compare
+ /// `PROTOCOL_VERSION` and keep an older server that is compatible — the same judgement
+ /// `spawn::ensure_running` makes locally, where a daemon whose `build`
+ /// differs but whose `protocol` matches is reused in silence. Comparing
+ /// version *strings* here is what made a 26.7.6 client prompt about a
+ /// 26.7.7 server it could talk to perfectly well, and made it upload 8 MB to
+ /// a machine that needed nothing.
+ ///
+ /// Failing to read any of it is not an error: an unreadable `/proc`, or a
+ /// server too old to know [`PROTOCOL_FLAG`], means we have no opinion — and
+ /// no opinion must never be reported as a mismatch.
fn check_running_build(&self, paths: &RemotePaths) -> Option {
- let out = self.ops.run(RUNNING_EXE_COMMAND).ok()?;
- let exe = out.stdout.trim();
- if exe.is_empty() {
- return None;
- }
+ let exe = self.running_server_exe()?;
+ let exe = exe.as_str();
let running_version = asset::version_from_path(exe);
if running_version.as_deref() == Some(self.version.as_str()) || exe == paths.binary {
return None;
}
+ // A different build, so ask the only question that decides anything.
+ // An unanswerable probe leaves the old behaviour in place: a server that
+ // predates the flag really might not understand us, and the prompt is
+ // the honest response to not knowing.
+ if self
+ .probe_protocol(exe)
+ .is_some_and(|spoken| spoken.serves(&RemoteProtocol::of_this_build()))
+ {
+ log::info!(
+ "remote {} is served by {exe}, a different build this client speaks to anyway",
+ self.host,
+ );
+ return None;
+ }
let entry = MismatchedRemoteDaemon {
host: self.host.clone(),
running_version,
@@ -1007,7 +1392,7 @@ impl<'a> Installer<'a> {
/// Find the executable path of this user's running `tty7-server`, if any.
///
/// `readlink /proc//exe` is readable only for the caller's own processes,
-/// which is exactly the scope wanted: one `tty7-server` per user (contract §8).
+/// which is exactly the scope wanted: one `tty7-server` per user.
/// `|| true` on the loop keeps a `set -e` login shell from turning "no daemon
/// running" into a failed command.
const RUNNING_EXE_COMMAND: &str = r#"for p in /proc/[0-9]*; do e=$(readlink "$p/exe" 2>/dev/null) || continue; case "$e" in */tty7-server-*) printf '%s' "${e% (deleted)}"; break;; esac; done; true"#;
diff --git a/crates/tty7-core/src/daemon/install/ssh_ops.rs b/crates/tty7-core/src/daemon/install/ssh_ops.rs
index 5ea0adb4..e353fc75 100644
--- a/crates/tty7-core/src/daemon/install/ssh_ops.rs
+++ b/crates/tty7-core/src/daemon/install/ssh_ops.rs
@@ -155,7 +155,16 @@ impl RemoteOps for SshRemoteOps {
}
fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
- SftpManager::global().put_bytes(&self.conn, path, bytes)
+ SftpManager::global().put_bytes(&self.conn, path, bytes, &|_| {})
+ }
+
+ fn put_with_progress(
+ &self,
+ path: &str,
+ bytes: &[u8],
+ on_progress: &(dyn Fn(u64) + Send + Sync),
+ ) -> Result<(), String> {
+ SftpManager::global().put_bytes(&self.conn, path, bytes, on_progress)
}
fn rename(&self, from: &str, to: &str) -> Result<(), String> {
@@ -255,7 +264,7 @@ mod tests {
}
}
- /// And must not swallow the failures §17 requires to be reported: a full
+ /// And must not swallow the failures that have to be reported: a full
/// disk or a read-only home has to surface as an error with a path, never as
/// "the file isn't there, go ahead and install".
#[test]
diff --git a/crates/tty7-core/src/daemon/install/tests.rs b/crates/tty7-core/src/daemon/install/tests.rs
index d9568546..43f224b3 100644
--- a/crates/tty7-core/src/daemon/install/tests.rs
+++ b/crates/tty7-core/src/daemon/install/tests.rs
@@ -1,6 +1,6 @@
//! The install flow, driven end to end against an in-memory remote.
//!
-//! Contract §18 asks for four things by name — `uname` parsing, version path
+//! Four things are asked for by name — `uname` parsing, version path
//! construction, atomic replacement, and the sha256 failure path — and none of
//! them may touch the network. The first two are unit-tested in
//! [`super::asset`] and [`super::checksums`]; the last two need the *whole*
@@ -64,6 +64,10 @@ struct FakeRemote {
/// Whether launching actually starts the fake daemon (false models a binary
/// that dies on exec).
launch_works: bool,
+ /// What each binary answers to `--protocol`, by path. A path that is absent
+ /// models a server too old to know the flag: the probe fails, and the
+ /// installer falls back to having no opinion.
+ speaks: Mutex>,
}
impl FakeRemote {
@@ -85,9 +89,16 @@ impl FakeRemote {
daemon_running: Mutex::new(false),
running_exe: Mutex::new(None),
launch_works: true,
+ speaks: Mutex::new(HashMap::new()),
}
}
+ /// Teach the binary at `exe` to answer `--protocol` with `spoken`.
+ fn speaking(self, exe: &str, spoken: RemoteProtocol) -> Self {
+ self.speaks.lock().unwrap().insert(exe.to_string(), spoken);
+ self
+ }
+
/// A machine tty7 has installed on before (so consent is not re-asked).
fn with_previous_install(self, version: &str) -> Self {
self.preinstall(&format!("{BIN_DIR}/tty7-server-{version}"), 0o755);
@@ -152,6 +163,19 @@ impl RemoteOps for FakeRemote {
if cmd == "uname -sm" {
return ok(&self.uname);
}
+ if let Some(exe) = cmd.strip_suffix(&format!(" {PROTOCOL_FLAG}")) {
+ let exe = exe.trim_matches('\'');
+ return match self.speaks.lock().unwrap().get(exe) {
+ Some(spoken) => ok(&serde_json::to_string(spoken).unwrap()),
+ // What a server older than the flag does: usage on stderr, and
+ // a non-zero status.
+ None => Ok(ExecOutput {
+ status: Some(1),
+ stdout: String::new(),
+ stderr: "tty7-server: nothing to do without --daemon or --stdio".into(),
+ }),
+ };
+ }
if cmd == RUNNING_EXE_COMMAND {
let exe = self.running_exe.lock().unwrap().clone().unwrap_or_default();
return ok(&exe);
@@ -485,7 +509,7 @@ fn the_final_path_is_only_ever_reached_by_renaming_a_ready_temp() {
}
/// The directory chain is created outermost-first (SFTP has no `mkdir -p`) and
-/// the directory that holds the binaries ends up 0700 (§16).
+/// the directory that holds the binaries ends up 0700.
#[test]
fn the_install_directory_is_created_in_order_and_locked_down() {
let remote = FakeRemote::new();
@@ -516,7 +540,7 @@ fn the_install_directory_is_created_in_order_and_locked_down() {
}
// ---------------------------------------------------------------------------
-// sha256 (§16, §17) — the failure path §18 names.
+// sha256 — the failure path.
// ---------------------------------------------------------------------------
/// **A checksum mismatch aborts and writes nothing.** Not a retry, not an
@@ -578,10 +602,10 @@ fn a_release_missing_our_asset_aborts() {
}
// ---------------------------------------------------------------------------
-// Consent (§12).
+// Consent.
// ---------------------------------------------------------------------------
-/// The prompt has to carry everything §12 asks it to say: which path, how big,
+/// The prompt has to carry everything it must say: which path, how big,
/// and where the bytes came from.
#[test]
fn the_confirmation_states_path_size_and_origin() {
@@ -725,7 +749,7 @@ fn a_present_but_unexecutable_binary_is_reinstalled() {
}
// ---------------------------------------------------------------------------
-// Refusals and write failures (§17).
+// Refusals and write failures.
// ---------------------------------------------------------------------------
/// An architecture we do not publish for is refused before anything is
@@ -758,7 +782,7 @@ fn an_unsupported_machine_is_refused_before_any_work() {
}
/// **A failed remote write reports the path and the server's reason, and is not
-/// retried anywhere else** (§17). A full disk must not become "let me try
+/// retried anywhere else**. A full disk must not become "let me try
/// /tmp".
#[test]
fn a_failed_write_names_the_path_and_does_not_fall_back() {
@@ -1162,3 +1186,489 @@ fn the_published_path_is_absolute_and_version_qualified() {
"this is the string `ensure_remote_server` hands the transport"
);
}
+
+// ---------------------------------------------------------------------------
+// Progress (an 8 MB first install must not look like a hang).
+// ---------------------------------------------------------------------------
+
+/// Records every report in order, which is what makes "monotonic" and "reaches
+/// the total" testable — neither is a property of any single report.
+#[derive(Default)]
+struct Reports(Mutex>);
+
+impl InstallProgress for Reports {
+ fn report(&self, host: &str, phase: InstallPhase) {
+ self.0.lock().unwrap().push((host.to_string(), phase));
+ }
+}
+
+impl Reports {
+ fn all(&self) -> Vec<(String, InstallPhase)> {
+ self.0.lock().unwrap().clone()
+ }
+
+ fn phases(&self) -> Vec {
+ self.all().into_iter().map(|(_, phase)| phase).collect()
+ }
+}
+
+/// A release whose asset arrives in pieces, like a real HTTP body.
+struct ChunkedRelease {
+ inner: FakeRelease,
+ chunks: usize,
+}
+
+impl AssetFetcher for ChunkedRelease {
+ fn get(&self, url: &str) -> Result, String> {
+ self.inner.get(url)
+ }
+
+ fn get_with_progress(
+ &self,
+ url: &str,
+ on_progress: &dyn Fn(u64, Option),
+ ) -> Result, String> {
+ let bytes = self.inner.get(url)?;
+ let total = bytes.len() as u64;
+ let step = total.div_ceil(self.chunks as u64).max(1);
+ let mut done = 0;
+ while done < total {
+ done = (done + step).min(total);
+ on_progress(done, Some(total));
+ }
+ Ok(bytes)
+ }
+}
+
+/// **Both halves of the wait are reported, and each one finishes.**
+///
+/// The download and the upload are separate network hops of the same ~8 MB, and
+/// a bar that covered only one of them would sit at 100% through the other —
+/// which is the exact failure this exists to prevent.
+#[test]
+fn an_install_reports_both_transfers_to_completion() {
+ let remote = FakeRemote::new();
+ let release = ChunkedRelease {
+ inner: FakeRelease::new(),
+ chunks: 4,
+ };
+ let user = FakeUser::approving();
+ let reports = Arc::new(Reports::default());
+
+ let report = with_install_progress(reports.clone(), || {
+ Installer::new(&remote, &release, &user, "me@build-box:22")
+ .with_version(VERSION)
+ .with_timeouts(Duration::from_millis(200), Duration::from_millis(10))
+ .run()
+ })
+ .expect("install");
+ assert!(report.installed, "the fake remote started empty");
+
+ let total = SERVER_BYTES.len() as u64;
+ let phases = reports.phases();
+
+ let downloads: Vec<(u64, Option)> = phases
+ .iter()
+ .filter_map(|p| match p {
+ InstallPhase::Downloading { done, total } => Some((*done, *total)),
+ _ => None,
+ })
+ .collect();
+ assert!(
+ downloads.len() > 1,
+ "a chunked body should report more than once: {downloads:?}"
+ );
+ assert_eq!(
+ downloads.last().map(|(done, _)| *done),
+ Some(total),
+ "the download's last report is the whole asset"
+ );
+ assert!(
+ downloads.windows(2).all(|w| w[0].0 <= w[1].0),
+ "a bar that goes backwards reads as a restart: {downloads:?}"
+ );
+
+ let uploads: Vec = phases
+ .iter()
+ .filter_map(|p| match p {
+ InstallPhase::Uploading { done, .. } => Some(*done),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(
+ uploads.last(),
+ Some(&total),
+ "the upload reaches the byte count the consent prompt quoted"
+ );
+
+ // Order matters: the client cannot push bytes it has not fetched, and a UI
+ // that saw them interleaved would have to decide which one to draw.
+ let first_upload = phases
+ .iter()
+ .position(|p| matches!(p, InstallPhase::Uploading { .. }))
+ .expect("an upload");
+ let last_download = phases
+ .iter()
+ .rposition(|p| matches!(p, InstallPhase::Downloading { .. }))
+ .expect("a download");
+ assert!(
+ last_download < first_upload,
+ "downloading finishes before uploading starts: {phases:?}"
+ );
+}
+
+/// **Every report names the machine it is about.**
+///
+/// The GUI keys its progress slots by machine, so a report that arrived with the
+/// wrong label — or an empty one — would paint one box's bytes under another's
+/// name while both were installing.
+#[test]
+fn every_report_carries_the_host() {
+ let remote = FakeRemote::new();
+ let release = ChunkedRelease {
+ inner: FakeRelease::new(),
+ chunks: 3,
+ };
+ let user = FakeUser::approving();
+ let reports = Arc::new(Reports::default());
+
+ with_install_progress(reports.clone(), || {
+ Installer::new(&remote, &release, &user, "me@build-box:22")
+ .with_version(VERSION)
+ .with_timeouts(Duration::from_millis(200), Duration::from_millis(10))
+ .run()
+ })
+ .expect("install");
+
+ let hosts: Vec = reports.all().into_iter().map(|(host, _)| host).collect();
+ assert!(!hosts.is_empty(), "the install reported something");
+ assert!(
+ hosts.iter().all(|h| h == "me@build-box:22"),
+ "one install, one machine: {hosts:?}"
+ );
+}
+
+/// **An install that is already present reports nothing.**
+///
+/// The common path — a machine tty7 has installed to before — does no transfer
+/// at all, and a bar that flashed on every connect would train the user to
+/// ignore it on the one connect where it means something.
+#[test]
+fn a_present_binary_reports_no_progress() {
+ let remote = FakeRemote::new().with_previous_install(VERSION);
+ let release = FakeRelease::new();
+ let user = FakeUser::approving();
+ let reports = Arc::new(Reports::default());
+
+ let report = with_install_progress(reports.clone(), || {
+ installer(&remote, &release, &user, "me@build-box:22").run()
+ })
+ .expect("install");
+
+ assert!(!report.installed, "nothing was written");
+ assert!(
+ reports.phases().is_empty(),
+ "nothing transferred, so nothing to show: {:?}",
+ reports.phases()
+ );
+}
+
+/// **The scoped sink outranks the global one, and is put back afterwards.**
+///
+/// Same contract as `with_install_confirm`, and it matters for the same reason:
+/// in the daemon each routed connection has its own client, and a global would
+/// send one machine's byte counts to the other machine's window.
+#[test]
+fn a_scoped_progress_sink_outranks_the_global_one() {
+ let scoped = Arc::new(Reports::default());
+ let phase = InstallPhase::Uploading { done: 1, total: 2 };
+
+ install_progress().report("before", phase);
+ with_install_progress(scoped.clone(), || {
+ install_progress().report("inside", phase);
+ });
+ install_progress().report("after", phase);
+
+ let seen: Vec = scoped.all().into_iter().map(|(host, _)| host).collect();
+ assert_eq!(
+ seen,
+ vec!["inside".to_string()],
+ "only the reports raised inside the scope land in it"
+ );
+}
+
+/// **`fraction` is safe to hand straight to a layout.**
+///
+/// It feeds a width, so anything outside `0.0..=1.0` draws a bar that overflows
+/// its track or inverts it. A zero or absent total is the interesting case: it
+/// means "unknown", not "zero percent", and the caller has to be able to tell.
+#[test]
+fn a_fraction_is_either_absent_or_in_range() {
+ assert_eq!(
+ InstallPhase::Downloading {
+ done: 0,
+ total: None
+ }
+ .fraction(),
+ None,
+ "no Content-Length means no fraction to draw"
+ );
+ assert_eq!(
+ InstallPhase::Uploading { done: 5, total: 0 }.fraction(),
+ None,
+ "a zero total is unknown, not complete"
+ );
+ assert_eq!(
+ InstallPhase::Uploading {
+ done: 50,
+ total: 100
+ }
+ .fraction(),
+ Some(0.5)
+ );
+ assert_eq!(
+ InstallPhase::Uploading {
+ done: 200,
+ total: 100
+ }
+ .fraction(),
+ Some(1.0),
+ "an over-count is clamped rather than overflowing the track"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Dialects, not build strings.
+// ---------------------------------------------------------------------------
+
+/// What this client speaks, which is what a remote has to match.
+fn ours() -> RemoteProtocol {
+ RemoteProtocol {
+ build: VERSION.to_string(),
+ ..RemoteProtocol::of_this_build()
+ }
+}
+
+const OTHER_BUILD: &str = "26.7.9-nightly.20260801";
+const OTHER_EXE: &str = "/home/me/.local/share/tty7/bin/tty7-server-26.7.9-nightly.20260801";
+
+/// **A newer server this client can talk to is adopted, not overwritten.**
+///
+/// The scene from the field: a `26.7.6` client meets a machine already serving
+/// `26.7.7-nightly`, both speaking the same dialects. Before this, the client
+/// stat'ed for its *own* version, missed, uploaded 8 MB nobody needed, and then
+/// asked the user to choose between keeping their sessions and restarting a
+/// server that was working fine.
+#[test]
+fn a_compatible_running_server_is_reused_without_installing() {
+ let remote = FakeRemote::new().serving(OTHER_EXE).speaking(
+ OTHER_EXE,
+ RemoteProtocol {
+ build: OTHER_BUILD.to_string(),
+ ..ours()
+ },
+ );
+ let release = FakeRelease::new();
+ let user = FakeUser::approving();
+
+ let report = installer(&remote, &release, &user, "me@build-box:22")
+ .run()
+ .expect("connect");
+
+ assert!(
+ !report.installed,
+ "nothing needed writing: {:?}",
+ remote.writes()
+ );
+ assert!(
+ report.reused.is_some(),
+ "the running server was adopted deliberately, and the report says so"
+ );
+ assert_eq!(
+ report.paths.binary, OTHER_EXE,
+ "the transport must connect to the binary that is actually serving"
+ );
+ assert!(
+ report.mismatch.is_none(),
+ "same dialects, so there is nothing to ask the user about"
+ );
+ assert!(
+ remote.writes().is_empty(),
+ "not one byte written to a machine that needed nothing: {:?}",
+ remote.writes()
+ );
+ assert!(
+ release.fetched().is_empty(),
+ "and nothing downloaded either: {:?}",
+ release.fetched()
+ );
+}
+
+/// **A server speaking a different dialect is still installed over.**
+///
+/// The other half of the same judgement — adoption is not a blanket "reuse
+/// whatever is there". A control dialect we cannot speak is exactly what the
+/// prompt exists for.
+#[test]
+fn an_incompatible_running_server_is_not_adopted() {
+ let remote = FakeRemote::new().serving(OTHER_EXE).speaking(
+ OTHER_EXE,
+ RemoteProtocol {
+ build: OTHER_BUILD.to_string(),
+ control: ours().control + 1,
+ ..ours()
+ },
+ );
+ let release = FakeRelease::new();
+ let user = FakeUser::approving();
+
+ let report = installer(&remote, &release, &user, "me@build-box:22")
+ .run()
+ .expect("connect");
+
+ assert!(report.installed, "a dialect we cannot speak means install");
+ assert!(report.reused.is_none());
+ assert_eq!(
+ report.paths.binary, BINARY,
+ "and the transport uses the one we just installed"
+ );
+ assert!(
+ report.mismatch.is_some(),
+ "the user still has a choice to make about the daemon that is running"
+ );
+}
+
+/// **The pane dialect counts too, not just the control one.**
+///
+/// A remote workspace uses both: control for the workspace, the pane protocol
+/// for every terminal in it. Matching one and not the other would open the
+/// workspace and then fail on the first pane.
+#[test]
+fn a_matching_control_dialect_is_not_enough_on_its_own() {
+ let remote = FakeRemote::new().serving(OTHER_EXE).speaking(
+ OTHER_EXE,
+ RemoteProtocol {
+ build: OTHER_BUILD.to_string(),
+ protocol: ours().protocol + 1,
+ ..ours()
+ },
+ );
+ let release = FakeRelease::new();
+ let user = FakeUser::approving();
+
+ let report = installer(&remote, &release, &user, "me@build-box:22")
+ .run()
+ .expect("connect");
+
+ assert!(
+ report.installed,
+ "the control versions agreed, but panes would not have worked"
+ );
+ assert!(report.reused.is_none());
+}
+
+/// **A server too old to answer `--protocol` is handled exactly as before.**
+///
+/// It predates the flag, so it exits non-zero; we learn nothing, and "nothing
+/// learnt" has to keep meaning "install ours and let the user decide", never
+/// "assume it is fine".
+#[test]
+fn a_server_that_cannot_be_probed_is_installed_over() {
+ // `.serving` without `.speaking`: the probe fails.
+ let remote = FakeRemote::new().serving(OTHER_EXE);
+ let release = FakeRelease::new();
+ let user = FakeUser::approving();
+
+ let report = installer(&remote, &release, &user, "me@build-box:22")
+ .run()
+ .expect("connect");
+
+ assert!(report.installed, "no answer means no adoption");
+ assert!(report.reused.is_none());
+ assert!(
+ report.mismatch.is_some(),
+ "an unprobeable different build is exactly when the prompt is honest"
+ );
+}
+
+/// **Our own version already installed still short-circuits everything.**
+///
+/// The fast path must not have grown a probe: a machine we have installed on
+/// before should cost a `stat` and nothing more.
+#[test]
+fn the_matching_version_still_costs_no_probe() {
+ let remote = FakeRemote::new()
+ .with_previous_install(VERSION)
+ .serving(BINARY);
+ let release = FakeRelease::new();
+ let user = FakeUser::approving();
+
+ let report = installer(&remote, &release, &user, "me@build-box:22")
+ .run()
+ .expect("connect");
+
+ assert!(!report.installed);
+ assert!(report.reused.is_none(), "adoption is for *other* builds");
+ assert!(
+ !remote
+ .journal()
+ .iter()
+ .any(|j| matches!(j, Journal::Exec(cmd) if cmd.ends_with(PROTOCOL_FLAG))),
+ "nothing to ask: the running exe is the path we wanted: {:?}",
+ remote.journal()
+ );
+}
+
+/// **`serves` is symmetric in neither direction by accident — it is equality.**
+///
+/// Written down because "newer can serve older" is the tempting wrong rule, and
+/// the failure it produces (a wire error mid-session, long after the connect)
+/// is far worse than the prompt it avoids.
+#[test]
+fn only_identical_dialects_serve() {
+ let base = ours();
+ assert!(base.serves(&base));
+ assert!(
+ base.serves(&RemoteProtocol {
+ build: "some other build entirely".to_string(),
+ ..base.clone()
+ }),
+ "the build string decides nothing"
+ );
+ assert!(
+ !base.serves(&RemoteProtocol {
+ control: base.control + 1,
+ ..base.clone()
+ }),
+ "a newer client is not automatically served by an older server"
+ );
+ assert!(
+ !RemoteProtocol {
+ control: base.control + 1,
+ ..base.clone()
+ }
+ .serves(&base),
+ "nor the other way round"
+ );
+}
+
+/// **The probe's output survives a chatty login shell.**
+///
+/// `.bashrc` on a shared box prints banners, `direnv` prints exports, and all of
+/// it lands on the same stdout the JSON does.
+#[test]
+fn a_noisy_shell_does_not_break_the_probe() {
+ let spoken = ours();
+ let json = serde_json::to_string(&spoken).unwrap();
+
+ assert_eq!(RemoteProtocol::parse(&json), Some(spoken.clone()));
+ assert_eq!(
+ RemoteProtocol::parse(&format!(
+ "Welcome to build-box!\nLast login: today\n{json}\n"
+ )),
+ Some(spoken),
+ "the answer is the last line, because the server prints it at exit"
+ );
+ assert_eq!(RemoteProtocol::parse(""), None);
+ assert_eq!(RemoteProtocol::parse("not json at all"), None);
+}
diff --git a/crates/tty7-core/src/daemon/install/wsl.rs b/crates/tty7-core/src/daemon/install/wsl.rs
index a5e2064c..87e07200 100644
--- a/crates/tty7-core/src/daemon/install/wsl.rs
+++ b/crates/tty7-core/src/daemon/install/wsl.rs
@@ -1,5 +1,5 @@
//! WSL — a distribution on *this* machine as a remote workspace host
-//! (design §7.3, §12 and decision D9).
+//! (decision D9).
//!
//! ## Why WSL is its own transport instead of "just another SSH host"
//!
@@ -615,7 +615,7 @@ impl RemoteOps for WslRemoteOps {
///
/// **Through `wsl.exe -- tee `, not through a `\\wsl$\\…` UNC
/// path**, and this is the one design decision in this file that had a real
- /// alternative. Design §12 names the UNC path; it is the worse of the two:
+ /// alternative. The UNC path is the worse of the two:
///
/// | | `\\wsl$` UNC write | `tee` over stdio |
/// |---|---|---|
@@ -677,7 +677,7 @@ impl RemoteOps for WslRemoteOps {
}
// ---------------------------------------------------------------------------
-// The bundled binary (design §12: WSL does not download).
+// The bundled binary (WSL does not download).
// ---------------------------------------------------------------------------
/// Overrides where the bundled Linux server binaries are looked for. Exists so
@@ -723,7 +723,8 @@ pub fn bundled_search_dirs(exe: Option<&Path>, override_dir: Option<&Path>) -> V
/// The Linux `tty7-server` this client shipped with.
///
-/// Design §12: "WSL 的安装不走下载 —— 直接把客户端自带的 Linux 二进制拷过去".
+/// A WSL install never downloads: the client's own bundled Linux binary is
+/// copied across instead.
/// The version question answers itself, because the bundled binary was built
/// from the same workspace version as the client asking for it; there is no tag
/// to resolve and no manifest to verify against.
@@ -984,7 +985,7 @@ mod tests {
));
}
- /// Design §7.3's command line, spelled out. This is the exact argv the
+ /// The WSL command line, spelled out. This is the exact argv the
/// transport is specified to produce, and it has never been run — so the
/// string is pinned here instead.
#[test]
@@ -1033,7 +1034,7 @@ mod tests {
}
/// The label a WSL host is prompted about, recorded under, and keyed by has
- /// to be the one `RemoteTarget` already defined (contract §4.2) — these are
+ /// to be the one `RemoteTarget` already defined — these are
/// compared as strings in the mismatch registry and in log lines a user is
/// meant to correlate.
#[test]
@@ -1485,7 +1486,7 @@ mod tests {
/// The bundled source loads bytes and reports where they came from, and its
/// absence is a *named* failure rather than a silent fall back to a
- /// download — which is the whole point of §12's WSL exception.
+ /// download — which is the whole point of the WSL exception.
#[test]
fn a_missing_bundled_binary_names_every_place_it_looked() {
let tmp = std::env::temp_dir().join(format!("tty7-wsl-src-{}", std::process::id()));
@@ -1768,8 +1769,8 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
- /// Declining writes nothing. Same rule as SSH — §12's "往别人机器上写二进制
- /// 值得问一次" applies to a distribution too, because it is still a
+ /// Declining writes nothing. Same rule as SSH — writing a binary onto a
+ /// machine is worth asking about once, and a distribution is still a
/// filesystem the user owns and did not ask us to touch.
#[test]
fn declining_writes_nothing_into_the_distribution() {
diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs
index e4e17711..cfb1f238 100644
--- a/crates/tty7-core/src/daemon/pane.rs
+++ b/crates/tty7-core/src/daemon/pane.rs
@@ -748,6 +748,13 @@ struct NativeSshBackend {
/// locks.
pub struct DaemonPane {
pub id: u64,
+ /// The workspace this pane was spawned for (a `WorkspaceId` uuid string),
+ /// when the spawning client said — see `ClientMsg::Spawn`'s `owner`.
+ /// Immutable for the pane's lifetime: ownership is decided at spawn, and a
+ /// pane that could change hands would be exactly the ambiguity this field
+ /// exists to close. Reported in `List` ([`PaneInfo::owner`]) so restore can
+ /// refuse to attach a workspace to a pane another one owns.
+ owner: Option,
/// The byte source (local PTY or native-SSH channel).
backend: PaneBackend,
/// The input side (keyboard input / pasted text): the PTY writer, or the
@@ -839,6 +846,7 @@ impl DaemonPane {
cwd: Option,
size: WinSize,
shell: Option,
+ owner: Option,
on_dead: impl FnOnce() + Send + 'static,
) -> anyhow::Result> {
let pty_size = pty_size(size);
@@ -880,6 +888,7 @@ impl DaemonPane {
let pane = Arc::new(Self {
id,
+ owner,
backend: PaneBackend::Pty(PtyBackend {
master: master.clone(),
child: Mutex::new(child),
@@ -1002,6 +1011,10 @@ impl DaemonPane {
let pane = Arc::new(Self {
id,
+ // Native-SSH spawns don't carry an owner yet: their leaves persist
+ // an `ssh_spec` and reconnect from it rather than by pane id, so
+ // the ownership check has nothing to protect there today.
+ owner: None,
backend: PaneBackend::NativeSsh(NativeSshBackend {
handle: bridge.handle,
connection: connection.clone(),
@@ -1391,6 +1404,7 @@ impl DaemonPane {
cwd: cwd.or_else(|| self.foreground_cwd()),
title: self.foreground_title(),
alive,
+ owner: self.owner.clone(),
}
}
@@ -2674,6 +2688,7 @@ mod tests {
args: vec!["-c".into(), "cd /usr && exec cat".into()],
args_are_tty7_defaults: false,
}),
+ None,
|| {},
)
.expect("spawn pane");
diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs
index ffb17861..8819dd17 100644
--- a/crates/tty7-core/src/daemon/protocol.rs
+++ b/crates/tty7-core/src/daemon/protocol.rs
@@ -68,9 +68,16 @@ pub const MAX_FRAME: usize = 64 * 1024 * 1024;
/// - **v1** — the dialect at the time versioning landed.
pub const PROTOCOL_VERSION: u32 = 3;
+/// Capability string for [`DaemonVersion::features`]: this daemon records
+/// which workspace each pane was spawned for and reports it in `List`'s
+/// [`PaneInfo::owner`], and it understands the [`kind::SPAWN_OWNED`] frame. A
+/// client must check for this before sending an owned spawn — the frame kind
+/// is unknown to older daemons, which drop the connection over it.
+pub const FEATURE_PANE_OWNER: &str = "pane-owner";
+
/// Reply to `ClientMsg::Version`: the protocol dialect the daemon speaks, plus
-/// its crate version for logs/diagnostics. Only `protocol` and `features` drive
-/// decisions.
+/// its crate version for logs/diagnostics. Only `protocol`, `features` and
+/// `instance` drive decisions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonVersion {
pub protocol: u32,
@@ -86,6 +93,15 @@ pub struct DaemonVersion {
/// every user whose daemon happens to predate it.
#[serde(default)]
pub features: Vec,
+ /// Identity of this daemon *process*, minted once at startup. Pane ids are
+ /// only meaningful within one daemon process — after a restart the numbers
+ /// start over from 1 and land on unrelated shells — so a client that
+ /// persists pane ids records this next to them and treats a mismatch as
+ /// "every saved id is stale" (see `Workspace::daemon_instance`). Empty for
+ /// daemons that predate the field; the remote `tty7-server` announces the
+ /// same identity through its control hello.
+ #[serde(default)]
+ pub instance: String,
}
impl DaemonVersion {
@@ -101,7 +117,11 @@ impl DaemonVersion {
// control dialect is served by `tty7-server`, which advertises
// `control` / `host-rpc` itself; claiming them here would make the
// GUI open a control connection this process cannot answer.
- features: Vec::new(),
+ //
+ // `pane-owner` *is* a pane-protocol capability, so every process
+ // serving panes from this build advertises it.
+ features: vec![FEATURE_PANE_OWNER.to_string()],
+ instance: process_instance().to_string(),
}
}
@@ -111,6 +131,13 @@ impl DaemonVersion {
}
}
+/// This process's pane-daemon identity: a uuid minted on first use and stable
+/// for the process lifetime. See [`DaemonVersion::instance`] for why it exists.
+pub fn process_instance() -> &'static str {
+ static INSTANCE: std::sync::OnceLock = std::sync::OnceLock::new();
+ INSTANCE.get_or_init(|| uuid::Uuid::new_v4().to_string())
+}
+
/// Terminal geometry shared by spawn/attach/resize. Cell pixel size travels too
/// so the daemon can set an accurate `TIOCSWINSZ` (`ws_xpixel`/`ws_ypixel`),
/// which some full-screen apps read.
@@ -186,6 +213,14 @@ pub struct PaneInfo {
/// False once the child has exited but the pane lingers (so a client can
/// still read its final scrollback).
pub alive: bool,
+ /// The workspace this pane was spawned for (a `WorkspaceId` uuid, as a
+ /// string), when the spawning client said ([`ClientMsg::Spawn`]'s `owner`).
+ /// `None` for panes spawned by older clients or through the legacy spawn
+ /// kinds. Restore uses this to refuse re-attaching a pane to a workspace
+ /// that never owned it — the failure mode where one workspace's saved ids
+ /// silently pick up another's shells.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub owner: Option,
}
/// A pane whose filesystem is not the host's — either a remote session, or a
@@ -236,7 +271,7 @@ pub struct LoopbackForwardRequest {
}
// ---------------------------------------------------------------------------
-// Workspace-scoped control requests (design §15, M7).
+// Workspace-scoped control requests (M7).
//
// A *remote workspace* has no pane on this daemon: its panes live on the remote
// `tty7-server`, and the only thing this side owns is the `SshConnection` the
@@ -906,6 +941,12 @@ pub enum ClientMsg {
cwd: Option,
size: WinSize,
shell: Option,
+ /// The workspace this pane will belong to (a `WorkspaceId` uuid, as a
+ /// string). Rides the [`kind::SPAWN_OWNED`] frame, which only a daemon
+ /// advertising [`FEATURE_PANE_OWNER`] understands — callers leave this
+ /// `None` for older daemons and the spawn goes out on the legacy kinds,
+ /// byte-for-byte as before.
+ owner: Option,
},
/// Bind this connection to an existing pane and (re)size it. The daemon
/// replies with a `Snapshot` then live `Output`.
@@ -991,7 +1032,7 @@ pub enum ClientMsg {
/// every pane, forever, to feed a view that's usually closed.
QueryProcs { pane_id: u64 },
/// A control request scoped to a **remote workspace's** SSH connection
- /// instead of a pane's (design §15). See [`WorkspaceRequest`].
+ /// instead of a pane's. See [`WorkspaceRequest`].
OnWorkspace(Box),
/// Ask which protocol version the daemon speaks (control connection); the
/// daemon replies `Version`. A daemon that predates versioning doesn't know
@@ -1138,10 +1179,19 @@ mod kind {
// here because this module is private and the router must not become a
// reason to open it — but the number is spent either way.
/// `OnWorkspace` — a control request on a remote workspace's SSH connection
- /// (design §15). 52 is the next number clear of every range above, of the
+ ///. 52 is the next number clear of every range above, of the
/// router's 51, and of the retired 13; the contract's control connection
/// reserves 60-63, which this stays below.
pub const ON_WORKSPACE: u8 = 52;
+ /// `Spawn` carrying a [`super::OwnedSpawn`] **struct** payload — the spawn
+ /// that also names the workspace owning the pane. A brand-new kind for the
+ /// same reason `SPAWN_SHELL` was one: the legacy spawn payloads are
+ /// positional tuples an old daemon cannot grow, so a client only sends this
+ /// to a daemon advertising [`super::FEATURE_PANE_OWNER`] and falls back to
+ /// the legacy kinds otherwise. The struct payload is the lesson learned —
+ /// any further spawn field rides this kind with `#[serde(default)]`, no new
+ /// number needed. 53 stays below the control connection's 60-63 reserve.
+ pub const SPAWN_OWNED: u8 = 53;
// Daemon -> client
pub const SPAWNED: u8 = 1;
@@ -1216,6 +1266,25 @@ pub fn read_frame(r: &mut R) -> io::Result<(u8, Vec)> {
Ok((kind[0], payload))
}
+/// The kind byte of the frame at the front of `buf`, once its 5-byte header has
+/// arrived — the payload need not have.
+///
+/// For the one caller that has to classify a reply *before* paying for it: the
+/// client's `Attach` is answered either by a tiny `Error` or by a `Size` +
+/// `Snapshot` replay that can run to megabytes, and waiting for the whole first
+/// frame to tell them apart would stall every successful attach behind its own
+/// scrollback.
+pub fn peek_frame_kind(buf: &[u8]) -> Option {
+ (buf.len() >= 5).then(|| buf[4])
+}
+
+/// Whether `kind` is the [`DaemonMsg::Error`] frame. The kind bytes themselves
+/// stay private — this is the one classification a client makes without
+/// decoding, and naming it keeps the numbering in one file.
+pub fn is_error_kind(kind: u8) -> bool {
+ kind == kind::ERROR
+}
+
/// Extract one complete frame from the front of `buf`, if fully buffered — the
/// resumable counterpart of [`read_frame`] for callers that read the stream
/// with timeouts (the client reader enforces the DEC 2026 synchronized-update
@@ -1254,6 +1323,19 @@ fn from_json Deserialize<'de>>(bytes: &[u8]) -> io::Result {
serde_json::from_slice(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
+/// The [`kind::SPAWN_OWNED`] payload — a struct, not a tuple, so the *next*
+/// spawn field is a `#[serde(default)]` line here instead of a new frame kind.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct OwnedSpawn {
+ #[serde(default)]
+ cwd: Option,
+ size: WinSize,
+ #[serde(default)]
+ shell: Option,
+ #[serde(default)]
+ owner: Option,
+}
+
impl ClientMsg {
/// Encode and write this message as one frame.
pub fn encode(&self, w: &mut W) -> io::Result<()> {
@@ -1265,12 +1347,31 @@ impl ClientMsg {
cwd,
size,
shell: None,
+ owner: None,
} => write_frame(w, kind::SPAWN, &to_json(&(cwd, size))?),
ClientMsg::Spawn {
cwd,
size,
shell: shell @ Some(_),
+ owner: None,
} => write_frame(w, kind::SPAWN_SHELL, &to_json(&(cwd, size, shell))?),
+ // An owner present means the caller checked FEATURE_PANE_OWNER —
+ // this frame kind is unknown to daemons without it.
+ ClientMsg::Spawn {
+ cwd,
+ size,
+ shell,
+ owner: owner @ Some(_),
+ } => write_frame(
+ w,
+ kind::SPAWN_OWNED,
+ &to_json(&OwnedSpawn {
+ cwd: cwd.clone(),
+ size: *size,
+ shell: shell.clone(),
+ owner: owner.clone(),
+ })?,
+ ),
ClientMsg::Attach { pane_id, size } => {
write_frame(w, kind::ATTACH, &to_json(&(pane_id, size))?)
}
@@ -1340,11 +1441,31 @@ impl ClientMsg {
cwd,
size,
shell: None,
+ owner: None,
}
}
kind::SPAWN_SHELL => {
let (cwd, size, shell) = from_json(&payload)?;
- ClientMsg::Spawn { cwd, size, shell }
+ ClientMsg::Spawn {
+ cwd,
+ size,
+ shell,
+ owner: None,
+ }
+ }
+ kind::SPAWN_OWNED => {
+ let OwnedSpawn {
+ cwd,
+ size,
+ shell,
+ owner,
+ } = from_json(&payload)?;
+ ClientMsg::Spawn {
+ cwd,
+ size,
+ shell,
+ owner,
+ }
}
kind::ATTACH => {
let (pane_id, size) = from_json(&payload)?;
@@ -1575,6 +1696,7 @@ mod tests {
cwd: Some(PathBuf::from("/work")),
size: SIZE,
shell: None,
+ owner: None,
},
ClientMsg::Resize(SIZE),
ClientMsg::Input(vec![b'l', b's', b'\r']),
@@ -1630,11 +1752,13 @@ mod tests {
cwd: Some(PathBuf::from("/tmp/x")),
size: SIZE,
shell: None,
+ owner: None,
},
ClientMsg::Spawn {
cwd: None,
size: SIZE,
shell: None,
+ owner: None,
},
ClientMsg::Spawn {
cwd: Some(PathBuf::from("/tmp/x")),
@@ -1644,6 +1768,13 @@ mod tests {
args: vec!["--distribution".into(), "Ubuntu".into()],
args_are_tty7_defaults: true,
}),
+ owner: None,
+ },
+ ClientMsg::Spawn {
+ cwd: Some(PathBuf::from("/tmp/x")),
+ size: SIZE,
+ shell: None,
+ owner: Some("bda10e44-02de-44a0-8412-ec1cda2b5f5b".into()),
},
ClientMsg::Attach {
pane_id: 42,
@@ -1771,12 +1902,22 @@ mod tests {
},
DaemonMsg::Exited { code: Some(0) },
DaemonMsg::Exited { code: None },
- DaemonMsg::PaneList(vec![PaneInfo {
- pane_id: 3,
- cwd: Some(PathBuf::from("/x")),
- title: "zsh".into(),
- alive: true,
- }]),
+ DaemonMsg::PaneList(vec![
+ PaneInfo {
+ pane_id: 3,
+ cwd: Some(PathBuf::from("/x")),
+ title: "zsh".into(),
+ alive: true,
+ owner: None,
+ },
+ PaneInfo {
+ pane_id: 4,
+ cwd: None,
+ title: String::new(),
+ alive: true,
+ owner: Some("ffe038d0-9ad6-40c0-815d-1fcc43c17ec0".into()),
+ },
+ ]),
DaemonMsg::RemoteContext(Some(RemoteContext {
kind: RemoteKind::Ssh,
argv: vec!["ssh".into(), "-p".into(), "2222".into(), "dev".into()],
@@ -1899,11 +2040,13 @@ mod tests {
protocol: PROTOCOL_VERSION,
build: "0.15.0".into(),
features: vec!["control".into(), "host-rpc".into()],
+ instance: "inst-a".into(),
}),
DaemonMsg::Version(DaemonVersion {
protocol: PROTOCOL_VERSION,
build: "0.15.0".into(),
features: Vec::new(),
+ instance: String::new(),
}),
DaemonMsg::Error("nope".into()),
];
@@ -1929,6 +2072,7 @@ mod tests {
cwd: Some(PathBuf::from("/work")),
size: SIZE,
shell: None,
+ owner: None,
};
let mut buf = Vec::new();
msg.encode(&mut buf).unwrap();
@@ -1949,6 +2093,7 @@ mod tests {
cwd: Some(PathBuf::from("/old")),
size: SIZE,
shell: None,
+ owner: None,
}
);
}
@@ -1967,6 +2112,7 @@ mod tests {
cwd: Some(PathBuf::from("/work")),
size: SIZE,
shell: Some(shell.clone()),
+ owner: None,
};
let mut buf = Vec::new();
msg.encode(&mut buf).unwrap();
@@ -1979,10 +2125,74 @@ mod tests {
cwd: Some(PathBuf::from("/work")),
size: SIZE,
shell: Some(shell),
+ owner: None,
}
);
}
+ /// An owned spawn rides the `SPAWN_OWNED` frame — never a legacy kind,
+ /// whose tuple payloads cannot carry the field — and round-trips with the
+ /// shell pick intact. The compat direction is the caller's contract:
+ /// `owner` is only ever set for a daemon advertising `pane-owner`, so the
+ /// legacy kinds stay byte-for-byte what old daemons expect (locked by
+ /// `default_spawn_stays_wire_compatible_with_old_daemons` above).
+ #[test]
+ fn owned_spawn_uses_the_owned_kind_and_round_trips() {
+ let msg = ClientMsg::Spawn {
+ cwd: Some(PathBuf::from("/work")),
+ size: SIZE,
+ shell: Some(ShellSpec {
+ program: "fish".into(),
+ args: vec!["-l".into()],
+ args_are_tty7_defaults: false,
+ }),
+ owner: Some("bda10e44-02de-44a0-8412-ec1cda2b5f5b".into()),
+ };
+ let mut buf = Vec::new();
+ msg.encode(&mut buf).unwrap();
+ let (k, payload) = read_frame(&mut std::io::Cursor::new(&buf)).unwrap();
+ assert_eq!(k, kind::SPAWN_OWNED);
+ assert_eq!(ClientMsg::from_frame(k, payload).unwrap(), msg);
+ }
+
+ /// The `SPAWN_OWNED` payload is a struct with defaults, so a frame from a
+ /// *newer* client — more fields, or fewer — still decodes. This is the
+ /// property that makes it the last spawn kind ever needed.
+ #[test]
+ fn owned_spawn_payload_tolerates_unknown_and_missing_fields() {
+ let payload = serde_json::to_vec(&serde_json::json!({
+ "size": {"cols": 80, "rows": 24, "cell_w": 8, "cell_h": 17},
+ "some_future_field": true,
+ }))
+ .unwrap();
+ let decoded = ClientMsg::from_frame(kind::SPAWN_OWNED, payload).unwrap();
+ assert_eq!(
+ decoded,
+ ClientMsg::Spawn {
+ cwd: None,
+ size: WinSize {
+ cols: 80,
+ rows: 24,
+ cell_w: 8,
+ cell_h: 17
+ },
+ shell: None,
+ owner: None,
+ }
+ );
+ }
+
+ /// A `PaneInfo` from an old daemon has no `owner` key and decodes to
+ /// `None` — the "attachable by anyone" reading every pane had before the
+ /// field existed.
+ #[test]
+ fn pane_info_owner_defaults_for_old_daemons() {
+ let old = serde_json::json!({"pane_id": 3, "title": "zsh", "alive": true});
+ let info: PaneInfo = serde_json::from_value(old).unwrap();
+ assert_eq!(info.owner, None);
+ assert!(info.alive);
+ }
+
/// An empty-payload binary frame (e.g. an `Input([])`) still round-trips and
/// an oversize length is rejected.
#[test]
diff --git a/crates/tty7-core/src/daemon/remote_link.rs b/crates/tty7-core/src/daemon/remote_link.rs
index 40c13746..a8d8d6f0 100644
--- a/crates/tty7-core/src/daemon/remote_link.rs
+++ b/crates/tty7-core/src/daemon/remote_link.rs
@@ -104,8 +104,7 @@ impl RemoteLink {
Ok(RemoteLink::LocalStdio(spawn_stdio(program, args)?))
}
- /// Spawn `wsl.exe -d -- --stdio` and take its stdio
- /// (design §7.3).
+ /// Spawn `wsl.exe -d -- --stdio` and take its stdio.
///
/// `server` is an **absolute path inside the distribution**, not a bare
/// name: `wsl.exe` runs the command without a login shell, so the `PATH`
@@ -235,7 +234,7 @@ pub const DEFAULT_REMOTE_SERVER_CMD: &str = "tty7-server --stdio";
const MAX_SOCKET_PATH_BYTES: usize = 100;
/// How this connection reaches the remote `tty7-server` — decided once per SSH
-/// connection and cached there (design §7.1), never re-decided per channel.
+/// connection and cached there, never re-decided per channel.
///
/// Probing per channel would put a failed `direct-streamlocal` open in front of
/// every pane on a host whose admin turned `AllowStreamLocalForwarding` off,
@@ -363,7 +362,7 @@ impl RemoteEnv {
/// bridge that resolves the path in the process that binds it.
///
/// Paths are joined as POSIX strings, never `PathBuf`: on a Windows client
-/// `PathBuf::join("/home/me", "tty7")` yields `/home/me\tty7` (contract §4.3).
+/// `PathBuf::join("/home/me", "tty7")` yields `/home/me\tty7`.
pub fn remote_control_socket(env: &RemoteEnv) -> Option {
if let Some(explicit) = env.control_sock.as_deref().filter(|s| !s.is_empty()) {
return Some(explicit.to_string());
diff --git a/crates/tty7-core/src/daemon/router.rs b/crates/tty7-core/src/daemon/router.rs
index a17e7c67..862c54d6 100644
--- a/crates/tty7-core/src/daemon/router.rs
+++ b/crates/tty7-core/src/daemon/router.rs
@@ -1,5 +1,5 @@
//! [`RemoteRouter`] — the local daemon as a forwarding hub for remote
-//! workspaces (design §6).
+//! workspaces.
//!
//! ## The shape
//!
@@ -18,7 +18,7 @@
//!
//! A router that parsed the stream would become a third opinion about the
//! protocol version. The remote's dialect is negotiated between the GUI and the
-//! remote server — contract §6.9's end-to-end handshake, and the reason the
+//! remote server — the end-to-end handshake, and the reason the
//! contract resolves erratum #15 the way it does: a local daemon that had to
//! understand remote frames would need to be upgraded in lockstep with both
//! ends, and every version skew would land in the middle where neither user nor
@@ -74,7 +74,7 @@
//!
//! [`RouteAction::RestartServer`] uses the same window in the opposite
//! direction: the *client* tells the daemon to replace the `tty7-server` on the
-//! target machine (design §12's "restart the service"). It is here for the same
+//! target machine ("Restart Server"). It is here for the same
//! reason the prompts are — the decision needs a user and the act needs an
//! `Arc`, and those live in different processes — and it fits the
//! window's own rule, being a precondition for a usable link rather than
@@ -90,7 +90,8 @@ use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::daemon::install::{
- InstallConfirm, InstallDecision, InstallRequest, MismatchedRemoteDaemon,
+ InstallConfirm, InstallDecision, InstallPhase, InstallProgress, InstallRequest,
+ MismatchedRemoteDaemon,
};
use crate::daemon::protocol::{self, AuthPromptKind, AuthResponse, DaemonMsg, NativeSshSpec};
use crate::daemon::remote_link::RemoteLink;
@@ -180,7 +181,7 @@ pub enum RouteTarget {
/// A host reached over SSH: `direct-streamlocal` when the server allows it,
/// `tty7-server --stdio` on a session channel when it does not.
Ssh(Box),
- /// A WSL distribution — no SSH, no auth, no network (design §7.3, D9).
+ /// A WSL distribution — no SSH, no auth, no network (D9).
///
/// The distro name as `wsl.exe -l -q` prints it. Nothing else is carried,
/// because nothing else exists: no user (the distribution's default user is
@@ -199,7 +200,7 @@ pub enum RouteTarget {
///
/// Everything else in this module assumes [`Forward`](RouteAction::Forward) —
/// the connection is a pipe and the daemon is in the middle of it. The one thing
-/// that is *not* a pipe is design §12's "restart the service": it needs the
+/// that is *not* a pipe is "Restart Server": it needs the
/// machine's `Arc`, which exists only in the daemon process,
/// while the decision to do it can only be made by the process with a user in
/// front of it. So it travels the same way every other cross-process question on
@@ -224,7 +225,7 @@ pub enum RouteAction {
/// and no link is opened: there is nothing to talk to afterwards, since the
/// daemon that was serving is the one being replaced.
///
- /// Design §12: this drops every pane that daemon hosts. It happens only when
+ /// This drops every pane that daemon hosts. It happens only when
/// a user has answered the keep-or-restart prompt with "Restart Server".
RestartServer,
}
@@ -251,7 +252,7 @@ pub struct RouteHeader {
/// What the daemon should do with this connection.
///
/// `#[serde(default)]` = [`RouteAction::Forward`], the only thing a routed
- /// connection did before design §12's restart needed a way across the
+ /// connection did before the restart needed a way across the
/// process boundary.
#[serde(default)]
pub action: RouteAction,
@@ -296,7 +297,7 @@ impl RouteHeader {
}
/// The same machine, but asking the daemon to replace the `tty7-server`
- /// running there rather than to talk to it (design §12).
+ /// running there rather than to talk to it.
///
/// The connection carries nothing afterwards: the ack is the whole
/// conversation. Callers must have a user's explicit "Restart Server" behind
@@ -452,7 +453,7 @@ pub enum RoutePrompt {
request_id: u64,
prompt: AuthPromptKind,
},
- /// "May tty7 write a server binary onto this machine?" (design §12).
+ /// "May tty7 write a server binary onto this machine?".
Install {
request_id: u64,
request: Box,
@@ -464,6 +465,12 @@ pub enum RoutePrompt {
Mismatch {
daemons: Vec,
},
+ /// How far the install this connection is performing has got. Told, not
+ /// asked, like `Mismatch` — but unlike it, **freely droppable**: these
+ /// arrive hundreds of times per install and each one supersedes the last, so
+ /// a client that misses some has lost nothing a later frame will not
+ /// correct.
+ InstallProgress { host: String, phase: InstallPhase },
}
/// The client's answer to a [`RoutePrompt`].
@@ -524,7 +531,7 @@ impl RouteReply {
/// **`machine` is the connection's own target**, handed down from the
/// [`RouteHeader`] this negotiation opened with. A client with more than one
/// machine has to know which one is asking — to name it in the sheet, and to
-/// queue one sheet per machine (design §10, D7) — and the header is the only
+/// queue one sheet per machine (D7) — and the header is the only
/// place that fact is certain. It used to be inferred from the answering
/// *thread*, which held for the workspace connect and silently did not for a
/// pane's (`connect_routed` never set it), leaving every routed pane prompt
@@ -633,6 +640,13 @@ fn answer(machine: &RouteTarget, prompt: RoutePrompt) -> Option {
crate::daemon::install::record_remote_mismatches(daemons);
None
}
+ RoutePrompt::InstallProgress { host, phase } => {
+ // Into this process's sink, which in the GUI is what the switcher
+ // reads. Same shape as `Mismatch`: relayed precisely so it lands on
+ // the side with a user on it.
+ crate::daemon::install::install_progress().report(&host, phase);
+ None
+ }
}
}
@@ -730,6 +744,10 @@ pub struct RouteSetup {
/// Install consent, in the shape [`crate::daemon::install::Installer`]
/// already speaks.
pub confirm: Arc,
+ /// Where that install's byte counts go. Separate from `confirm` even though
+ /// one `Relay` is both, because [`unattended`](RouteSetup::unattended) wants
+ /// a sink that discards and a confirm that refuses — two different defaults.
+ pub progress: Arc,
/// Where a build mismatch discovered during setup is collected, to be
/// handed to the client instead of to this process's registry.
pub mismatches: Arc>>,
@@ -745,6 +763,7 @@ impl RouteSetup {
RouteSetup {
broker: PromptBroker::new(Box::new(|_| false)),
confirm: Arc::new(crate::daemon::install::DenyInstall),
+ progress: Arc::new(crate::daemon::install::SilentProgress),
mismatches: Arc::new(Mutex::new(Vec::new())),
channel,
}
@@ -764,10 +783,13 @@ impl RouteSetup {
T: Send + 'static,
{
let confirm = self.confirm.clone();
+ let progress = self.progress.clone();
let sink = self.mismatches.clone();
tokio::task::spawn_blocking(move || {
crate::daemon::install::with_install_confirm(confirm, || {
- crate::daemon::install::with_mismatch_sink(sink, f)
+ crate::daemon::install::with_install_progress(progress, || {
+ crate::daemon::install::with_mismatch_sink(sink, f)
+ })
})
})
.await
@@ -838,6 +860,26 @@ impl InstallConfirm for Relay {
}
}
+impl InstallProgress for Relay {
+ /// Fire-and-forget onto the outbox, with no reply to wait for and no error
+ /// path — the send fails only once the client has gone, and an install that
+ /// nobody is watching any more should carry on rather than abort over a
+ /// progress frame.
+ ///
+ /// The outbox is unbounded, so this never blocks the thread pushing bytes
+ /// over SFTP. The frames are small (tens of bytes) and the writer drains
+ /// them between transfer chunks.
+ fn report(&self, host: &str, phase: InstallPhase) {
+ let prompt = RoutePrompt::InstallProgress {
+ host: host.to_string(),
+ phase,
+ };
+ if let Ok(payload) = serde_json::to_vec(&prompt) {
+ let _ = self.out.send((ROUTE_PROMPT_KIND, payload));
+ }
+ }
+}
+
/// The forwarding hub. Stateless: a routed connection's only state is the two
/// halves of the pipe, and both die with it.
pub struct RemoteRouter;
@@ -887,6 +929,7 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> {
_ => true,
})),
confirm: relay.clone(),
+ progress: relay.clone(),
mismatches: Arc::new(Mutex::new(Vec::new())),
channel: header.channel,
};
@@ -939,7 +982,7 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> {
// but dropping it would be a silent truncation.
Some((link, conn, frames.into_buffer()))
}
- // Design §12's restart: the daemon that would have been on the other
+ // The restart: the daemon that would have been on the other
// end of this pipe is the one that was just replaced, so the ack is
// the last thing this connection carries. The client reconnects to
// the new one on its own — the supervisor's reconnect is already the
@@ -1084,7 +1127,7 @@ async fn perform(header: &RouteHeader, setup: &RouteSetup) -> anyhow::Result) -> anyhow::Result<()> {
let first = ClientMsg::from_frame(first_kind, first_payload)?;
match first {
- ClientMsg::Spawn { cwd, size, shell } => {
+ ClientMsg::Spawn {
+ cwd,
+ size,
+ shell,
+ owner,
+ } => {
let id = registry.alloc_id();
// Reclaim a pane whose child exits while *detached* (nobody attached,
// so no connection's detach path will ever drop it): remove it from
@@ -272,7 +277,7 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> {
.ok();
}
};
- let pane = match DaemonPane::spawn(id, cwd, size, shell, on_dead) {
+ let pane = match DaemonPane::spawn(id, cwd, size, shell, owner, on_dead) {
Ok(p) => p,
Err(e) => {
// Report the failure to the client and close.
@@ -540,7 +545,7 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> {
// A remote workspace has no pane here to address, so its forwards and
// SFTP go through one envelope that names the connection instead
- // (design §15). The whole answer — including every failure — is built by
+ //. The whole answer — including every failure — is built by
// `ssh::workspace::handle`, so this arm stays a pipe.
ClientMsg::OnWorkspace(req) => {
let mut w = write_stream;
diff --git a/crates/tty7-core/src/daemon/spawn.rs b/crates/tty7-core/src/daemon/spawn.rs
index d5632fde..d6becab1 100644
--- a/crates/tty7-core/src/daemon/spawn.rs
+++ b/crates/tty7-core/src/daemon/spawn.rs
@@ -66,6 +66,42 @@ pub fn take_mismatched_daemon() -> Option {
MISMATCHED_DAEMON.lock().ok()?.take()
}
+/// What the version handshake learned about the daemon currently serving this
+/// process's endpoint. Refreshed by every [`ensure_running`] — including the
+/// one `RemoteTerminal`'s spawn retry runs after a daemon death — and cleared
+/// when the daemon predates the handshake, so a reader never acts on the
+/// identity of a daemon that is no longer the one answering.
+static LOCAL_DAEMON: std::sync::Mutex> = std::sync::Mutex::new(None);
+
+/// The serving daemon's process identity, when it reports one. `None` means
+/// "unknown" (an older daemon, or nothing running) — callers must treat that
+/// as "no instance check possible", never as a mismatch.
+pub fn local_daemon_instance() -> Option {
+ let guard = LOCAL_DAEMON.lock().ok()?;
+ guard
+ .as_ref()
+ .map(|v| v.instance.clone())
+ .filter(|i| !i.is_empty())
+}
+
+/// Whether the serving daemon advertises `feature`
+/// (e.g. [`crate::daemon::protocol::FEATURE_PANE_OWNER`]). `false` when
+/// nothing is known — the safe answer, because every capability gated on this
+/// has a legacy fallback.
+pub fn local_daemon_supports(feature: &str) -> bool {
+ LOCAL_DAEMON
+ .lock()
+ .ok()
+ .and_then(|guard| guard.as_ref().map(|v| v.has_feature(feature)))
+ .unwrap_or(false)
+}
+
+fn note_local_daemon(version: Option) {
+ if let Ok(mut slot) = LOCAL_DAEMON.lock() {
+ *slot = version;
+ }
+}
+
/// How a live daemon answered the version handshake.
#[derive(Debug, PartialEq, Eq)]
enum VersionProbe {
@@ -96,7 +132,10 @@ pub fn ensure_running() -> anyhow::Result<()> {
// panes either way.
if let Ok(mut stream) = transport::connect() {
match query_daemon_version(&mut stream) {
- VersionProbe::Speaks(v) if v.protocol == PROTOCOL_VERSION => return Ok(()),
+ VersionProbe::Speaks(v) if v.protocol == PROTOCOL_VERSION => {
+ note_local_daemon(Some(v));
+ return Ok(());
+ }
VersionProbe::Speaks(v) => {
log::warn!(
"daemon (build {}) speaks protocol {}, this build needs {}; \
@@ -105,6 +144,9 @@ pub fn ensure_running() -> anyhow::Result<()> {
v.protocol,
PROTOCOL_VERSION
);
+ // Still the serving daemon: its identity and capability list
+ // are true regardless of the dialect gap.
+ note_local_daemon(Some(v.clone()));
if let Ok(mut slot) = MISMATCHED_DAEMON.lock() {
*slot = Some(MismatchedDaemon { version: Some(v) });
}
@@ -114,6 +156,7 @@ pub fn ensure_running() -> anyhow::Result<()> {
log::warn!(
"daemon predates protocol versioning; keeping it and deferring to the user"
);
+ note_local_daemon(None);
if let Ok(mut slot) = MISMATCHED_DAEMON.lock() {
*slot = Some(MismatchedDaemon { version: None });
}
@@ -121,6 +164,7 @@ pub fn ensure_running() -> anyhow::Result<()> {
}
VersionProbe::Unresponsive => {
log::info!("daemon did not answer the version handshake; restarting it");
+ note_local_daemon(None);
drop(stream);
// `stop` shuts the old daemon down gracefully (`Shutdown`
// predates versioning, so even the oldest daemon honors it),
@@ -154,7 +198,14 @@ pub fn ensure_running() -> anyhow::Result<()> {
// (via `bind`) slightly before the accept loop is ready.
let deadline = Instant::now() + STARTUP_TIMEOUT;
loop {
- if transport::connect().is_ok() {
+ if let Ok(mut stream) = transport::connect() {
+ // Capture the fresh daemon's identity (instance + features). It is
+ // our own build, but asking beats assuming — and this is the only
+ // handshake a cold start ever runs.
+ match query_daemon_version(&mut stream) {
+ VersionProbe::Speaks(v) => note_local_daemon(Some(v)),
+ _ => note_local_daemon(None),
+ }
return Ok(());
}
if Instant::now() >= deadline {
@@ -619,6 +670,7 @@ mod tests {
protocol: PROTOCOL_VERSION,
build: "test".into(),
features: Vec::new(),
+ instance: "inst-test".into(),
})
.encode(&mut daemon)
.unwrap();
diff --git a/crates/tty7-core/src/daemon/ssh/forward.rs b/crates/tty7-core/src/daemon/ssh/forward.rs
index ab77987e..d555a6a3 100644
--- a/crates/tty7-core/src/daemon/ssh/forward.rs
+++ b/crates/tty7-core/src/daemon/ssh/forward.rs
@@ -20,7 +20,7 @@
//! connection alive exactly like `ssh -N`.
//!
//! There are two owners, because tty7 has two unrelated features that both open
-//! forwards (design §2):
+//! forwards:
//!
//! | | SSH pane ("连一下") | remote workspace ("在上面开发") |
//! |---|---|---|
@@ -410,7 +410,7 @@ impl SshForwardRegistry {
self.teardown_owned(&ForwardOwner::Pane(pane_id)).await;
}
- // ---- Workspace-owned forwards (remote workspaces, design §15) -----------
+ // ---- Workspace-owned forwards (remote workspaces) ----------------------
/// [`establish`](Self::establish) for a remote workspace. `view_pane` is only
/// stamped into the returned row for the GUI's per-pane list; ownership — and
@@ -718,7 +718,7 @@ impl SshForwardRegistry {
}
/// [`ensure_loopback`](Self::ensure_loopback) for a remote workspace: the
- /// ⌘-clicked `localhost:PORT` in a remote-workspace pane (design §15).
+ /// ⌘-clicked `localhost:PORT` in a remote-workspace pane.
///
/// The forward is owned by the workspace, so clicking the link in one pane
/// and then closing that pane leaves the browser tab working.
@@ -890,7 +890,7 @@ impl SshManager {
}
/// Ensure the on-demand loopback forward behind a ⌘-clicked `localhost:PORT`
- /// in a remote-workspace pane (design §15).
+ /// in a remote-workspace pane.
pub fn ensure_workspace_loopback(
&self,
workspace: WorkspaceId,
@@ -1233,7 +1233,7 @@ mod tests {
);
}
- /// **Remote-workspace ownership (design §15).** The panes of a remote
+ /// **Remote-workspace ownership.** The panes of a remote
/// workspace are transient — a tab closed, a pane respawned after a reconnect
/// — so a forward the user ⌘-clicked into existence must outlive them. Only
/// closing the *workspace* collects it.
diff --git a/crates/tty7-core/src/daemon/ssh/mod.rs b/crates/tty7-core/src/daemon/ssh/mod.rs
index 50dd3574..49c2dc68 100644
--- a/crates/tty7-core/src/daemon/ssh/mod.rs
+++ b/crates/tty7-core/src/daemon/ssh/mod.rs
@@ -20,7 +20,7 @@ pub mod forward;
pub mod known_hosts;
pub mod session;
pub mod sftp;
-/// Workspace-scoped control requests (design §15) — see `workspace::handle`.
+/// Workspace-scoped control requests — see `workspace::handle`.
pub mod workspace;
mod auth;
@@ -393,7 +393,7 @@ impl SshManager {
/// [`ConnectionKey`] registry the SSH panes use, so a workspace opened
/// against a host the user already has a pane on costs no prompt at all, and
/// a second workspace on the same host costs no second prompt — each stream
- /// is a new *channel*, never a new authentication (design §7.1). One channel
+ /// is a new *channel*, never a new authentication. One channel
/// per pane, one per workspace control stream; no multiplexing of our own on
/// top of SSH's.
///
@@ -499,7 +499,7 @@ impl SshManager {
}
/// Replace the `tty7-server` running on `spec`'s host with this client's
- /// build — design §12's "restart the service", and **it drops every pane
+ /// build — "Restart Server", and **it drops every pane
/// that server is hosting**.
///
/// Only ever reached from a [`RouteAction::RestartServer`](crate::daemon::router::RouteAction)
diff --git a/crates/tty7-core/src/daemon/ssh/session.rs b/crates/tty7-core/src/daemon/ssh/session.rs
index f7b30e1b..0e9ce72e 100644
--- a/crates/tty7-core/src/daemon/ssh/session.rs
+++ b/crates/tty7-core/src/daemon/ssh/session.rs
@@ -267,7 +267,7 @@ pub struct SshConnection {
remote_forwards: RemoteForwardTable,
alive: AtomicBool,
/// How this host's `tty7-server` is reached — probed once, then reused by
- /// every remote workspace stream on this connection (design §7.1).
+ /// every remote workspace stream on this connection.
///
/// Per *connection*, not per channel: deciding costs a round trip (an `exec`
/// to read the remote's environment, and on a host with
@@ -356,7 +356,7 @@ impl SshConnection {
}
/// Open a `direct-streamlocal@openssh.com` channel to `socket_path` on the
- /// remote — the preferred way into a remote `tty7-server` (design §7.1).
+ /// remote — the preferred way into a remote `tty7-server`.
///
/// The remote's sshd connects the channel to that Unix socket itself, so the
/// far end sees an ordinary local connection and needs no extra process. The
diff --git a/crates/tty7-core/src/daemon/ssh/sftp.rs b/crates/tty7-core/src/daemon/ssh/sftp.rs
index 56dd020f..083f9afa 100644
--- a/crates/tty7-core/src/daemon/ssh/sftp.rs
+++ b/crates/tty7-core/src/daemon/ssh/sftp.rs
@@ -52,7 +52,7 @@ use crate::daemon::protocol::{
use super::{ConnectionKey, SshConnection, SshManager};
-/// Chunk size for streaming reads/writes (matches the Tabby reference, §6).
+/// Chunk size for streaming reads/writes (matches the Tabby reference).
const CHUNK: usize = 256 * 1024;
/// How long a finished job's final progress lingers for the GUI to observe before
@@ -388,11 +388,20 @@ impl SftpManager {
/// server that runs out of disk reports it on the write or the close, and
/// swallowing that would leave a truncated file for the caller to chmod and
/// rename into place as though it were whole.
+ /// `on_progress` is called with the running total after each chunk lands.
+ /// It runs on the SSH runtime between writes, so it must not block — the
+ /// installer's sink just stores the number.
+ ///
+ /// Counted after `write_all` rather than before, so the figure is bytes the
+ /// transport has accepted rather than bytes we intend to send. It still
+ /// reaches `len` before `flush`/`shutdown` have confirmed anything, which is
+ /// why a full bar is not the installer's success signal — the `Ok` is.
pub fn put_bytes(
&self,
conn: &Arc,
path: &str,
bytes: &[u8],
+ on_progress: &(dyn Fn(u64) + Send + Sync),
) -> Result<(), String> {
SshManager::global().handle().block_on(async {
self.with_session(conn, |sftp| async move {
@@ -401,8 +410,11 @@ impl SftpManager {
.open_with_flags(path.to_string(), flags)
.await
.map_err(|e| format!("{e}"))?;
+ let mut written = 0u64;
for chunk in bytes.chunks(CHUNK) {
file.write_all(chunk).await.map_err(|e| format!("{e}"))?;
+ written += chunk.len() as u64;
+ on_progress(written);
}
file.flush().await.map_err(|e| format!("{e}"))?;
file.shutdown().await.map_err(|e| format!("{e}"))?;
diff --git a/crates/tty7-core/src/daemon/ssh/workspace.rs b/crates/tty7-core/src/daemon/ssh/workspace.rs
index 55b74fd3..a16c94ad 100644
--- a/crates/tty7-core/src/daemon/ssh/workspace.rs
+++ b/crates/tty7-core/src/daemon/ssh/workspace.rs
@@ -1,6 +1,6 @@
-//! Workspace-scoped control requests (design §15, M7).
+//! Workspace-scoped control requests (M7).
//!
-//! A *remote workspace* (design §2, "在上面开发") has no pane on this daemon: its
+//! A *remote workspace* has no pane on this daemon: its
//! panes live on the remote `tty7-server` and reach it through a routed byte
//! pipe. What this side owns is the [`SshConnection`] that pipe rides — the same
//! connection an SSH pane to that host would have used, deduplicated by
diff --git a/crates/tty7-core/src/host/conformance.rs b/crates/tty7-core/src/host/conformance.rs
index fb7efd9a..13e1b7df 100644
--- a/crates/tty7-core/src/host/conformance.rs
+++ b/crates/tty7-core/src/host/conformance.rs
@@ -118,6 +118,8 @@ macro_rules! for_each_host_case {
search_skips_ignored_dirs,
search_respects_limit,
search_respects_max_dirs,
+ // machine inventory
+ shells_are_named_and_have_a_default,
// watch
watch_reports_create_and_delete,
watch_is_non_recursive,
@@ -1041,6 +1043,40 @@ pub fn search_respects_max_dirs(h: &dyn Host, sb: &dyn Sandbox) {
assert_eq!(hit_names(&hits), vec!["needle.txt"]);
}
+// ---------------------------------------------------------------------------
+// machine inventory
+// ---------------------------------------------------------------------------
+
+/// Every row of the new-tab dropdown is launchable and labelled, and the menu
+/// knows which one is the default.
+///
+/// Deliberately not "the list is non-empty": a host with no shell registered
+/// anywhere is a strange machine, not a broken `Host` implementation. What the
+/// dropdown cannot survive is a blank row, a row with nothing to spawn, or two
+/// rows with the same name — the dedupe the local probe does is part of the
+/// contract, not an implementation detail of `/etc/shells` parsing.
+pub fn shells_are_named_and_have_a_default(h: &dyn Host, _sb: &dyn Sandbox) {
+ let inv = h.shells().expect("a host can list its shells");
+ assert!(
+ !inv.default_name.trim().is_empty(),
+ "no default shell name to tag the menu with"
+ );
+ let mut seen = std::collections::HashSet::new();
+ for shell in &inv.shells {
+ assert!(!shell.label.trim().is_empty(), "a shell row with no label");
+ assert!(
+ !shell.program.trim().is_empty(),
+ "shell {:?} has nothing to spawn",
+ shell.label
+ );
+ assert!(
+ seen.insert(shell.label.clone()),
+ "{:?} is listed twice",
+ shell.label
+ );
+ }
+}
+
// ---------------------------------------------------------------------------
// watch
// ---------------------------------------------------------------------------
diff --git a/crates/tty7-core/src/host/local.rs b/crates/tty7-core/src/host/local.rs
index 45e9aae1..a292089f 100644
--- a/crates/tty7-core/src/host/local.rs
+++ b/crates/tty7-core/src/host/local.rs
@@ -29,8 +29,8 @@ use notify::{RecursiveMode, Watcher};
use crate::core::git;
use crate::core::gitignore::GitignoreChain;
use crate::host::{
- Entry, Host, HostId, MTime, Meta, Output, SearchHit, SharedHost, WatchHandle, WatchSub,
- guard_off_ui,
+ Entry, Host, HostId, MTime, Meta, Output, SearchHit, SharedHost, ShellInventory, WatchHandle,
+ WatchSub, guard_off_ui,
};
/// How long changes are collected before a batch is delivered. Matched exactly
@@ -329,6 +329,11 @@ impl Host for LocalHost {
git::git_output(cwd, args)
}
+ fn shells(&self) -> io::Result {
+ guard_off_ui();
+ Ok(crate::core::shells::inventory())
+ }
+
fn watch(&self, dirs: &[PathBuf]) -> io::Result {
guard_off_ui();
local_watch(dirs, Arc::clone(&self.gitignore))
diff --git a/crates/tty7-core/src/host/mod.rs b/crates/tty7-core/src/host/mod.rs
index 35c99f09..4c916cc8 100644
--- a/crates/tty7-core/src/host/mod.rs
+++ b/crates/tty7-core/src/host/mod.rs
@@ -12,8 +12,8 @@
//!
//! # Blocking on purpose
//!
-//! Every method blocks. That is a decision, not an oversight
-//! (`docs/2026-07-27-remote-workspace-impl-contract.md` §1): the trait has to be
+//! Every method blocks. That is a decision, not an oversight: the trait
+//! has to be
//! object-safe because the whole tree holds `Arc`, the server side
//! serves these same calls from a blocking thread pool, and a GPUI
//! `&mut Context` cannot be held across an `.await` anyway — so making the
@@ -46,6 +46,8 @@ use std::sync::Arc;
use std::sync::OnceLock;
use std::thread::ThreadId;
+pub use crate::core::shells::ShellInventory;
+
// ---------------------------------------------------------------------------
// Identity
// ---------------------------------------------------------------------------
@@ -456,7 +458,7 @@ pub trait Host: Send + Sync + 'static {
/// conflict prompt. The post-write metadata is the write's own answer, so
/// it closes the window by construction. It is also one round trip instead
/// of two on every remote save; the control reply already carried it
- /// (contract §6.4), so nothing on the wire moved.
+ ///, so nothing on the wire moved.
///
/// Callers that genuinely don't want it write `.map(|_| ())`.
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result ;
@@ -503,6 +505,18 @@ pub trait Host: Send + Sync + 'static {
/// `GIT_WORK_TREE` cleared, and both output streams captured.
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result;
+ // ----- machine inventory -----------------------------------------------
+
+ /// The shells this host can launch, plus which one a plain new tab lands
+ /// on — the new-tab dropdown's menu.
+ ///
+ /// On the trait rather than beside `detect_shells` because a window bound to
+ /// a remote workspace opens its tabs *over there*: a picker built from this
+ /// computer's `/etc/shells` offers paths that don't exist on the machine the
+ /// spawn actually reaches. Probing is not free (Windows enumerates WSL by
+ /// spawning `wsl.exe`), so callers ask once per machine, not per menu open.
+ fn shells(&self) -> io::Result;
+
// ----- watching --------------------------------------------------------
/// Open a long-lived, non-recursive watch over `dirs` (which may be empty —
diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs
index 5a997376..f7ec17db 100644
--- a/crates/tty7-core/src/host/remote.rs
+++ b/crates/tty7-core/src/host/remote.rs
@@ -44,7 +44,7 @@ use crate::daemon::control::{
ReplyOk,
};
use crate::host::{
- Entry, Host, HostId, Meta, Output, SearchHit, SharedHost, WatchHandle, WatchSub,
+ Entry, Host, HostId, Meta, Output, SearchHit, SharedHost, ShellInventory, WatchHandle, WatchSub,
};
/// A [`Host`] backed by a control connection to another machine.
@@ -357,6 +357,17 @@ impl Host for RemoteHost {
}
}
+ /// Safe to send unguarded: the request landed in control v2, and the
+ /// handshake already refused any peer on another dialect. A server too old
+ /// to know the variant is never on the other end of a live connection — it
+ /// was replaced at install time, or the connection never opened.
+ fn shells(&self) -> io::Result {
+ match self.call(ControlRequest::Shells)? {
+ ReplyOk::Shells(inv) => Ok(inv),
+ other => Err(wrong_shape("a shell inventory", &other)),
+ }
+ }
+
fn watch(&self, dirs: &[PathBuf]) -> io::Result {
let id = match self.call(ControlRequest::WatchOpen {
dirs: wire_paths(dirs),
@@ -593,6 +604,7 @@ mod tests {
crate::daemon::control::feature::CONTROL.into(),
crate::daemon::control::feature::HOST_RPC.into(),
],
+ instance: "test-instance".into(),
}
}
@@ -676,6 +688,33 @@ mod tests {
(host, seen_rx)
}
+ /// The dropdown of a remote window is built from the *server's* shells.
+ /// This is the whole point: a menu filled from the client's `/etc/shells`
+ /// offers `/bin/zsh` on a box whose zsh lives elsewhere, and every pick
+ /// fails to spawn.
+ #[test]
+ fn shells_come_from_the_peer() {
+ let (host, seen) = host_with_peer('/', |req| match req {
+ ControlRequest::Shells => Some((
+ ControlReply::Ok(ReplyOk::Shells(crate::core::shells::ShellInventory {
+ shells: vec![crate::core::shells::DetectedShell {
+ label: "zsh".into(),
+ program: "/usr/bin/zsh".into(),
+ args: vec![],
+ }],
+ default_name: "zsh".into(),
+ })),
+ vec![],
+ )),
+ other => panic!("unexpected request {other:?}"),
+ });
+
+ let inv = host.shells().unwrap();
+ assert_eq!(seen.recv().unwrap(), ControlRequest::Shells);
+ assert_eq!(inv.default_name, "zsh");
+ assert_eq!(inv.shells[0].program, "/usr/bin/zsh");
+ }
+
/// Path arithmetic follows the *peer's* separator, not the client's. On a
/// Windows client this is the difference between `/home/me/src` and
/// `/home/me\src`, and between "absolute" and "drive-relative".
diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs
index f380d8d2..3d66ff98 100644
--- a/crates/tty7-core/src/host/server.rs
+++ b/crates/tty7-core/src/host/server.rs
@@ -8,7 +8,7 @@
//! carries nothing else.
//!
//! That symmetry is not an accident, it is the reason the trait is blocking
-//! (contract §1). A server handler runs on a thread pool, where blocking is what
+//!. A server handler runs on a thread pool, where blocking is what
//! you want, so the identical `LocalHost` that answers a local file tree answers
//! a remote one — no async mirror of every method, and no second implementation
//! to drift.
@@ -124,10 +124,10 @@ impl Services {
}
// ---------------------------------------------------------------------------
-// Attachment / takeover (design §10, D8)
+// Attachment / takeover (D8)
// ---------------------------------------------------------------------------
-/// The live half of design §10's attachment record.
+/// The live half of the attachment record.
///
/// [`Attachment`](crate::core::workspace_store::Attachment) in the store is the
/// *data* — token, hostname, since — and answers "who holds this workspace".
@@ -183,7 +183,7 @@ struct Evicted {
/// Whether the link exists *for* this workspace, and so should be closed
/// with it.
///
- /// Design §10 says to close the displaced session's streams. When the
+ /// The displaced session's streams are closed. When the
/// connection was opened for one workspace — its hello named it — that is
/// exactly right, and it is the strong form of the guarantee: the old client
/// cannot write again even if it is wedged or hostile.
@@ -473,7 +473,7 @@ fn is_disconnect(e: &io::Error) -> bool {
/// The reply goes out **even on a mismatch**, which is the only reason the
/// client can say "the server speaks v2, I speak v1" instead of "the connection
/// dropped" — a `HELLO` carries no `req_id`, so there is no error reply to hang
-/// the mismatch on (contract §6.7).
+/// the mismatch on.
fn handshake(
r: &mut R,
sink: &Sink,
@@ -513,6 +513,7 @@ fn handshake(
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
features,
+ instance: crate::daemon::control::server_instance().to_string(),
}))?;
if hello.control_version != CONTROL_VERSION {
@@ -529,7 +530,7 @@ fn handshake(
/// connections apart even when the same client opens both.
static NEXT_CONN: AtomicU64 = AtomicU64::new(1);
-/// Design §10's takeover, server side: claim `workspace` for this connection and
+/// The takeover, server side: claim `workspace` for this connection and
/// tell whoever held it.
///
/// The order is the whole behaviour. The store's record moves first (so a
@@ -667,7 +668,7 @@ fn submit(conn: &Arc, req_id: u64, req: ControlRequest, blob: Vec) {
fn run_job(conn: &Arc, req_id: u64, req: ControlRequest, blob: Vec) {
// Cheap pre-check: a request cancelled before a worker picked it up is not
// worth running at all. (Once it *has* started there is nothing to do — a
- // filesystem call is not interruptible, so §6.8's "best effort, not
+ // filesystem call is not interruptible, so "best effort, not
// guaranteed" is discharged by discarding the result.)
if conn.is_cancelled(req_id) {
conn.forget(req_id);
@@ -811,6 +812,9 @@ fn run_request(
(ReplyOk::Output(h.git(&p(&cwd), &borrowed)?), Vec::new())
}
+ // ----- machine inventory ---------------------------------------------
+ ControlRequest::Shells => (ReplyOk::Shells(h.shells()?), Vec::new()),
+
// ----- watch ---------------------------------------------------------
ControlRequest::WatchOpen { dirs } => {
let id = conn.open_watch(req_id, &paths(&dirs))?;
@@ -869,7 +873,7 @@ fn run_request(
(ReplyOk::Unit, Vec::new())
}
- // ----- attachment (design §10, D8) -----------------------------------
+ // ----- attachment (D8) -----------------------------------
ControlRequest::WorkspaceAttach { id } => (
ReplyOk::Attached {
took_over_from: attach_workspace(conn, &id, false)?,
@@ -2004,6 +2008,9 @@ mod tests {
stderr: Vec::new(),
})
}
+ fn shells(&self) -> io::Result {
+ self.inner.shells()
+ }
fn watch(&self, dirs: &[PathBuf]) -> io::Result {
self.inner.watch(dirs)
}
@@ -2186,6 +2193,7 @@ mod tests {
separator: '/',
home: "/root".into(),
features: vec![],
+ instance: "other-instance".into(),
})
.encode(&mut s);
});
@@ -3152,7 +3160,7 @@ mod tests {
}
// -----------------------------------------------------------------------
- // Attachment and takeover (design §10, D8)
+ // Attachment and takeover (D8)
// -----------------------------------------------------------------------
/// **D8 in one test.** The newcomer wins, the incumbent is *told* rather
@@ -3213,7 +3221,7 @@ mod tests {
);
}
- /// Design §10 says to close the displaced session's stream, and when the
+ /// The displaced session's stream is closed, and when the
/// connection exists for that one workspace that is exactly right.
#[test]
fn a_dedicated_connection_is_closed_when_its_workspace_is_taken() {
diff --git a/crates/tty7-core/src/lib.rs b/crates/tty7-core/src/lib.rs
index a1354b60..a992cbd2 100644
--- a/crates/tty7-core/src/lib.rs
+++ b/crates/tty7-core/src/lib.rs
@@ -7,7 +7,7 @@
//! `tty7-server` must agree on byte for byte.
//!
//! **This crate must never depend on gpui.** That is the invariant the split
-//! exists to enforce (see `docs/2026-07-27-remote-workspace-design.md` §11);
+//! exists to enforce;
//! `cargo tree -p tty7-core | grep gpui` must stay empty. Where a type genuinely
//! needs a gpui shape — `Config` as a `Global`, `WindowState` as a `Bounds`,
//! `FontFeatures` — the data lives here and the GUI crate adds the gpui-facing
diff --git a/crates/tty7-server/Cargo.toml b/crates/tty7-server/Cargo.toml
index 16e2ef49..11f2fa13 100644
--- a/crates/tty7-server/Cargo.toml
+++ b/crates/tty7-server/Cargo.toml
@@ -26,7 +26,7 @@ tty7-core = { path = "../tty7-core" }
# Sandboxes for the suite: an empty directory per case, removed on drop. The
# server is on this machine, so a local temp dir is a path in its namespace.
tempfile = "3"
-# Workspace records cross the control wire as opaque JSON (contract §6.4), so
+# Workspace records cross the control wire as opaque JSON, so
# `tests/workspace_store.rs` has to build and read one. Dev-only: the binary
# itself still depends on nothing but `tty7-core`.
serde_json.workspace = true
diff --git a/crates/tty7-server/src/main.rs b/crates/tty7-server/src/main.rs
index 65651348..4c2194e6 100644
--- a/crates/tty7-server/src/main.rs
+++ b/crates/tty7-server/src/main.rs
@@ -1,7 +1,7 @@
//! `tty7-server` — the tty7 session daemon with no GUI attached.
//!
-//! This is the binary that runs on the machine a *remote* workspace lives on
-//! (see `docs/2026-07-27-remote-workspace-design.md`). It runs the same
+//! This is the binary that runs on the machine a *remote* workspace lives on.
+//! It runs the same
//! `daemon::server` the local GUI auto-spawns, plus the control listener that
//! backs a remote `Host`; the only difference from the GUI's daemon is that
//! nothing on this side ever opens a window, which is why the code it needs had
@@ -64,6 +64,7 @@ OPTIONS:
--pane Forward to the machine's *pane* socket instead
--control-sock Use
as the control socket instead of the default
--config-dir
Use for the socket, config and session files
+ --protocol Print the dialects this binary speaks, as JSON
-V, --version Print the version and exit
-h, --help Print this help and exit
";
@@ -87,6 +88,22 @@ fn main() -> ExitCode {
println!("tty7-server {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
+ // Before the config dir, the crash handler and the logger, like `--version`:
+ // a client asking what this binary speaks must not touch the machine's
+ // state, and must answer even on a box where the config dir is unwritable.
+ //
+ // One line of JSON on stdout, because the reader is a client parsing SSH
+ // output rather than a person (`install::RemoteProtocol::parse`).
+ if args
+ .iter()
+ .any(|a| a == tty7_core::daemon::install::PROTOCOL_FLAG)
+ {
+ println!(
+ "{}",
+ tty7_core::daemon::install::RemoteProtocol::of_this_build().to_line()
+ );
+ return ExitCode::SUCCESS;
+ }
if args.iter().any(|a| a == "--help" || a == "-h") {
print!("{USAGE}");
return ExitCode::SUCCESS;
@@ -213,7 +230,7 @@ fn run_stdio(args: &[String]) -> io::Result<()> {
// `persist` writes the whole document: the second to save
// silently drops the first's changes. Their attachment
// registries would be separate too, which makes design
- // §10's takeover a no-op between them — both clients would
+ // the takeover a no-op between them — both clients would
// hold the same workspace and neither would be told.
//
// Not attempted when the caller named a socket: starting a
@@ -299,8 +316,8 @@ fn bridge_panes() -> io::Result<()> {
/// server, in both directions, until either side stops.
///
/// Deliberately dumb: it parses nothing. The version handshake this stream
-/// carries is between the *client* and the server at the far end (contract
-/// §6.9), and a bridge that understood the frames would be a third opinion about
+/// carries is between the *client* and the server at the far end, and a bridge
+/// that understood the frames would be a third opinion about
/// the protocol version, which is exactly the coupling the design forbids.
#[cfg(unix)]
fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
@@ -322,7 +339,7 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
// up" into a bridge that never exits and, worse, never closes its stdout, so
// the client at the far end waits forever for an EOF that is sitting in this
// process. Returning lets the process exit, which closes stdout, which is
- // the signal the client is actually waiting for. Design §10's takeover is
+ // the signal the client is actually waiting for. The takeover is
// the case that made this visible: the server closes the displaced session's
// link, and that has to reach the client through this bridge.
let feeder_socket = upstream.try_clone()?;
@@ -363,7 +380,7 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
/// What this machine offers over a control connection, beyond its filesystem.
///
/// The workspace store is the reason this binary exists on a remote box at all:
-/// design §10 puts the workspace list, the tab/pane tree and each pane's cwd on
+/// the workspace list, the tab/pane tree and each pane's cwd live on
/// **the machine the panes run on**, so that connecting from a different laptop
/// shows the same thing. The client's `session.json` keeps only its own view
/// state.
diff --git a/crates/tty7-server/tests/cli.rs b/crates/tty7-server/tests/cli.rs
index ceb4eb1b..9ae58a12 100644
--- a/crates/tty7-server/tests/cli.rs
+++ b/crates/tty7-server/tests/cli.rs
@@ -9,7 +9,7 @@
//!
//! Everything `--stdio` is Unix-only — the flag is refused on Windows, where a
//! machine is reached over its own transport rather than by shipping a server
-//! onto it (contract §8). The plain argument handling below is not, and runs
+//! onto it. The plain argument handling below is not, and runs
//! everywhere.
use std::process::{Command, Stdio};
diff --git a/crates/tty7-server/tests/remote_router.rs b/crates/tty7-server/tests/remote_router.rs
index 88d0358e..0825d062 100644
--- a/crates/tty7-server/tests/remote_router.rs
+++ b/crates/tty7-server/tests/remote_router.rs
@@ -14,7 +14,7 @@
//! unit-tested there.
// Unix-only: the hub this stands up is a Unix-domain socket, which is also the
-// only shape the remote side of a routed connection takes (contract §8). The
+// only shape the remote side of a routed connection takes. The
// Windows client reaches a *remote* server the same way; it is the local hop
// that differs, and `daemon::router` covers that with its own `cfg`.
#![cfg(unix)]
diff --git a/crates/tty7-server/tests/routed_pane.rs b/crates/tty7-server/tests/routed_pane.rs
index 1f8a3335..58d5ce46 100644
--- a/crates/tty7-server/tests/routed_pane.rs
+++ b/crates/tty7-server/tests/routed_pane.rs
@@ -168,6 +168,7 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() {
cwd: Some(dir.path().to_path_buf()),
size: win(),
shell: Some(plain_shell()),
+ owner: None,
}
.encode(&mut sock)
.unwrap();
@@ -258,6 +259,7 @@ fn a_routed_kill_reaches_the_pane_it_names() {
cwd: Some(dir.path().to_path_buf()),
size: win(),
shell: Some(plain_shell()),
+ owner: None,
}
.encode(&mut sock)
.unwrap();
diff --git a/crates/tty7-server/tests/stdio_conformance.rs b/crates/tty7-server/tests/stdio_conformance.rs
index a25004e2..2dc774d0 100644
--- a/crates/tty7-server/tests/stdio_conformance.rs
+++ b/crates/tty7-server/tests/stdio_conformance.rs
@@ -24,7 +24,7 @@
// Unix-only: every case here is a `--stdio` child, and `--stdio` is refused on
// Windows by design — a Windows machine is reached over its own transport, not
-// by shipping a server onto it (contract §8).
+// by shipping a server onto it.
#![cfg(unix)]
use std::io;
diff --git a/crates/tty7-server/tests/workspace_store.rs b/crates/tty7-server/tests/workspace_store.rs
index 85661260..df750aa1 100644
--- a/crates/tty7-server/tests/workspace_store.rs
+++ b/crates/tty7-server/tests/workspace_store.rs
@@ -11,7 +11,7 @@
//!
//! | | Why an in-process socket pair would not do |
//! |---|---|
-//! | The record is on **the server's** disk | The whole storage split (design §10) is "the machine is the authority". A store in the test's own address space proves nothing about that |
+//! | The record is on **the server's** disk | The whole storage split is "the machine is the authority". A store in the test's own address space proves nothing about that |
//! | `workspace-store` is advertised only when served | The capability bit is built from what the *binary* wires up, and that wiring lives in `main.rs` |
//! | A change reaches the **other** connection | Two clients, one server process, one file — the configuration the user actually has when their laptop and their desktop are both connected |
//!
@@ -387,7 +387,7 @@ fn a_change_from_one_client_reaches_the_other() {
assert_eq!(store.len(), 0);
}
-/// **Design §10's takeover, across two real processes.**
+/// **The takeover, across two real processes.**
///
/// The same two-client shape as the change-notification test, and for the same
/// reason: a takeover is by definition something one connection does to
@@ -427,7 +427,7 @@ fn a_later_client_takes_the_workspace_and_the_first_is_cut_off() {
// The displaced client is told which workspace it lost and to whom.
laptop.expect_preempted("w", "desktop");
// …and then its link is closed, because this connection existed for that
- // workspace. Design §10: "关闭它的流".
+ // workspace: the server closes its stream.
let deadline = Instant::now() + Duration::from_secs(10);
while laptop.control.is_connected() {
assert!(
@@ -499,7 +499,7 @@ fn bridged(sock: &Path, token: &str) -> Client {
}
/// [`bridged`], naming the client machine and, optionally, the workspace this
-/// connection is opened *for* — the hello field design §10's takeover keys on.
+/// connection is opened *for* — the hello field the takeover keys on.
fn bridged_for(sock: &Path, token: &str, hostname: &str, workspace: Option<&str>) -> Client {
let hello = ControlHello {
control_version: tty7_core::daemon::control::CONTROL_VERSION,
diff --git a/docs/2026-07-27-remote-workspace-design.md b/docs/2026-07-27-remote-workspace-design.md
deleted file mode 100644
index bd82d634..00000000
--- a/docs/2026-07-27-remote-workspace-design.md
+++ /dev/null
@@ -1,430 +0,0 @@
-# tty7 远程开发:远程 Workspace 设计
-
-> 状态:设计定稿,待实现
-> 日期:2026-07-27
-
-## 1. 一句话
-
-选一台开发机,tty7 给你一个整体就是那台机器的窗口。合上笔记本、重启、断网、换台电脑——里面的东西一直在跑,回来还在原地。
-
-主卖点是 **agent 不再因为合盖而中断**。丢个 shell 忍忍就过去了,丢一个跑了四十分钟的 agent 会话不行。
-
-对老用户还有第二条:**远程终于不再是残的**。今天 SSH 上去,repo 分组、分支、diff、file tree、worktree 全部消失;远程 workspace 里它们全都在。
-
-## 2. 用户模型:只有一条规则
-
-**一个窗口 = 一台机器的一个 workspace。**
-
-- 一个 remote host 可以跑多个 workspace,跟本地一样。
-- 窗口里所有 tab 和 pane 都在那台机器上,不混。
-- 同一台机器可以开好几个窗口;几台机器的窗口并排也行;本地窗口和远程窗口并排也行。
-
-### 与 SSH pane 的区别
-
-这是两个功能,不要混:
-
-| | 连一下(SSH pane) | 在上面开发(远程 workspace) |
-|---|---|---|
-| 干嘛 | 看个日志、重启个服务 | 写代码 |
-| 单位 | 一个 pane | 一个窗口 |
-| 关掉之后 | 没了 | 还在跑 |
-| 入口 | 命令面板 | 首页「连接主机」 |
-
-远程 workspace 里不提供连别的机器的入口。用户自己在 shell 里敲 `ssh` 当然照样能用,但 tty7 不识别、不接管。
-
-**机器只配一次**:远程 workspace 直接用已存的 SSH 配置(`core::ssh_profile` 的 profile、`~/.ssh/config` 的 alias),密码、密钥、跳板机全都现成。不新做一套主机配置 UI。
-
-## 3. 目标与非目标
-
-### v1 做
-
-- Mac / Linux / Windows 都能当客户端
-- 连 Linux 机器;Windows 用户连自己的 WSL 也算
-- 服务自动装
-- 一台机器多个窗口、多台机器并存、和本地窗口混着用
-- 断线自动重连
-- repo 分组、分支、diff、worktree、agent 全套在远程可用
-- 文件浏览、端口转发
-
-### v1 不做
-
-- **两台电脑同时连同一个 workspace** —— 先后连没问题;撞上时是**接管**(§10),不是共享
-- **拿 Windows 当被连的机器** —— 用 WSL
-- **一个窗口里既有本地又有远程** —— 这个**永远不做**
-- **自动同步文件、自动猜端口** —— 手动就够了
-- 远程 workspace 里连别的机器的入口
-- 远程读远程的 `config.json`(§13)
-- 断线超出 replay ring 的输出的持久化补偿(§10)
-- 客户端没运行时的推送通知
-
-## 4. 现状盘点
-
-### 已经成立的地基
-
-| 能力 | 在哪 | 对远程的意义 |
-|---|---|---|
-| 持久 daemon:一条连接一个 pane,`Attach`/`Detach`,断连即 detach、pane 继续裸跑 | `daemon/server.rs`、`daemon/pane.rs` | "合上笔记本还在跑"在本地已经成立,远程要做的是把这个 daemon 挪到对面 |
-| `ReplayRing`:断连期间的输出进环形缓冲,attach 时重放 | `daemon/pane.rs` | 重连补屏直接可用 |
-| `PROTOCOL_VERSION` 握手 + 不兼容时询问用户 | `daemon/spawn.rs::ensure_running` | 远程版本 skew 照搬 |
-| 传输抽象:`Stream = Read + Write + try_clone` | `daemon/transport.rs` | 多一种传输形态不破坏上层 |
-| 原生 SSH 栈:连接复用(`ConnectionKey`)、`direct-tcpip`、session channel、SFTP、known_hosts、auth broker、jump / ProxyCommand / SOCKS5 | `daemon/ssh/*` | 远程 workspace 的传输层几乎白送 |
-| Workspace 模型:`Workspace` = 一组 tab + 窗口几何 + `open` 标记 + 名字,home 页 picker | `core/session.rs` | 远程模型 1:1 照搬,不新造概念 |
-| agent 状态:hook 发 OSC 777 → daemon 侧 sniffer → 客户端 | `core/agent_hooks.rs`、`daemon/pane.rs` | PTY 在哪 sniffer 就在哪,远程天然成立 |
-
-### 反着的那块
-
-右侧那套富功能全部直接读 **GUI 进程自己的**文件系统和 git:
-
-- `ui/file_tree.rs:120` —— `std::fs::read_dir`,注释明写 "no daemon round-trips (the SFTP panel covers the remote case)"
-- `ui/app.rs:3895`、`core/worktree.rs:65` —— `Command::new("git")`,跑在客户端
-- gitignore 判定、`notify` 文件监听、repo 根上溯(`ui/file_tree.rs:628` 的 `.find(|p| p.join(".git").exists())`)也都是本地 fs
-
-所以"全套在远程可用"不是把 daemon 挪过去就顺带有的,它是本设计里最大的一块(§8)。
-
-## 5. 关键决策一览
-
-| # | 决策 | 选了 | 否掉了 | 为什么 |
-|---|---|---|---|---|
-| D1 | 富功能远端化 | 抽 `Host` 抽象层,本地直调 / 远程 RPC | 窄推送 + 复用 SFTP 面板 | 窄方案下 file tree 没有 gitignore 和文件监听,diff overlay 和 code editor 在远程缺失或另写一套,长期两条代码路径并存,最后还得推倒 |
-| D2 | 远程二进制 | 拆出 headless crate,远程只装 `tty7-server` | 远程跑完整 `tty7 --daemon`;同 crate 加 cargo feature | 完整二进制含 gpui/字体/资源且在无头 Linux 上可能因缺 libfontconfig 起不来,而无头机正是目标场景;cargo feature 方案会让 `#[cfg]` 撒遍 `ui/` 和 `core/` |
-| D3 | 谁开 SSH 连接 | 本地 daemon 当转发中枢,GUI 传输层不动 | GUI 内嵌 russh 直连 | SSH 引擎、auth broker、known_hosts、jump 链、端口转发全在本地 daemon;GUI 直连意味着两套 SSH 引擎并存 |
-| D4 | 通道形态 | 每条逻辑流一条 SSH channel,首选 `direct-streamlocal` | 自建多路复用层 | russh 客户端侧有 `channel_open_direct_streamlocal`(`client/mod.rs:854`),远程零 bridge 进程、零 mux 代码 |
-| D5 | 二进制怎么上去 | 客户端下载 + SFTP 上传 | 远程 curl;两者都做并回退 | 内网 / 跳板机后面的机器上不了外网,而那是一大类目标用户;双路径的失败回退边界很难调对 |
-| D6 | 断线时的窗口 | 只读降级 + 状态条 | 整窗遮罩;缓存输入重连后发 | 断线那一刻最想看的就是 agent 断之前输出了什么;缓存输入会在看不见的时候落到一个已经变样的屏幕上 |
-| D7 | 启动时 | 即连,认证 sheet 排队一次弹一个 | 开窗不连等点击;按凭证类型分情况 | "回来还在原地"不该变成"回来再点一下";按凭证分情况会让同一个动作在不同机器上行为不同 |
-| D8 | 两个客户端撞上 | 后来者接管,先来的转 `Preempted` 只读 | 拒绝后来者;并存只读旁观 | 最常见的撞车是"旧机器忘了关",拒绝等于把人锁在门外;并存只读实质就是在做多客户端 |
-| D9 | WSL | 单独一条 stdio 传输 | 要求 WSL 里跑 sshd;v1 不做 WSL | 为一个本机上的发行版配 sshd 很荒谬,也拆了"服务自动装"的台;stdio 传输还顺带让端到端测试不需要 sshd(§17) |
-| D10 | Linux 二进制链接方式 | musl 静态链接 | glibc 动态链接 | 一个二进制通吃所有发行版,不看目标机的 glibc 版本 |
-
-## 6. 架构总览
-
-```
-┌─ 客户端 GUI(gpui) ─────────────────────────────┐
-│ TerminalView×N file_tree / git_diff / │
-│ │ worktree / code_editor │
-│ │ │ │
-│ │ Host trait ◄── 新 │
-│ ▼ ▼ │
-│ RemoteTerminal(现有) LocalHost │ RemoteHost │
-└────────┴────────────────────┴────────────────────┘
- │ 现有 transport:UDS / loopback TCP,不动
- ▼
-┌─ 本地 daemon ────────────────────────────────────┐
-│ 本地 pane(PTY)· SSH pane · SFTP · 端口转发 │
-│ SshManager / PromptBroker / known_hosts / jump │
-│ ── 全部现有,远程 workspace 直接复用 ── │
-│ RemoteRouter ◄── 新:纯字节转发,不解析 │
-└────────┬─────────────────────────────────────────┘
- │ SSH:每条流一条 channel
- │ 首选 direct-streamlocal → 远程 daemon.sock
- │ 回退 session channel + exec tty7-server --stdio
- │ WSL:wsl.exe 子进程的 stdin/stdout
- ▼
-┌─ 远程 tty7-server(headless,无 gpui) ──────────┐
-│ pane registry(DaemonPane,现有代码原样搬) │
-│ workspace store ◄── 新:布局的权威副本存这里 │
-│ Host 服务端 ◄── 新:fs / git / watch RPC │
-└──────────────────────────────────────────────────┘
-```
-
-**GUI 侧传输代码一行不改**。`transport::Stream` 仍然是那条本地流;`Spawn` / `Attach` / control 消息多带一个路由头,说明"去哪台机器"。本地 daemon 对远程流只做字节转发,不解析内容。
-
-## 7. 传输层
-
-### 7.1 SSH 主机
-
-每条逻辑流一条 SSH channel,不自建多路复用:
-
-| 流 | channel 数 | 说明 |
-|---|---|---|
-| 每个 pane | 1 | 对应现有"一条连接 = 一个 pane" |
-| 每个远程 workspace 的控制流 | 1 | Host RPC + workspace store + 事件推送 |
-
-**首选** `direct-streamlocal@openssh.com` 直接接到远程的 daemon socket。OpenSSH 的 `AllowStreamLocalForwarding` 默认为 `yes`。
-
-**回退**:被管理员关掉时(channel open 失败),改用 session channel `exec tty7-server --stdio`——一个纯字节转发的小进程,把自己的 stdin/stdout 接到同一个 unix socket。回退是每连接一次性探测,结果缓存在 `SshConnection` 上,不逐 channel 重试。
-
-同一台机器的多个 workspace 共用一条 `SshConnection`(现有 `ConnectionKey` 的复用逻辑直接生效):一台机器只认证一次。
-
-### 7.2 远程 socket 路径
-
-`$XDG_RUNTIME_DIR/tty7/daemon.sock`,没有 `XDG_RUNTIME_DIR` 时退到 `~/.local/share/tty7/daemon.sock`。`sun_path` 长度限制的 fallback(短路径 + 配置目录哈希)沿用 `transport.rs` 现有实现。
-
-一台机器**一个** `tty7-server`(per user),多个 workspace 在它内部;socket 权限 0600,目录 0700。
-
-### 7.3 WSL
-
-`wsl.exe -d -- tty7-server --stdio`,子进程的 stdin/stdout 就是 `Stream`。无 SSH、无认证、无网络。
-
-## 8. 协议扩展
-
-现有协议是"一条连接一个 pane",控制类只有短连接 `List`。Host 层要的是长连接上的请求/响应,量大且并发。
-
-新增一条 **control 连接**,`PROTOCOL_VERSION` bump 到 **3**。
-
-### 帧格式
-
-沿用外层 `[u32 LE payload_len][u8 kind][payload]`,control 连接的 kind 是新值:
-
-| 形态 | payload 布局 | 用于 |
-|---|---|---|
-| 小请求 / 响应 | `[u64 req_id][JSON]` | `read_dir`、`stat`、`git`、`repo_root`、workspace 读写 |
-| 大 payload | `[u64 req_id][raw bytes]` | `read_file` / `write_file` 的文件内容 |
-| 事件推送 | `[u64 req_id = 0][JSON]` | 文件变更、pane 死亡、agent 状态、被接管通知 |
-
-`req_id` 允许乱序匹配,所以一个慢的 `git` 调用不会堵住 file tree 的目录展开。`req_id == 0` 保留给无请求对应的服务端推送。
-
-热路径(pane 的 `Input` / `Output` / `Snapshot`)不走 control 连接,保持现有的零序列化直传。
-
-## 9. Host 抽象层
-
-```rust
-pub trait Host: Send + Sync {
- fn read_dir(&self, p: &Path) -> io::Result>; // Entry 带 ignored 标记
- fn stat(&self, p: &Path) -> io::Result ; // 含 mtime
- fn read_file(&self, p: &Path) -> io::Result>;
- fn write_file(&self, p: &Path, b: &[u8]) -> io::Result<()>;
- fn create_dir(&self, p: &Path) -> io::Result<()>;
- fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
- fn remove(&self, p: &Path, recursive: bool) -> io::Result<()>;
- fn repo_root(&self, p: &Path) -> io::Result>;
- fn git(&self, cwd: &Path, args: &[&str]) -> io::Result;
- fn watch(&self, dirs: &[PathBuf]) -> WatchSub;
-}
-```
-
-**同步阻塞签名是刻意的。** 这些调用点现在全部已经在 background executor 上跑(`file_tree.rs` 的注释:render 只读缓存,miss 变成排队加载),保持阻塞语义意味着调用点的结构一行不用动,只换实现来源。
-
-`LocalHost` 直调 `std::fs` / `Command::new("git")`,零开销、零往返。`RemoteHost` 走 control 连接的 RPC。
-
-### 三个为"每次往返都要钱"而变形的方法
-
-| 方法 | 天真做法的问题 | 设计 |
-|---|---|---|
-| `read_dir` 的 `ignored` | 客户端自己解析 `.gitignore` 链,一次展开要往返读好几个 `.gitignore` | **服务端算好再返回**。gitignore 解析代码搬进 `tty7-core`,本地与远程共用同一份,一个目录一次往返 |
-| `repo_root` | 现在是逐级 `p.join(".git").exists()` 上溯,远程等于逐级往返 | 提成一个方法,服务端一次走完 |
-| `watch` | 递归 watch 一个大 repo,事件洪水跨网络 | 只 watch **已展开的目录集合**,非递归;服务端按 100ms 窗口合并后批量推送 |
-
-### `git` 的约定
-
-`GIT_OPTIONAL_LOCKS=0` 的只读约定(现在在 `git_status::git` helper 里)下沉到 `Host::git` 的两个实现里,两边一致。远程一次 git 探针 = 一次往返,跨洲可能 200ms+;这是可接受的,因为探针本来就是后台触发(cd / 命令结束 / agent 回合结束),UI 期间显示上一份快照。
-
-### Host 从哪来
-
-一个 workspace 一个 `Arc`,pane 和面板从所属 workspace 拿。本地 workspace 拿 `LocalHost`。
-
-### 要改的调用点
-
-| 文件 | 内容 |
-|---|---|
-| `ui/file_tree.rs` | `read_dir`、gitignore 判定、`notify` watcher、新建 / 重命名 / 删除、`:628` 的 repo root 上溯 |
-| `ui/code_editor.rs` | `:343` `:382` `:642` 的 stat、`:531` 的 write、`:691` 的 read;mtime 冲突检测照旧,走 `Host::stat` |
-| `terminal/git_status.rs` | 分支 + `+N −M` 的 shell-out;`GitStatusCache` 的 key 从 `PathBuf` 变成 `(HostId, PathBuf)` |
-| `terminal/git_diff.rs` | `git diff HEAD`。`ui/diff_overlay.rs` 只消费结果,本身不用改 |
-| `core/worktree.rs` | `git worktree add` / `list`、`is_inside_repo`、`.tty7/.gitignore` 的写入。路径构造是纯字符串,留在客户端 |
-| `ui/app.rs:3895` | 送给 agent 的 diff。这里现有一道显式挡板(`local_cwd()`,注释:"远程 pane 的 cwd 不能用本地 git")——改造后这道挡板拆掉,远程 pane 的 diff 真的能取到 |
-
-## 10. 会话与 workspace 模型
-
-### 存储分工
-
-| 存在哪 | 内容 | 为什么在这边 |
-|---|---|---|
-| **远程** `~/.local/share/tty7/workspaces.json` | workspace 列表与名字、tab / pane 树、每个 pane 的 cwd / pane_id / agent 信息、`last_active` | 换台电脑连过来要看到同一份。这是机器的事实 |
-| **客户端** `session.json` | 「我连过哪些 host 的哪些 workspace」、窗口几何、`open` 标记 | 这是**这台客户端**的视图状态。公司电脑上关掉窗口,不该让家里电脑看不见 |
-
-`Workspace` 加一个字段:
-
-```rust
-pub struct Workspace {
- // ...现有字段不动
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub host: Option, // None = 本地,语义与今天完全一致
-}
-```
-
-远程条目的 `session` 字段在客户端留空——布局的权威在远程,连上之后拉。旧的 `session.json` 没有 `host` 字段,反序列化后全是 `None`,即全部是本地 workspace,与今天行为逐字相同。
-
-`RemoteRef` 指向一个已存的 SSH 配置(profile id 或 `~/.ssh/config` alias 或 `user@host:port`)加上远程侧的 `WorkspaceId`。
-
-**`HostId`** 是客户端进程内对一个 `Arc` 的稳定标识:本地是一个固定值,远程由 `RemoteRef` 里的连接部分派生(同一台机器的多个 workspace 共享同一个 `HostId`,与 §7.1 的连接复用同粒度)。它只在进程内有效,不持久化。
-
-pane 标识在客户端侧是 `(HostId, pane_id)`;`pane_id` 只在单台远程 server 内唯一。
-
-### 首页入口
-
-「连接主机」→ 选一个已存的 SSH 配置 → 连上(首次触发安装,§12)→ 列出这台机器上已有的 workspace + 「新建」。新建的 workspace 落在 `~`,名字按现有 `Workspace::display_name` 的规则从 tab 的 repo / cwd 推导。
-
-### 连接状态机
-
-```
-Disconnected ──connect──> Connecting ──✓──> Attached
- │ ✗
- ▼
- Failed(状态条给 [重试])
-
-Attached ──网络断──> Reconnecting ──✓──> Attached
- 只读 + 状态条,指数退避 1/2/4/…/30s 封顶,无限重试
-
-Attached ──别处 attach──> Preempted
- 只读 + [抢回],不自动重连
-```
-
-**永不自动关窗**,任何失败态都停在窗口里等用户处置。
-
-**只读降级的具体表现**:窗口照常显示,能滚历史、能选能复制、能 ⌘F 搜索;键盘输入不生效,底部一条"未连接 — 输入暂不生效",顶部一条状态条写当前状态。输入**不缓存**(见 D6)。
-
-**重连流程**:control 连接重建 → 拉 workspace 布局 → 对每个 pane 重开 channel + `Attach` + replay 补屏 → 以新客户端的尺寸 `Resize`。
-
-**补屏的诚实边界**:断得太久、输出太多,`ReplayRing`(默认几 MB)会滚掉最早的部分,那时以 daemon 当前的 grid 快照为准,中间那段是真的丢了。这与今天本地 daemon 的行为一致,不额外承诺。
-
-### 接管
-
-远程 server 为每个 workspace 记录当前 attach 的客户端会话(一个随机 token + 客户端主机名)。新的 attach 到来时,向旧会话推送 `Preempted { by: <主机名> }` 然后关闭它的流。旧客户端转入 `Preempted` 状态,状态条写"已在 <主机名> 上打开",给一个 [抢回] 按钮——点了就是反向再接管一次。
-
-### 启动时
-
-`open: true` 的远程 workspace 在启动时立即重开并连接。需要认证的窗口**一次只弹一个 sheet**,其余排队;不需要认证的(密钥、ssh-agent)并行连。
-
-## 11. crate 拆分
-
-`src/daemon/` 依赖的 core 模块只有 7 个:`agent_hooks` `cli_agent` `config` `osc` `proc` `shells` `threads`。其中真正沾 gpui 的**只有 `config` 一个文件的一行** `use gpui::{FontFeatures, Global}`(`cli_agent` 的两处 gpui 只是注释)。
-
-| 搬进 `tty7-core` | 处理方式 |
-|---|---|
-| `daemon/*`(protocol、pane、server、ssh、transport、shell_integration…) | 原样搬,零改动 |
-| `core/{osc, proc, shells, threads, agent_hooks, cli_agent}` | 原样搬 |
-| `core/config` | `font_features` 在 core 里存 `HashMap`,GUI 侧转 `gpui::FontFeatures`;`impl Global` 留在 GUI |
-| `core/session` 的数据部分(`SessionPane` / `SessionTab` / `Workspace` / `Workspaces`) | 纯 serde,搬。`WorkspaceStore`(gpui `Global` + `claim` / `focus` / `rename`)留在 GUI |
-| `core/worktree`、gitignore 解析、`git_status` 的 shell-out helper | 搬——服务端要用同一份 |
-| `core/crash` | 搬(远程 server 崩了也要写 crash.log) |
-| 留在 GUI crate | `ui/*`、`terminal/*`、`core/{actions, window_state, update}` |
-
-产物:
-
-```
-tty7-core 无 gpui,protocol / daemon / pty / ssh / Host 的两个实现 / 服务端 RPC
-tty7 GUI bin,依赖 gpui + tty7-core
-tty7-server headless bin,只依赖 tty7-core
-```
-
-CI 增加 `x86_64-unknown-linux-musl` 和 `aarch64-unknown-linux-musl` 两个 target,产出 `tty7-server` 的静态二进制(D10)。
-
-## 12. 安装、启动、版本
-
-```
-1. uname -sm → Linux x86_64
-2. SFTP stat ~/.local/share/tty7/bin/tty7-server-<客户端版本>
-3. 不在 → 客户端 GET GitHub Release asset + sha256 校验
-4. SFTP put → bin/.tty7-server-.tmp
-5. chmod 0755 → rename(原子)
-6. direct-streamlocal 试连远程 daemon socket
- 连不上 → exec 一次 tty7-server --daemon(setsid 脱离)→ 重试
-```
-
-第 6 步就是远程版的 `spawn::ensure_running`。
-
-**首次连一台新机器时,安装那一步给一次明确确认**(写哪个路径、多大、从哪来),之后同一台机器的升级静默。往别人机器上写二进制值得问一次。
-
-**全程不用 sudo**,只碰 `$HOME`。
-
-**版本不匹配**照搬 `spawn::ensure_running`:握手比 `PROTOCOL_VERSION`,兼容就继续用旧 server;不兼容就问用户"保留旧会话(继续用旧方言)还是重启服务(丢掉正在跑的 pane)"。二进制路径带版本号所以能并存,但 socket 只有一个——并存的是文件,不是运行中的服务。
-
-**WSL 的安装**不走下载:直接把客户端自带的 Linux 二进制拷到 `\\wsl$\\home\\.local\share\tty7\bin\`。这要求 Windows 客户端的安装包里带一份 `tty7-server` 的 Linux musl 二进制。
-
-## 13. 配置归属
-
-**客户端的 `config.json` 是唯一权威,远程机器上不需要 config.json。**
-
-服务端需要知道的字段(`shell`、`shell_args`、`agent_commands`、`restore_agent_sessions`)随 `Spawn` / 控制消息下发。现有 `ShellSpec` 已经是这个做法,照着扩。
-
-远程 workspace 窗口里的 Settings 页显示、修改的都是客户端配置,与本地窗口无差别。
-
-## 14. agent 集成
-
-链路在远程与本地同构,只换了位置:
-
-```
-远程 agent 进程
- └─ hook 调 tty7-server agent-hook
- └─ 写 OSC 777 到控制终端
- └─ 远程 server 的 sniffer 收进 pane 状态
- └─ control 连接推送到客户端
- └─ tab 状态点 / 通知 / tray 图标
-```
-
-要改的两处:
-
-- hook emitter 的命令从 `tty7 agent-hook` 变成远程的 `tty7-server agent-hook`(同一份代码,换 bin)。
-- `TTY7` env marker 由远程 server 在 spawn 时注入(现有逻辑原样搬)。
-
-Settings → Agents 的"安装 hooks"动作,在远程 workspace 下作用于**远程机器**(走 `Host::write_file`)。
-
-## 15. 端口转发与文件传输
-
-### 端口转发
-
-远程 workspace 下,转发的归属从 **pane 变成 workspace**(现有 `SshForwardRegistry` 按 `pane_id` 键,要加一个 workspace 维度)。转发跑在该 workspace 所属的 `SshConnection` 上,现有 `daemon/ssh/forward.rs` 直接可用。
-
-⌘/Ctrl-click 远程 pane 里的 `localhost:PORT`:在该 workspace 的连接上按需建一条 local forward,再用本地浏览器打开。这是**按需**,不是自动扫描——"自动猜端口"不做。
-
-**WSL 例外**:WSL 与 Windows 共享 localhost,不需要任何转发,⌘-click 直接开浏览器。
-
-### 文件传输
-
-| 场景 | 走哪 |
-|---|---|
-| file tree 浏览、打开、保存、新建 / 重命名 / 删除 | `Host`(统一,走 control 连接) |
-| 大文件上传 / 下载、拖到 Finder | 现有 SFTP 面板,同一条 SSH 连接 |
-| WSL 的大文件传输 | 没有 SFTP;走 `Host::read_file` / `write_file`,或直接用 `\\wsl$` 路径 |
-
-## 16. 安全
-
-| 面 | 措施 |
-|---|---|
-| 二进制来源 | GitHub Release + sha256 校验(release 里带 checksums 文件),校验失败即中止,不装 |
-| 权限范围 | 不用 sudo,只写 `$HOME`;目录 0700,socket 0600 |
-| 通道信任边界 | `direct-streamlocal` 只有已认证的 SSH 会话能开,等价于 SSH 本身的信任边界。tty7 自身的通信**不开任何监听端口**(用户显式要求的端口转发是另一回事,见 §15) |
-| 主机认证 | 沿用现有 known_hosts(新主机 / 变更主机的 GUI 确认 sheet) |
-| 首次写入的知情 | 首次安装给一次明确确认(§12) |
-
-## 17. 错误处理与降级
-
-| 情况 | 行为 |
-|---|---|
-| 远程没装 git | `Host::git` 返回错误;分支 / diff / worktree 优雅缺省(跟本地非 repo 目录同路径),file tree 照常工作,`ignored` 全为 false |
-| `AllowStreamLocalForwarding no` | 自动回退 stdio bridge(§7.1),用户无感 |
-| 远程磁盘满 / 无写权限 | 安装报明确错误(路径 + 原因),不重试,不降级到别的路径 |
-| 远程 server 崩了 | 客户端的 pane 流全断 → 走 `Reconnecting`;重连时 `ensure_running` 把它拉起来。**布局不丢**(远程的 `workspaces.json` 是持久化的),但 pane 进程没了,按现有"pane 不存在"的路径处理:依 `workspaces.json` 里的 cwd / agent 信息重新 spawn,agent 走现有的 `--resume` 恢复 |
-| control 连接断但 pane 流还活着 | 不允许——control 连接是 workspace 的生命线,它断了就整个 workspace 转 `Reconnecting` |
-| 单个 `Host` RPC 超时 | 该请求返回 `TimedOut`,调用点显示上一份缓存 / 加载态,不影响其它请求(`req_id` 乱序匹配) |
-| sha256 不匹配 | 中止安装并明确报出来,不静默重试、不降级到无校验安装 |
-
-## 18. 验证策略
-
-最重要的一条:**stdio 传输让远程 workspace 能在 CI 里端到端测,不需要 sshd、不需要网络**——同机起一个 `tty7-server --stdio` 子进程,跑完整的"远程" workspace 流程。
-
-| 层 | 怎么测 |
-|---|---|
-| `Host` trait | 一套 conformance 测试,`LocalHost` 和 `RemoteHost` 都跑,逐条比对结果 |
-| 协议 | round-trip(照搬 `protocol.rs` 现有模式)+ 版本 skew 的握手分支 |
-| 传输 | streamlocal 与 stdio 回退各一个集成测试 |
-| 状态机 | 重连退避、接管、启动排队认证——纯单元测试,不碰网络 |
-| 安装 | `uname` 解析、版本路径构造、原子替换、sha256 失败路径 |
-| 端到端 | stdio 传输跑通"开 workspace → 开 pane → 断开 → 重连补屏 → 接管" |
-| 回归护栏 | M1 / M2 是纯重构,现有全部测试必须逐条绿,不允许改测试来适配 |
-
-## 19. 里程碑
-
-前两步是**纯重构、零行为变化、CI 必须全绿**——这让这份大 spec 有一段安全的前半程。
-
-| | 内容 | 完成标志 |
-|---|---|---|
-| M1 | crate 拆分(§11) | 本地功能一个不少,`tty7-server` 能在无头 Linux 上跑起来 |
-| M2 | `Host` trait + `LocalHost`,改造全部调用点(§9) | 行为逐字不变 |
-| M3 | control 连接 + Host 服务端 RPC(§8) | stdio 传输在本机端到端跑通 |
-| M4 | SSH 传输 + 安装 + 版本协商(§7.1、§12) | 能连一台真的远程机器 |
-| M5 | workspace 模型 + 首页入口 + 窗口绑定(§10) | 一台机器多窗口、多机器并存、和本地混开 |
-| M6 | 状态机:重连 / 接管 / 启动即连(§10) | 拔网线再插回来 |
-| M7 | 端口转发 + SFTP 在远程 workspace 下接线(§15) | 远程起的 dev server,⌘-click `localhost:3000` 能在本地浏览器打开;拖文件到 Finder 能下来 |
-| M8 | WSL(§7.3、§12) | Windows 上「连接主机」能选到本机 WSL 发行版,全套功能与 SSH 主机一致 |
diff --git a/docs/2026-07-27-remote-workspace-impl-contract.md b/docs/2026-07-27-remote-workspace-impl-contract.md
deleted file mode 100644
index f10d14f6..00000000
--- a/docs/2026-07-27-remote-workspace-impl-contract.md
+++ /dev/null
@@ -1,1380 +0,0 @@
-# 远程 Workspace:实施契约
-
-> 状态:契约定稿,供并行实现
-> 日期:2026-07-27
-> 配套:[`2026-07-27-remote-workspace-design.md`](./2026-07-27-remote-workspace-design.md)(下称"设计文档")
-
-## 0. 这份文档是什么
-
-设计文档定方向,**这份定接口**。签名、字节布局、kind 数值、文件归属、测试清单 —— 都是可以直接抄进代码的形态。
-
-**读法**:
-
-| 你是 | 先读 |
-|---|---|
-| 任何 agent | §1 岔路口裁决、§9 模块归属、§11 设计文档勘误 |
-| A1(crate 拆分) | §9、§11 的 11/12/14/15 条 |
-| A2(Host + LocalHost) | §2 §3 §4 §10 |
-| A3(调用点改造) | §1 §2 §5 §11 的 2/3/9/10/13 条 |
-| A4(control wire + RemoteHost) | §6 §7 §10 |
-| A5(control server + tty7-server) | §6 §7 §8 |
-
-**冲突仲裁**:本文档与设计文档冲突时,**以本文档为准**;§11 逐条记录了偏差与理由。
-
-**行号约定**:正文里 `src/daemon/…` / `src/core/…` 形式的行号引用取自 **M1 拆分前**的树。A1 已经把它们搬到 `crates/tty7-core/src/{daemon,core}/…`(模块路径刻意保持不变,见 §9.1),行号大体不动。`src/ui/…` / `src/terminal/…` 的引用仍然有效。
-
----
-
-## 1. 岔路口裁决:阻塞 vs 异步
-
-### 裁决
-
-**`Host` trait 保持同步阻塞签名。所有调用点一律搬到 background executor,不做例外。**
-
-### 为什么不做 async trait
-
-| 理由 | 展开 |
-|---|---|
-| **object safety** | 全树需要 `Arc`(设计文档 §9"一个 workspace 一个 `Arc`")。`async fn` in trait 目前不 object-safe,只能 `#[async_trait]` 装箱,`LocalHost` 每次 `stat` 都要一次堆分配 —— 而 `LocalHost` 是 99% 的路径 |
-| **服务端复用** | `tty7-server` 侧的 RPC handler 跑在线程池上,本来就是阻塞的。同一个 `LocalHost` 实例既服务本地 GUI 又服务远程客户端,只有阻塞签名能一份代码两处用 |
-| **传染性** | GPUI 的 `&mut Context` 不能跨 `.await` 持有。把 Host 变 async 不会减少调用点改造量 —— 该拆的还是要拆,只是多背一个 async runtime |
-| **既有先例** | `terminal/view.rs:3963-3980` 的远程路径补全已经是这个形状:`cx.spawn` → `cx.background_spawn(阻塞调用)` → `this.update` 落地。它工作良好,照抄即可 |
-
-### 但设计文档 §9 那句话是错的,必须正面处理
-
-> 设计文档 §9:"这些调用点现在**全部已经**在 background executor 上跑……保持阻塞语义意味着调用点的结构一行不用动。"
-
-**实测不成立。** 已经在 background 上的只有 `file_tree` 的 `read_dir` + gitignore 编译(`request_load` :448-479)。以下全部在 **UI 线程同步跑**:
-
-| 文件 | 行 | 调用 | 所在函数 |
-|---|---|---|---|
-| `ui/code_editor.rs` | 328 | `path.canonicalize()` | `open_file_in_editor` |
-| `ui/code_editor.rs` | 343 | `std::fs::metadata` | `open_file_in_editor` |
-| `ui/code_editor.rs` | 361 | `std::fs::read` | `open_file_in_editor` |
-| `ui/code_editor.rs` | 382 | `std::fs::metadata` | `open_file_in_editor` |
-| `ui/code_editor.rs` | 531 | `std::fs::write` | `editor_save_active` |
-| `ui/code_editor.rs` | 535 | `std::fs::metadata` | `editor_save_active` |
-| `ui/code_editor.rs` | 642 | `std::fs::metadata` | `editor_handle_external_change` |
-| `ui/code_editor.rs` | 691 | `std::fs::read_to_string` | `editor_reload_from_disk` |
-| `ui/code_editor.rs` | 697 | `std::fs::metadata` | `editor_reload_from_disk` |
-| `ui/file_tree.rs` | 953 | `File::create_new` | `file_tree_commit_edit` |
-| `ui/file_tree.rs` | 957 | `fs::create_dir` | `file_tree_commit_edit` |
-| `ui/file_tree.rs` | 963 | `to.exists()` | `file_tree_commit_edit` |
-| `ui/file_tree.rs` | 967 | `fs::rename` | `file_tree_commit_edit` |
-| `ui/file_tree.rs` | 998 | `path.is_dir()` | `file_tree_delete` |
-| `ui/file_tree.rs` | 1013/1015 | `remove_dir_all` / `remove_file` | `file_tree_delete` |
-| `ui/app.rs` | 3997-4005 | `Command::new("git")` ×2 | `send_git_diff_to_agent` |
-| `core/worktree.rs` | 62-73 | `Command::new("git")` | `git` helper(同步调用方待查) |
-
-**后果**:M2 **不是**"只换实现来源"。M2 包含一次真实的异步化改造,且它**不是零行为变化**(见下表)。里程碑表 §19 说"M1 / M2 是纯重构、零行为变化"—— M1 是,M2 不是。这个预期要在 M2 开工前对齐。
-
-### 允许的行为变化(M2 唯一豁免清单)
-
-| 调用点 | 改造前 | 改造后 | 用户可见差异 |
-|---|---|---|---|
-| `open_file_in_editor` | 同步打开 | tab 立刻出现,内容异步填 | 大文件/远程时先看到空 tab + loading |
-| `editor_save_active` | 同步落盘 | 异步落盘,落地前 buffer 标 `saving` | ⌘S 后 dirty 标记延迟一帧清除 |
-| `file_tree_commit_edit` | 同步创建/改名 | 异步,乐观插入行 | 失败时行会消失 + 通知 |
-| `file_tree_delete` | 同步删除 | 异步,乐观移除行 | 同上 |
-| `send_git_diff_to_agent` | 同步 `git diff` | 异步 | 大 repo 上不再卡 UI(改善) |
-
-**除此之外任何行为变化都是 bug。**
-
-### 强制模式:`HostOps`
-
-GUI 侧禁止直接 `host.stat(...)`。一律走 `ui/host_ops.rs` 的门面(§5)。理由:
-- 单一出口便于加 in-flight 去重、staleness 校验、错误通知
-- `debug_assert` 守卫能集中在一处,把"谁又在 UI 线程上阻塞了"变成一个 panic 而不是一次卡顿
-
----
-
-## 2. `Host` trait 最终签名
-
-**位置**:`crates/tty7-core/src/host/mod.rs`
-**负责人**:A2
-
-```rust
-use std::io;
-use std::path::{Path, PathBuf};
-use std::sync::Arc;
-use std::time::Duration;
-
-// ---------------------------------------------------------------------------
-// 标识
-// ---------------------------------------------------------------------------
-
-/// 客户端进程内对一个 `Arc` 的稳定标识。**不持久化**,只做
-/// 进程内 key(`GitStatusCache`、pane 标识、in-flight 表)。
-///
-/// - `HostId::LOCAL` 是常量 0,本地 host 永远是它。
-/// - 远程由 `RemoteRef` 的**连接部分**(不含 `WorkspaceId`)派生:同一台机器的
-/// 多个 workspace 共享一个 `HostId`,与 §7.1 的 `SshConnection` 复用同粒度。
-#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)]
-pub struct HostId(pub u64);
-
-impl HostId {
- pub const LOCAL: HostId = HostId(0);
-
- /// 从连接标识派生。`key` 必须是规范化后的连接串(见 §4.2),
- /// 相同机器必须产出相同串。碰撞到 0 时强制搬到 1 —— 0 是本地的保留值。
- pub fn from_connection_key(key: &str) -> HostId {
- let h = fnv1a64(key.as_bytes());
- HostId(if h == 0 { 1 } else { h })
- }
-
- pub fn is_local(self) -> bool {
- self == HostId::LOCAL
- }
-}
-
-/// 与 `transport::socket_path_for` 同款 FNV-1a:跨编译器/std 版本稳定。
-/// A1 把它从 `daemon/transport.rs` 提到 `tty7-core` 的公共位置,两处共用一份。
-pub fn fnv1a64(bytes: &[u8]) -> u64 { /* 照搬 transport.rs:59-66 */ }
-
-// ---------------------------------------------------------------------------
-// 辅助类型
-// ---------------------------------------------------------------------------
-
-/// 一个目录项。**没有 `path` 字段** —— 路径由调用方 `Host::join(dir, &e.name)`
-/// 重建。原因:远程路径的分隔符是远端的,`PathBuf::join` 在 Windows 客户端上
-/// 会吐出 `/home/me\src`(见 §4.3)。
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct Entry {
- pub name: String,
- pub is_dir: bool,
- /// 符号链接本身是否是 link。`is_dir` 已经**跟随**链接解析过
- /// (与 `SftpEntry.target_is_dir` 的语义一致)。
- pub is_symlink: bool,
- /// 服务端算好的 gitignore 判定(含 `.git` 本身恒为 true)。
- /// 目录不在任何 repo 里,或远程没装 git 时,恒为 `false`。
- pub ignored: bool,
-}
-
-/// 文件元信息。刻意不是 `std::fs::Metadata`(不可构造、不可序列化)。
-#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct Meta {
- pub is_dir: bool,
- pub is_symlink: bool,
- pub len: u64,
- /// `None` = 平台/文件系统没有 mtime。
- pub mtime: Option,
- pub readonly: bool,
-}
-
-/// 精确到纳秒的 mtime。**不用毫秒**:`code_editor` 的外部变更检测靠
-/// mtime 相等判断"这是我们自己刚写的回声",毫秒粒度会把同毫秒内的真实外部
-/// 修改吞掉。**不用 `u128` 纳秒**:JSON 数字放不下 u128 而不失精度。
-#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
-pub struct MTime {
- /// Unix epoch 起的整秒,可为负(1970 之前)。
- pub secs: i64,
- /// 0..1_000_000_000。
- pub nanos: u32,
-}
-
-/// 一次子进程执行的结果。刻意不是 `std::process::Output`
-/// (`ExitStatus` 不可跨平台构造)。
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct Output {
- /// `None` = 被信号杀死 / 无法取得退出码。
- pub status: Option,
- pub stdout: Vec,
- pub stderr: Vec,
-}
-
-impl Output {
- pub fn success(&self) -> bool { self.status == Some(0) }
- /// stdout 按 UTF-8 lossy 解读并 trim —— 三个既有 git helper 的公共形状。
- pub fn stdout_trimmed(&self) -> String {
- String::from_utf8_lossy(&self.stdout).trim().to_string()
- }
- pub fn stderr_trimmed(&self) -> String {
- String::from_utf8_lossy(&self.stderr).trim().to_string()
- }
-}
-
-/// 一个**长命**的文件监听订阅。dir 集合可变 —— file tree 每次展开都会改动
-/// 集合,重建整个订阅代价太大(远程等于一次往返 + 服务端重建 notify)。
-///
-/// Drop 即退订(远程侧发 `watch.close`)。
-pub struct WatchSub {
- rx: smol::channel::Receiver>,
- inner: Box,
-}
-
-impl WatchSub {
- /// 批量事件流。服务端已按 100ms 窗口合并去重(§6.6);本地实现同样合并,
- /// 两边行为一致。
- pub fn events(&self) -> &smol::channel::Receiver> { &self.rx }
-
- /// 替换监听的目录集合。差量由实现自己算,调用方只给全集。
- /// 一律**非递归**。
- pub fn set_dirs(&self, dirs: &[PathBuf]) -> io::Result<()> { self.inner.set_dirs(dirs) }
-}
-
-pub trait WatchHandle: Send + Sync {
- fn set_dirs(&self, dirs: &[PathBuf]) -> io::Result<()>;
-}
-
-// ---------------------------------------------------------------------------
-// Host
-// ---------------------------------------------------------------------------
-
-/// 一台机器的文件系统 + git 出口。
-///
-/// **所有方法都是阻塞的,且禁止在 UI 线程上调用**(§1)。GUI 侧一律经
-/// `ui::host_ops::HostOps`,它负责 `background_spawn` 与落地。
-///
-/// object-safe 是硬要求:全树持 `Arc`。
-pub trait Host: Send + Sync + 'static {
- // ----- 标识 ------------------------------------------------------------
-
- fn id(&self) -> HostId;
-
- /// 该 host 的路径分隔符。本地 = `std::path::MAIN_SEPARATOR`,
- /// 远程 Linux/WSL 恒为 `'/'`。
- fn separator(&self) -> char;
-
- // ----- 路径算术(§4.3 强制走这里,禁止 `Path::join`) -------------------
-
- /// `dir` + `name`。用 `self.separator()`,不用 `PathBuf::push`。
- fn join(&self, dir: &Path, name: &str) -> PathBuf {
- default_join(dir, name, self.separator())
- }
-
- /// 该 host 语义下 `p` 是否是绝对路径。**必须用它,不用 `Path::is_absolute`**
- /// —— Windows 客户端上 `/home/me` 的 `is_absolute()` 是 `false`
- /// (drive-relative),这正是 `local_cwd()` 挡板文档里那个陷阱。
- fn is_absolute(&self, p: &Path) -> bool;
-
- // ----- 读 --------------------------------------------------------------
-
- /// 一个目录的**已排序**列表(目录在前,然后按 lowercase 名字),
- /// 排序规则与 `file_tree::sort_entries` 逐字一致,服务端排好。
- ///
- /// `root` 是 gitignore 链的上界(`Entry::ignored` 从 root 走到 dir 逐级
- /// 求值,深者胜,`!` 白名单反转)。`root` 为 `None` 时 `ignored`
- /// 除 `.git` 外全 false。
- ///
- /// 隐藏文件**不过滤** —— `show_hidden` 是 UI 状态,留在客户端。
- fn read_dir(&self, dir: &Path, root: Option<&Path>) -> io::Result>;
-
- fn stat(&self, p: &Path) -> io::Result ;
-
- /// `p` 存在与否。`stat().is_ok()` 的省流版;实现可以合并成一次往返。
- fn exists(&self, p: &Path) -> bool {
- self.stat(p).is_ok()
- }
-
- /// 读整个文件。`max_bytes` 是**服务端**上限:超过就返回
- /// `ErrorKind::FileTooLarge`(映射见 §6.5)而不是传一遍再丢掉。
- /// 传 `MAX_FILE_BYTES`(`code_editor` 的既有常量)。
- fn read_file(&self, p: &Path, max_bytes: u64) -> io::Result>;
-
- /// 规范化。远程实现走服务端 `canonicalize`。失败时调用方沿用既有习惯
- /// (`unwrap_or_else(|_| p.to_path_buf())`)。
- fn canonicalize(&self, p: &Path) -> io::Result;
-
- /// 广度优先的名字包含匹配,**服务端执行**。
- ///
- /// 这是设计文档 §9 漏掉的方法。`file_tree.rs::TreeLoader::search` 最多访问
- /// 2000 个目录才停 —— 逐目录 RPC 等于 2000 次往返,跨洲 = 400 秒。必须整个
- /// 搬到服务端。语义与既有实现逐字一致:BFS、跳过 ignored 目录、`limit` 命中
- /// 即停、`max_dirs` 访问上限即停。
- fn search(&self, roots: &[PathBuf], query: &str, limit: usize, max_dirs: usize)
- -> io::Result>;
-
- // ----- 写 --------------------------------------------------------------
-
- fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<()>;
-
- /// 排他创建,已存在即 `AlreadyExists`(对应 `File::create_new`)。
- fn create_file_new(&self, p: &Path) -> io::Result<()>;
-
- /// `recursive = false` → `fs::create_dir`;`true` → `create_dir_all`。
- /// (设计文档只给了 `create_dir`;`worktree` 写 `.tty7/.gitignore` 要 all。)
- fn create_dir(&self, p: &Path, recursive: bool) -> io::Result<()>;
-
- /// 目标已存在时必须返回 `AlreadyExists`,**由实现保证**,不靠调用方先
- /// `exists()` 探一次(那是一次多余往返 + TOCTOU)。
- fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
-
- /// `recursive` 只对目录有意义。文件传 `false`。
- fn remove(&self, p: &Path, recursive: bool) -> io::Result<()>;
-
- // ----- git -------------------------------------------------------------
-
- /// `p` 所在仓库的工作树根:最近的、含 `.git`(目录或 worktree 文件)的祖先。
- /// 服务端一次走完,不逐级往返。
- fn repo_root(&self, p: &Path) -> io::Result>;
-
- /// `git -C `。
- ///
- /// 两个实现**必须**统一到同一份不变量(§4.4):
- /// `GIT_OPTIONAL_LOCKS=0` + `stdin(null)` + Windows 上 `hide_console`。
- ///
- /// 返回 `Ok(Output)` 表示"git 跑起来了",退出码在 `Output::status` 里。
- /// `Err` 只表示"没跑起来"(git 不存在、cwd 不存在、RPC 失败/超时)。
- fn git(&self, cwd: &Path, args: &[&str]) -> io::Result;
-
- // ----- 监听 ------------------------------------------------------------
-
- /// 建立一个长命订阅,初始集合为 `dirs`(可空)。**非递归**。
- fn watch(&self, dirs: &[PathBuf]) -> io::Result;
-
- // ----- 生命周期 --------------------------------------------------------
-
- /// 该 host 当前是否可用。`LocalHost` 恒 `true`;`RemoteHost` 在
- /// `Reconnecting` / `Preempted` 期间为 `false`,调用点据此显示上一份缓存
- /// 而不是错误。
- fn is_connected(&self) -> bool { true }
-}
-
-/// `search` 的一条命中。`path` 是**绝对**路径(服务端用自己的分隔符拼好)。
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub struct SearchHit {
- pub name: String,
- pub path: PathBuf,
- pub is_dir: bool,
- pub ignored: bool,
-}
-
-/// 便利别名,全树用它。
-pub type SharedHost = Arc;
-```
-
-### 方法清单与设计文档的差异
-
-| 设计文档 §9 | 本契约 | 变化 |
-|---|---|---|
-| `read_dir(&self, p)` | `read_dir(&self, dir, root)` | 加 `root`:gitignore 链需要上界 |
-| — | `search(...)` | **新增**,见上(2000 次往返问题) |
-| — | `exists`, `canonicalize` | **新增**,既有调用点用得到(`file_tree:963/998`、`code_editor:328`) |
-| — | `create_file_new` | **新增**,`file_tree:953` 用 `create_new` 语义 |
-| `create_dir(&self, p)` | `create_dir(&self, p, recursive)` | `worktree` 要 `create_dir_all` |
-| `read_file(&self, p)` | `read_file(&self, p, max_bytes)` | 服务端截断,省一次全量传输 |
-| — | `id`, `separator`, `join`, `is_absolute` | **新增**,跨 OS 路径算术(§4.3) |
-| — | `is_connected` | **新增**,降级态需要它 |
-| `watch(&self, dirs) -> WatchSub` | 同名,`WatchSub` 可 `set_dirs` | 订阅长命,集合可变 |
-
----
-
-## 3. `LocalHost`
-
-**位置**:`crates/tty7-core/src/host/local.rs`
-**负责人**:A2
-
-```rust
-pub struct LocalHost { /* 无状态,watcher 除外 */ }
-
-impl LocalHost {
- pub fn new() -> Arc { Arc::new(Self { .. }) }
-}
-```
-
-| 约束 | 说明 |
-|---|---|
-| **零往返、零分配开销** | 每个方法直调 `std::fs` / `Command`。除了 `Vec`/`String` 的必要分配,不许有额外拷贝 |
-| **`id()` 恒为 `HostId::LOCAL`** | |
-| **`separator()`** | `std::path::MAIN_SEPARATOR` |
-| **gitignore** | 用 A1 已提取的 `core::gitignore::GitignoreChain`(`is_ignored(path, is_dir, root)`)。`LocalHost` 内部持 `Mutex` 做 matcher 缓存,不再由调用方 seed/handback(`TreeLoader` 那套来回搬是为了跨线程,`Arc` 之后不需要了) |
-| **`watch`** | `notify::recommended_watcher`,`RecursiveMode::NonRecursive`,内部 100ms 合并去重后批量投递 —— **与远程行为逐字一致**,不许本地"顺便"更实时 |
-| **`git`** | 三套既有 helper 的合并版,见 §4.4 |
-
----
-
-## 4. 四个必须钉死的约定
-
-### 4.1 `Host` 从哪来
-
-- 一个 workspace 一个 `SharedHost`。本地 workspace 拿进程级单例 `LocalHost`。
-- `TerminalView` / `FileTreeState` / `CodeEditor` 都从所属 workspace 取,**不许自己 `LocalHost::new()`**。
-- 进程内有一张 `HostRegistry: HashMap`(`gpui::Global`,住 GUI crate),`HostId` → `SharedHost` 反查用于 `GitStatusCache` 这类只存 id 的地方。
-
-### 4.2 `HostId` 的派生串
-
-`HostId::from_connection_key` 的输入必须是规范化后的**连接**串,格式固定:
-
-| host 种类 | key 串 |
-|---|---|
-| 本地 | 不派生,直接 `HostId::LOCAL` |
-| SSH profile | `ssh-profile:` |
-| `~/.ssh/config` alias | `ssh-alias:` |
-| 裸 `user@host:port` | `ssh-direct:@:`(port 省略时补默认 22) |
-| WSL | `wsl:` |
-
-**不含 `WorkspaceId`** —— 同机多 workspace 共享 `HostId`(设计文档 §10 已定)。
-
-### 4.3 跨 OS 路径规则(设计文档完全没提,但是硬伤)
-
-Windows 客户端连 Linux 远程时,远程路径是 `/home/me/proj`。
-
-| `std::path` API | 在 Windows 上对远程 POSIX 路径 | 裁决 |
-|---|---|---|
-| `join` / `push` | ❌ 产出 `/home/me\proj` | **禁用**。用 `Host::join` |
-| `is_absolute` | ❌ 返回 `false`(drive-relative) | **禁用**。用 `Host::is_absolute` |
-| `canonicalize` | ❌ 走本地文件系统 | **禁用**。用 `Host::canonicalize` |
-| `parent` / `file_name` / `with_file_name` | ✅ Windows 的 `std::path` 把 `/` 也当分隔符 | 可用 |
-| `starts_with` / `strip_prefix` / `ancestors` / `components` | ✅ 同上 | 可用 |
-| `exists` / `is_dir` / `metadata` / `read_dir` | ❌ 走本地文件系统 | **禁用**。用 `Host::*` |
-
-**执行方式**:A3 改造完后,`ui/` 与 `terminal/` 里除 `host_ops.rs` 外不得出现 `std::fs::`、`Command::new("git")`、`.canonicalize()`、`.is_absolute()`。加一个 CI grep 守卫(§10.6)。
-
-### 4.4 `git` 调用的统一不变量
-
-三套既有出口的实测差异:
-
-| 出口 | 位置 | `GIT_OPTIONAL_LOCKS=0` | `stdin(null)` | cwd 传递 | 错误表示 | 线程 |
-|---|---|---|---|---|---|---|
-| `git_status::git` | 拆分前 `terminal/git_status.rs:332-345`;A1 已提到 `core::git::git` | ✅ | ✅ | `-C` | `Option`,stderr 丢弃 | background |
-| `worktree::git` | `core/worktree.rs:63-75` | ❌ | ❌ | `-C` | `Result`,stderr 是错误文本 | 调用方决定 |
-| `app.rs` 内联闭包 | `ui/app.rs:3995-4005` | ❌ | ❌ | `current_dir` | 静默空串 | **UI 线程** |
-
-**统一后的 `Host::git` 不变量(两个实现都必须满足)**:
-
-1. `git -C ` —— 一律 `-C`,不用 `current_dir`
-2. `GIT_OPTIONAL_LOCKS=0`
-3. `stdin` = null
-4. stdout **和** stderr 都捕获进 `Output`(不丢 stderr —— `worktree` 需要它做错误文本)
-5. Windows 上 `hide_console`(本地实现;远程无此概念)
-6. 不继承 GUI 进程的 `GIT_*` 环境变量以外的 git 相关污染 —— 显式 `env_remove("GIT_DIR")`、`env_remove("GIT_WORK_TREE")`
-
-**调用方适配**(A3 负责):
-
-| 原调用方 | 适配 |
-|---|---|
-| `git_status` / `git_diff` | `host.git(...).ok().filter(Output::success).map(\|o\| o.stdout_trimmed())` —— 保留 `Option` 语义 |
-| `worktree` | `match host.git(...) { Ok(o) if o.success() => Ok(o.stdout_trimmed()), Ok(o) => Err(o.stderr_trimmed()), Err(e) => Err(format!("failed to run git: {e}")) }` —— 逐字保持既有 `Result` 形状 |
-| `app.rs::send_git_diff_to_agent` | 直接 `stdout_trimmed()`,失败当空串(保持既有行为),但整段搬到 background |
-
-**行为变化警告**:`worktree` 和 `app.rs` 拿到 `GIT_OPTIONAL_LOCKS=0` 是一个真实变化(`git worktree add` 是写操作,`optional_locks=0` 只影响可选锁如 `index.lock` 的刷新,**不影响写操作的必要锁**)。已确认安全,但 M2 的回归测试必须覆盖 `worktree add` / `list` 的完整路径。
-
----
-
-## 5. `HostOps`:GUI 侧唯一出口
-
-**位置**:`src/ui/host_ops.rs`(留在 GUI crate,因为它认识 `gpui`)
-**负责人**:A2 定义,A3 消费
-
-```rust
-/// GPUI 侧的 Host 门面。把每个阻塞方法包成
-/// `spawn → background_spawn → update` 的三段式,并统一处理
-/// in-flight 去重、staleness、错误通知。
-///
-/// **这是 GUI 里唯一允许触碰 `Arc` 的地方。**
-pub struct HostOps;
-
-impl HostOps {
- /// 通用逃生舱:在 background 上跑 `f`,结果回到 UI 线程交给 `land`。
- /// 用于契约里没有专门包装的方法。
- pub fn run(
- host: SharedHost,
- cx: &mut Context,
- f: F,
- land: L,
- ) where
- E: 'static,
- T: Send + 'static,
- F: FnOnce(&dyn Host) -> T + Send + 'static,
- L: FnOnce(&mut E, T, &mut Context) + 'static,
- { /* cx.spawn + cx.background_spawn + this.update */ }
-
- /// 带 window 的变体(需要 `push_notification` / `focus` 的落地)。
- pub fn run_in(
- host: SharedHost, window: &mut Window, cx: &mut Context, f: F, land: L,
- ) { /* cx.spawn_in */ }
-}
-```
-
-**UI 线程守卫**(debug 构建):
-
-```rust
-// tty7-core::host 里
-#[cfg(debug_assertions)]
-pub fn assert_off_ui_thread() {
- debug_assert!(
- !crate::host::is_ui_thread(),
- "Host call on the UI thread — route it through ui::host_ops::HostOps"
- );
-}
-```
-
-`is_ui_thread()` 比对启动时 `main.rs` 注册的 `ThreadId`。每个 `Host` 方法的默认实现入口调一次(release 下被优化掉)。
-
-**必备模式(照抄 `terminal/view.rs:3963-3980`)**:
-
-| 关注点 | 做法 |
-|---|---|
-| **in-flight 去重** | 每种请求一张 key → bool 表(`file_tree::Loads` 已经是这个形状,复用它的 `begin`/`finish`/`invalidate` 三态) |
-| **staleness** | 落地前必须重查前置条件(`remote_path_results` 检查 `self.cmd.text() != line`)。目录列表检查 `Loads::finish` 是否被 `invalidate` 过 |
-| **错误** | `Err` 一律 `window.push_notification`,**不许静默**,除非既有行为就是静默(`git` 探针) |
-| **断连** | `!host.is_connected()` 时不发请求,直接显示上一份缓存 |
-
----
-
-## 6. control 连接协议
-
-**位置**:`crates/tty7-core/src/daemon/control.rs`(wire 定义 + 编解码 + 测试)
-**负责人**:A4 定义并落地 wire,A5 消费
-
-### 6.1 kind 数值:**60-63**(两个方向各一套)
-
-先把号段现状钉死(`src/daemon/protocol.rs:974-1061`):
-
-| 空间 | 已用 | 保留(注释显式声明) | 退役 |
-|---|---|---|---|
-| Client → daemon | 1-12, 14-17, 20-22, 30-34, 40, 50 | 15-19(WS3 auth)、20-24(WS4 forward)、30-36(SFTP) | **13**(曾是 `SPAWN_MANAGED_SSH`) |
-| Daemon → client | 1-15, 20-22, 30-33, 40, 50 | 同上 | — |
-
-**分配**:
-
-| 方向 | 名字 | 值 | payload 形态 |
-|---|---|---|---|
-| C→S | `CONTROL_HELLO` | **60** | `[JSON]`(无 req_id) |
-| C→S | `CONTROL_REQUEST` | **61** | `[u64 req_id][u32 json_len][JSON]` |
-| C→S | `CONTROL_REQUEST_BLOB` | **62** | `[u64 req_id][u32 json_len][JSON][raw bytes]` |
-| C→S | `CONTROL_CANCEL` | **63** | `[u64 req_id][u32 json_len = 0]` |
-| S→C | `CONTROL_HELLO_OK` | **60** | `[JSON]` |
-| S→C | `CONTROL_RESPONSE` | **61** | `[u64 req_id][u32 json_len][JSON]` |
-| S→C | `CONTROL_RESPONSE_BLOB` | **62** | `[u64 req_id][u32 json_len][JSON][raw bytes]` |
-| S→C | `CONTROL_EVENT` | **63** | `[u64 req_id = 0][u32 json_len][JSON]` |
-
-**为什么是 60-63**:
-
-| 理由 | 展开 |
-|---|---|
-| **避开全部保留段** | 15-19 / 20-24 / 30-36 都被注释显式预留给 WS3/WS4/SFTP 的后续扩展。占用它们等于把那三块的扩展空间吃掉 |
-| **不复用 13** | 13 是**退役**号,不是空闲号。一个 pre-WS2 的 daemon 会把 kind 13 解成 `SpawnManagedSsh` 并**静默 mis-spawn 一个 pane**,而不是报未知 kind。退役号一律永不复用 |
-| **跟随既有分组习惯** | 现有 kind 按 10 分组(10 / 20 / 30 / 40 / 50)。60 是下一个整十位,把整个 control 方言收进一块 |
-| **留出 64-69** | 同一块里给后续 control 帧留 6 个号(如未来的流式响应、背压信号),不必再挑新段 |
-
-**实现前提**:`mod kind` 目前是**私有的**(`protocol.rs:974`)。A4 必须先把它改成 `pub(crate) mod kind`,或把 control kind 定义在 `control.rs` 里自己的 `pub mod kind`。**选后者** —— control 是独立方言,独立号段,独立模块,只在 `control.rs` 的头部注释里交叉引用 `protocol::kind` 的已用表。
-
-### 6.2 三种 payload 的精确字节布局
-
-所有多字节整数 **little-endian**。外层帧沿用 `[u32 LE payload_len][u8 kind][payload]`,`MAX_FRAME = 64 MiB` 不变。
-
-```
-CONTROL_HELLO / CONTROL_HELLO_OK (60)
-┌──────────────────────────┐
-│ JSON (payload_len bytes) │
-└──────────────────────────┘
-
-CONTROL_REQUEST / CONTROL_RESPONSE (61)
-CONTROL_EVENT (63, S→C)
-CONTROL_CANCEL (63, C→S)
-┌───────────────┬───────────────┬──────────────────┐
-│ u64 LE req_id │ u32 LE json_n │ JSON (json_n B) │
-└───────────────┴───────────────┴──────────────────┘
- 8 bytes 4 bytes
-必须满足:payload_len == 12 + json_n
-
-CONTROL_REQUEST_BLOB / CONTROL_RESPONSE_BLOB (62)
-┌───────────────┬───────────────┬─────────────────┬──────────────────────┐
-│ u64 LE req_id │ u32 LE json_n │ JSON (json_n B) │ raw blob (剩余全部) │
-└───────────────┴───────────────┴─────────────────┴──────────────────────┘
-blob_len == payload_len - 12 - json_n
-```
-
-**为什么大 payload 也带 JSON 头**(设计文档 §8 写的是 `[u64 req_id][raw bytes]`,无 JSON):
-`write_file` 必须携带目标路径,`read_file` 的响应必须携带 `Meta`(`code_editor` 需要写入后的 mtime,否则要再来一次 `stat` 往返)。裸 blob 形态承载不了参数 —— 这是设计文档 §8 的一处实打实的漏洞(§11 第 7 条)。
-
-**为什么事件也带 req_id 且恒为 0**:
-冗余 8 字节换来"所有非 HELLO 的 control 帧共享同一个头解析器"。reader 一律 `read u64 → read u32 → 取 JSON`,然后按 kind 分派。`req_id != 0` 的 `CONTROL_EVENT` 是协议错误(`InvalidData`),reader 必须校验。
-
-**解码校验(缺一不可)**:
-
-| 检查 | 违反时 |
-|---|---|
-| `payload_len >= 12`(61/62/63) | `InvalidData` |
-| `12 + json_n <= payload_len` | `InvalidData`(防 `json_n` 溢出导致越界切片) |
-| `61`/`63` 上 `12 + json_n == payload_len` | `InvalidData` |
-| `CONTROL_EVENT` 的 `req_id == 0` | `InvalidData` |
-| `CONTROL_REQUEST` 的 `req_id != 0` | `InvalidData` |
-| 未知 kind | `InvalidData`(与既有协议一致,是错误不是跳过) |
-
-### 6.3 `req_id` 语义
-
-| 项 | 规则 |
-|---|---|
-| **分配方** | 客户端。服务端从不分配 |
-| **起点 / 步进** | 从 1 开始,`fetch_add(1)` 单调递增。**0 永久保留给推送** |
-| **回绕** | `u64` 不考虑回绕 |
-| **匹配** | 乱序。客户端维护 `HashMap>`;响应到达时 `remove` 并投递 |
-| **未知 req_id 的响应** | **静默丢弃**,不当错误 —— 超时后取消的请求可能仍会收到迟到的响应 |
-| **一请求一响应** | 严格。没有流式、没有多段。大文件靠 `MAX_FRAME` 兜底,超过就 `FileTooLarge` |
-| **连接重建** | in-flight 全部以 `ErrorKind::ConnectionReset` 失败;req_id 计数器**不重置**(无所谓,但重置也不会错) |
-
-### 6.4 每个 RPC 的请求/响应结构
-
-**请求 JSON** 是一个 externally-tagged enum:
-
-```rust
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum ControlRequest {
- // --- 探活 ---
- Ping,
-
- // --- fs 读 ---
- ReadDir { dir: String, root: Option },
- Stat { path: String },
- Exists { path: String },
- Canonicalize { path: String },
- ReadFile { path: String, max_bytes: u64 },
- Search { roots: Vec, query: String, limit: u64, max_dirs: u64 },
-
- // --- fs 写 ---
- /// blob = 文件内容(走 CONTROL_REQUEST_BLOB)
- WriteFile { path: String },
- CreateFileNew { path: String },
- CreateDir { path: String, recursive: bool },
- Rename { from: String, to: String },
- Remove { path: String, recursive: bool },
-
- // --- git ---
- RepoRoot { path: String },
- Git { cwd: String, args: Vec },
-
- // --- watch ---
- /// 建立订阅,服务端回 `WatchId`
- WatchOpen { dirs: Vec },
- WatchSet { id: u64, dirs: Vec },
- WatchClose { id: u64 },
-
- // --- workspace store(M5,A4 只留位,不实现) ---
- WorkspaceList,
- WorkspaceGet { id: String },
- WorkspacePut { id: String, json: serde_json::Value },
- WorkspaceDelete { id: String },
-}
-```
-
-**路径一律 `String`,不是 `PathBuf`。** 原因:`PathBuf` 的 serde 在非 UTF-8 路径上的表示是平台相关的,而两端可能是不同 OS。远程侧路径恒为 UTF-8 POSIX;非 UTF-8 路径由服务端在 `read_dir` 时以 lossy 形式返回并标记(与既有 `file_tree` 的 `to_string_lossy` 行为一致)。
-
-**响应 JSON**:
-
-```rust
-#[derive(Serialize, Deserialize)]
-pub enum ControlReply {
- #[serde(rename = "ok")]
- Ok(ReplyOk),
- #[serde(rename = "err")]
- Err(WireError),
-}
-
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum ReplyOk {
- Unit,
- Pong,
- Entries(Vec),
- Meta(Meta),
- Bool(bool),
- Path(String),
- OptPath(Option),
- /// `ReadFile` 的响应:内容在 blob 里,这里只有元信息
- FileMeta { meta: Meta },
- Hits(Vec),
- Output(Output),
- WatchId(u64),
- /// workspace store(M5)
- Json(serde_json::Value),
-}
-```
-
-**逐方法对照表**:
-
-| 请求 | 帧 kind | blob | 响应 `ReplyOk` | 响应帧 kind |
-|---|---|---|---|---|
-| `Ping` | 61 | — | `Pong` | 61 |
-| `ReadDir` | 61 | — | `Entries` | 61 |
-| `Stat` | 61 | — | `Meta` | 61 |
-| `Exists` | 61 | — | `Bool` | 61 |
-| `Canonicalize` | 61 | — | `Path` | 61 |
-| `ReadFile` | 61 | — | `FileMeta` | **62**(blob = 内容) |
-| `Search` | 61 | — | `Hits` | 61 |
-| `WriteFile` | **62** | 内容 | `Meta`(写后的 mtime) | 61 |
-| `CreateFileNew` | 61 | — | `Unit` | 61 |
-| `CreateDir` | 61 | — | `Unit` | 61 |
-| `Rename` | 61 | — | `Unit` | 61 |
-| `Remove` | 61 | — | `Unit` | 61 |
-| `RepoRoot` | 61 | — | `OptPath` | 61 |
-| `Git` | 61 | — | `Output` | 61 |
-| `WatchOpen` | 61 | — | `WatchId` | 61 |
-| `WatchSet` / `WatchClose` | 61 | — | `Unit` | 61 |
-| `Workspace*` | 61 | — | `Json` / `Unit` | 61 |
-
-`Output.stdout`/`stderr` 是 `Vec`,走 JSON 会被 serde 序列化成数字数组 —— 一个 1MB 的 diff 变成约 4MB JSON。**优化(必做)**:`Output` 的两个字段用 `#[serde(with = "serde_bytes")]`(`Vec` → base64 字符串)。若 A4 觉得 base64 的 33% 仍然贵,可把 `Git` 的响应也走 kind 62(blob = stdout),JSON 只带 `status` 和 stderr。**裁决:v1 用 base64,简单;`git diff HEAD` 的典型量级在 100KB 内,不值得为它做第二条 blob 路径。** 若实测大 repo 上成为瓶颈,再迁到 62 —— 那是纯加法(`ReplyOk::Output` 保留,新增 `ReplyOk::OutputMeta` + blob)。
-
-### 6.5 错误编码
-
-`io::ErrorKind` 既不是 serde,其变体集也不稳定(`#[non_exhaustive]`)。所以过线用一个**受限的字符串枚举**:
-
-```rust
-#[derive(Serialize, Deserialize)]
-pub struct WireError {
- pub kind: WireErrorKind,
- /// 人类可读,直接进通知。**不含路径以外的服务端内部细节。**
- pub msg: String,
-}
-
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum WireErrorKind {
- NotFound,
- PermissionDenied,
- AlreadyExists,
- InvalidInput,
- NotADirectory,
- IsADirectory,
- DirectoryNotEmpty,
- /// 超过 `max_bytes`
- FileTooLarge,
- /// 服务端上没有 git / git 起不来
- GitUnavailable,
- TimedOut,
- /// control 连接断了(客户端本地合成,不过线)
- ConnectionReset,
- /// 兜底。`msg` 带原始描述
- Other,
-}
-```
-
-**映射表(两个方向都必须实现,且必须是彼此的逆)**:
-
-| `io::ErrorKind` | `WireErrorKind` |
-|---|---|
-| `NotFound` | `NotFound` |
-| `PermissionDenied` | `PermissionDenied` |
-| `AlreadyExists` | `AlreadyExists` |
-| `InvalidInput` / `InvalidFilename` | `InvalidInput` |
-| `NotADirectory` | `NotADirectory` |
-| `IsADirectory` | `IsADirectory` |
-| `DirectoryNotEmpty` | `DirectoryNotEmpty` |
-| `FileTooLarge` | `FileTooLarge` |
-| `TimedOut` | `TimedOut` |
-| `ConnectionReset` / `BrokenPipe` / `UnexpectedEof` | `ConnectionReset` |
-| 其它 | `Other` |
-
-反向(`WireErrorKind` → `io::Error`):同表反查,`GitUnavailable` → `io::ErrorKind::NotFound`,`Other` → `io::ErrorKind::Other`。`msg` 一律作为 `io::Error` 的 payload。
-
-**关键约束**:`Err` 只表示"操作没能执行"。**`git` 的非零退出码不是 `Err`** —— 它是 `Ok(Output { status: Some(1), .. })`。这一条决定了 `git_status` 的 `Option` 语义能否原样保留。
-
-### 6.6 事件推送(`CONTROL_EVENT`, kind 63, req_id = 0)
-
-```rust
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum ControlEvent {
- /// 文件变更。服务端按 100ms 滚动窗口合并、去重后批量推。
- /// 一个窗口内的路径去重;窗口内路径数超过 `WATCH_BURST_CAP`(1024) 时
- /// 退化成 `WatchOverflow`,客户端整树 invalidate。
- Watch { id: u64, paths: Vec },
- WatchOverflow { id: u64 },
-
- /// M5/M6 保留位(A4 只定义,不实现)
- PaneExited { pane_id: u64, code: Option },
- AgentStatus { pane_id: u64, json: serde_json::Value },
- Preempted { by: String },
- WorkspaceChanged { id: String },
-}
-```
-
-### 6.7 握手:`CONTROL_HELLO` / `CONTROL_HELLO_OK`
-
-```rust
-#[derive(Serialize, Deserialize)]
-pub struct ControlHello {
- /// 客户端说的 control 方言版本。当前 1。
- pub control_version: u32,
- /// 这条 control 连接绑定的 workspace。`None` = 只用 Host RPC,
- /// 不绑 workspace(M3 的 stdio 端到端测试走这条)。
- pub workspace: Option,
- /// 客户端会话 token + 主机名,用于 §10 的接管。
- pub client_token: String,
- pub client_hostname: String,
-}
-
-#[derive(Serialize, Deserialize)]
-pub struct ControlHelloOk {
- pub control_version: u32,
- /// 服务端的 `PROTOCOL_VERSION`(冗余,方便诊断)
- pub protocol_version: u32,
- /// 服务端二进制版本,display only
- pub build: String,
- /// 服务端的路径分隔符(客户端据此实现 `Host::separator`)
- pub separator: char,
- /// 服务端 `$HOME`(首页"新建 workspace 落在 `~`"要用)
- pub home: String,
- /// 能力位,见 §6.9
- #[serde(default)]
- pub features: Vec,
-}
-```
-
-握手失败(`control_version` 不匹配)时服务端回 `CONTROL_HELLO_OK` 里带自己的版本然后**关连接**,客户端据此报明确错误 —— 不发 `CONTROL_RESPONSE`,因为 HELLO 没有 req_id。
-
-### 6.8 超时
-
-| 类别 | 方法 | 默认 deadline |
-|---|---|---|
-| 探活 | `Ping` | 5s |
-| 元数据 | `ReadDir` `Stat` `Exists` `Canonicalize` `RepoRoot` `Watch*` | **5s** |
-| 内容 | `ReadFile` `WriteFile` | **30s** |
-| 变更 | `CreateFileNew` `CreateDir` `Rename` `Remove` | **10s** |
-| git | `Git` | **20s** |
-| 搜索 | `Search` | **20s** |
-| workspace | `Workspace*` | 10s |
-
-**超时行为**:
-
-1. 客户端本地放弃,向服务端发 `CONTROL_CANCEL { req_id }`
-2. 调用方拿到 `io::Error(TimedOut)`
-3. 服务端收到 cancel **尽力**中止(`Git` 杀子进程;fs 操作不可中断,跑完丢弃结果)—— **不保证**
-4. 迟到的响应到达时 req_id 已不在表里,静默丢弃(§6.3)
-5. **超时不断连**。设计文档 §17"单个 `Host` RPC 超时 → 该请求返回 `TimedOut`,不影响其它请求"
-
-**连接层 keepalive**(与 RPC 超时正交):
-
-| 项 | 值 |
-|---|---|
-| 客户端 `Ping` 间隔 | 15s(仅在 30s 内无任何入站帧时才发) |
-| 判死阈值 | **45s** 无任何入站帧 |
-| 判死后 | 整个 workspace 转 `Reconnecting`(设计文档 §17:control 断 = workspace 断) |
-
-### 6.9 `PROTOCOL_VERSION` bump 到 3
-
-**先说清楚:按 `protocol.rs:42-48` 自己写的规则,新增 kind 是 additive,本来不需要 bump。** 设计文档 §8 直接说"bump 到 3",没给理由。真实理由是另一个:
-
-> **v3 = "我会说 control 方言"这一能力位可以被正向查询。**
-
-不 bump 的话,客户端只能靠"开 control 连接 → 收到 unknown kind 错误 → 连接被对端关掉"来探测。而未知 kind 在既有实现里**会直接杀掉那条连接**(`InvalidData`),且与真正的 desync 无法区分。代价是一次无谓往返 + 一条误导性错误日志。
-
-同时**新增 `DaemonVersion.features`**(additive,`#[serde(default)]`):
-
-```rust
-pub struct DaemonVersion {
- pub protocol: u32,
- #[serde(default)]
- pub build: String,
- /// 细粒度能力位。v3 之后新增能力不再 bump 版本号。
- /// 已定义:`"control"`、`"host-rpc"`、`"workspace-store"`、`"stdio-bridge"`
- #[serde(default)]
- pub features: Vec,
-}
-```
-
-**兼容性影响**:
-
-| 组合 | 行为 |
-|---|---|
-| v3 GUI ↔ v3 daemon/server | 正常 |
-| v2 GUI ↔ v3 daemon | ✅ **无降级**。v2 GUI 从不发 60-63;v3 daemon 对既有 kind 的解码逐字不变 |
-| v3 GUI ↔ v2 **本地** daemon | ❌ 本地 daemon 不认 60-63。走既有 `spawn::ensure_running` 的不兼容询问路径。**本地 daemon 必须先升到 v3,远程 workspace 才能用** |
-| v3 GUI ↔ v2 **远程** server | ❌ 同上,但这是"远程需要升级"的独立提示,不能与本地 daemon 的提示混为一谈 |
-
-**两次独立握手**(设计文档没说清):远程 workspace 涉及**两个** `PROTOCOL_VERSION` 检查 ——
-1. GUI ↔ 本地 daemon(既有 `spawn::ensure_running`,不变)
-2. GUI ↔ 远程 tty7-server(**端到端**,穿过本地 daemon 的纯转发层)
-
-第 2 次必须是端到端的,否则本地 daemon 就得解析 control 流,违背 §6 架构图的"RemoteRouter 纯字节转发,不解析"(§11 第 16 条)。
-
----
-
-## 7. `Stream` 抽象(M4)
-
-### 7.1 复核结论:设计文档 §4 说错了,但结论意外正确
-
-| 设计文档 §4 说 | 实际(`daemon/transport.rs:44`, `:397`) |
-|---|---|
-| "传输抽象:`Stream = Read + Write + try_clone`" | `Stream` 是 **type alias**:Unix `= UnixStream`,Windows `= TcpStream`。不是 trait |
-| "多一种传输形态不破坏上层" | **结论成立,理由完全不同** |
-
-真实理由:**远程流根本不经过 `transport::Stream`。**
-
-设计文档 §6 自己写了"GUI 侧传输代码一行不改…本地 daemon 对远程流只做字节转发"。所以:
-
-```
-GUI ──transport::Stream (UnixStream/TcpStream,不动)──> 本地 daemon
- │
- RemoteLink (新, async) ──> SSH channel / WSL stdio
-```
-
-**新抽象住在本地 daemon 里,不在 GUI 里。** 而 daemon 的 SSH 侧已经是 tokio async(russh 就是),所以新抽象应该是 **async 的,且应复用已经存在的形状** —— `daemon/ssh/connect.rs:29-33` 的 `Transport` 枚举(`Tcp` / `Process(ProcessStream)` / `Channel(russh::ChannelStream)`,各自 delegate `AsyncRead`/`AsyncWrite`)。
-
-### 7.2 `RemoteLink`(daemon 侧,M4 真正要引入的东西)
-
-**位置**:`crates/tty7-core/src/daemon/remote_link.rs`
-**负责人**:A4(M4 阶段;M3 只需要 `Stdio` 变体)
-
-```rust
-/// 本地 daemon 与远程 tty7-server 之间的一条逻辑流。
-///
-/// 枚举而非 trait object —— 与既有 `ssh::connect::Transport` 同款理由:
-/// 每个变体的 `AsyncRead`/`AsyncWrite` 是直接 delegate,没有 vtable。
-pub enum RemoteLink {
- /// 首选:`direct-streamlocal@openssh.com` 直连远程 daemon.sock。
- /// russh: `client::Handle::channel_open_direct_streamlocal(socket_path)`
- /// (已确认存在于 `russh/src/client/mod.rs:854`,签名
- /// `async fn(&self, S: Into) -> Result, Error>`)
- StreamLocal(russh::ChannelStream),
-
- /// 回退:session channel + `exec tty7-server --stdio`。
- /// 与 StreamLocal 同类型,语义不同 —— 分开是为了让日志/诊断能区分。
- SessionExec(russh::ChannelStream),
-
- /// WSL:`wsl.exe -d -- tty7-server --stdio` 的 stdin/stdout。
- /// 直接复用既有 `ssh::connect::ProcessStream`(:82-113)。
- Wsl(crate::daemon::ssh::connect::ProcessStream),
-
- /// CI / 端到端测试:本机 `tty7-server --stdio` 子进程。
- /// 与 Wsl 同类型,分开是为了让测试路径显式。
- LocalStdio(crate::daemon::ssh::connect::ProcessStream),
-}
-
-impl tokio::io::AsyncRead for RemoteLink { /* 四路 delegate */ }
-impl tokio::io::AsyncWrite for RemoteLink { /* 四路 delegate,poll_shutdown 全实现 */ }
-
-impl RemoteLink {
- /// 这条流的诊断标签,进日志和状态条。
- pub fn kind_label(&self) -> &'static str { .. }
-}
-```
-
-**为什么四个变体只有两种底层类型**:诊断价值。`StreamLocal` 失败要触发一次性回退探测(设计文档 §7.1"结果缓存在 `SshConnection` 上"),日志里必须能区分"streamlocal 断了"和"stdio bridge 断了"。
-
-### 7.3 现有调用方的用法能否满足
-
-实测调用点(`transport::Stream` 上的具体方法,全树只有这几处):
-
-| 位置 | 用法 | 在新架构下 |
-|---|---|---|
-| `terminal/remote.rs:333` | `stream.try_clone()` | **仍是本地 `UnixStream`**,不受影响 |
-| `daemon/server.rs:235` | `read_stream.try_clone()` | 本地 accept 侧,不受影响 |
-| `terminal/remote.rs:1059` | `shutdown(Shutdown::Write)`(`kill_pane`) | 本地流,不受影响 |
-| `terminal/remote.rs:1488` | `writer.shutdown(Shutdown::Both)` | 本地流,不受影响 |
-| `terminal/remote.rs:828` | `set_read_timeout` | 本地流,不受影响 |
-| `terminal/view.rs:7123` | `set_read_timeout(250ms)`(DEC 2026 同步更新 deadline) | 本地流,不受影响 |
-| `daemon/spawn.rs:179` | `set_read_timeout(HANDSHAKE_TIMEOUT)` | 本地流,不受影响 |
-
-**结论:`transport::Stream` 一个字都不用改,`try_clone` / `shutdown` / `set_read_timeout` 全部保留。** 这是 M4 最大的好消息,也是设计文档"多一种传输形态不破坏上层"的真实成因。
-
-### 7.4 服务端侧确实需要一个阻塞 trait
-
-`tty7-server --stdio` 模式下,服务端要把自己的 `stdin`/`stdout` 当成一条流用,而 `server.rs` 的 accept 循环是阻塞 + `try_clone` 的形状。
-
-**位置**:`crates/tty7-core/src/daemon/duplex.rs`
-**负责人**:A5
-
-```rust
-/// 服务端一条双工流。抽掉 `try_clone` —— stdin/stdout 天然是两个 handle,
-/// 无法 clone 出一个双向流。改成构造期 split。
-pub trait Duplex: Send + 'static {
- type Read: io::Read + Send + 'static;
- type Write: io::Write + Send + 'static;
- fn split(self) -> io::Result<(Self::Read, Self::Write)>;
-}
-
-impl Duplex for std::os::unix::net::UnixStream { /* try_clone */ }
-impl Duplex for std::net::TcpStream { /* try_clone */ }
-
-/// `--stdio` 模式:进程的 stdin/stdout。
-pub struct StdioDuplex;
-impl Duplex for StdioDuplex {
- type Read = std::io::Stdin;
- type Write = std::io::Stdout;
- fn split(self) -> io::Result<(Stdin, Stdout)> { Ok((io::stdin(), io::stdout())) }
-}
-```
-
-**改动量**:`daemon/server.rs:235` 的 `read_stream.try_clone()?` → `stream.split()?`,一行。这是 M4 **唯一**需要改的既有传输调用点。
-
----
-
-## 8. 远程 socket 路径与 `--stdio` 桥
-
-**负责人**:A5
-
-| 项 | 值 |
-|---|---|
-| 首选 socket | `$XDG_RUNTIME_DIR/tty7/daemon.sock` |
-| 无 `XDG_RUNTIME_DIR` | `~/.local/share/tty7/daemon.sock` |
-| 超长路径回退 | 沿用 `transport::socket_path_for` 的短路径 + FNV-1a64 哈希(`transport.rs:59-90`),**原样搬,不重写** |
-| 权限 | socket 0600,目录 0700 |
-| 粒度 | 一台机器一个 `tty7-server`(per user) |
-
-**`tty7-server --stdio`** 是一个纯字节转发的小进程:把自己的 stdin/stdout 接到上面那个 socket,不解析任何内容。它**不是** server 本体 —— server 本体是 `tty7-server --daemon`。
-
-**`--stdio` 的三个用途**:
-1. `AllowStreamLocalForwarding no` 时的回退(设计文档 §7.1)
-2. WSL(无 SSH)
-3. **CI 端到端测试**(设计文档 §18 的核心杠杆)
-
----
-
-## 9. 模块归属与 agent 分工
-
-### 9.1 crate 布局(A1 产出)
-
-**A1 已落地**(本契约成文时 working tree 里的实际形状):
-
-```
-Cargo.toml # workspace root,[patch] 留在这里
-src/ # tty7(GUI bin),依赖 gpui + tty7-core
-crates/tty7-core/src/lib.rs # pub mod core; pub mod daemon; —— 无 gpui
-crates/tty7-core/src/core/ # agent_hooks cli_agent config crash git gitignore
- # osc proc session shells threads window_state worktree
-crates/tty7-core/src/daemon/ # mod pane pidfile procinfo protocol remote server
- # shell_integration spawn transport winproc + ssh/
-crates/tty7-server/src/main.rs # headless bin,只依赖 tty7-core
-```
-
-**模块路径刻意保持不变**(`crate::core::config`、`crate::daemon::protocol`),GUI crate 以同名 re-export,所以两侧调用点读起来一样。新增的 `host` 模块**必须遵循同一约定**:`crates/tty7-core/src/host/`,`lib.rs` 加 `pub mod host;`。
-
-**已确认的 A1 产出**(不要重做):
-
-| 模块 | 内容 |
-|---|---|
-| `core::git` | `probe` / `branch_name` / `git(cwd, args) -> Option`(带 `GIT_OPTIONAL_LOCKS=0` + `stdin(null)`) |
-| `core::gitignore` | `GitignoreChain { is_ignored(path, is_dir, root) -> bool, absorb, clear, len }` |
-| `core::window_state` | A1 把它搬进了 core(设计文档 §11 说留 GUI)。**不是错误**,接受现状 |
-
-### 9.2 文件归属表(新增文件)
-
-| 路径 | crate | 内容 | 负责人 |
-|---|---|---|---|
-| `crates/tty7-core/src/host/mod.rs` | core | `Host` trait + 辅助类型 + `HostId` + `fnv1a64` | **A2** |
-| `crates/tty7-core/src/host/local.rs` | core | `LocalHost`(内部用 `core::git` + `core::gitignore::GitignoreChain`) | **A2** |
-| `crates/tty7-core/src/host/conformance.rs` | core | §10 的共用测试套 | **A2** 定义骨架 + 全部 case |
-| `src/ui/host_ops.rs` | GUI | §5 门面 | **A2** |
-| `src/ui/host_registry.rs` | GUI | `HostId → SharedHost` 的 `gpui::Global` | **A2** |
-| `crates/tty7-core/src/daemon/control.rs` | core | control wire(kind / 帧 / enum / 编解码 / round-trip 测试) | **A4**(第一个 commit) |
-| `crates/tty7-core/src/host/remote.rs` | core | `RemoteHost`(control 客户端 + req_id 表 + 超时) | **A4** |
-| `crates/tty7-core/src/daemon/remote_link.rs` | core | §7.2(M4) | **A4** |
-| `crates/tty7-core/src/host/server.rs` | core | control 服务端 handler(`ControlRequest` → `LocalHost`) | **A5** |
-| `crates/tty7-core/src/daemon/duplex.rs` | core | §7.4 `Duplex` trait | **A5** |
-| `crates/tty7-server/src/main.rs` | server | `--daemon` / `--stdio` / `agent-hook` 三个子命令(A1 已建空壳) | **A5** |
-| `src/ui/{file_tree,code_editor,app}.rs`, `src/terminal/{git_status,git_diff,view}.rs` | GUI | 调用点改造 | **A3** |
-
-**A2 的额外任务**:`core::git::git` 现在返回 `Option`,丢掉了 stderr 和退出码。`Host::git` 需要 `Output`。做法:在 `core::git` 里**新增** `pub fn git_output(cwd, args) -> io::Result` 作为底层,把既有 `git()` 改写成它的薄封装(`git_output(..).ok().filter(Output::success).map(|o| o.stdout_trimmed())`)—— 既有调用方零改动。
-| `src/ui/host_ops.rs` | GUI | **新** —— §5 | **A2** 定义,A3 消费 |
-| `src/ui/host_registry.rs` | GUI | **新** —— `HostId → SharedHost` 的 `gpui::Global` | A2 |
-| `src/ui/{file_tree,code_editor,app}.rs`, `src/terminal/{git_status,git_diff,view}.rs` | GUI | 改造调用点 | **A3** |
-
-### 9.3 五个 agent 的分工与依赖
-
-| Agent | 范围 | 依赖 | 完成标志 |
-|---|---|---|---|
-| **A1** | crate 拆分(M1)+ 三处提取(gitignore / git helper / session 数据) | — | `cargo test --workspace` 全绿;`tty7-server` 空壳能在无头 Linux 上 `--version` |
-| **A2** | `Host` trait + `LocalHost` + `HostOps` + `HostRegistry` + conformance 骨架 | A1 完成 | conformance 套在 `LocalHost` 上全绿 |
-| **A3** | 调用点改造(M2) | A2 的**签名**冻结(不必等实现) | 现有全部测试逐条绿 + §1 豁免清单外零行为变化 |
-| **A4** | `control.rs` wire + `RemoteHost` + `remote_link.rs` | A1 完成;**wire 先落地**(见下) | wire round-trip 测试全绿;`RemoteHost` 在 conformance 套上全绿 |
-| **A5** | control 服务端 handler + `tty7-server` + `duplex.rs` + stdio 桥 | A4 的 **wire 模块**(不必等 `RemoteHost`) | stdio 端到端:本机起子进程跑通全套 conformance |
-
-**同步点**:
-
-```
-A1 ────┬──> A2 ──签名冻结──> A3
- │
- └──> A4 第一个 commit = control.rs wire(纯类型 + 编解码 + 测试)
- │
- ├──> A4 继续做 RemoteHost
- └──> A5 消费 wire,做服务端
-```
-
-**A4 的第一个 commit 必须只含 wire**,不含 `RemoteHost`。这是 A4/A5 并行的唯一前提。
-
-**互不重叠保证**:
-
-| 文件 | 唯一写者 |
-|---|---|
-| `crates/tty7-core/src/host/{mod,local,conformance}.rs` | A2 |
-| `src/ui/{host_ops,host_registry}.rs` | A2 |
-| `crates/tty7-core/src/host/remote.rs`, `crates/tty7-core/src/daemon/{control,remote_link}.rs` | A4 |
-| `crates/tty7-core/src/host/server.rs`, `crates/tty7-core/src/daemon/duplex.rs`, `crates/tty7-server/**` | A5 |
-| `src/ui/{file_tree,code_editor,app}.rs`, `src/terminal/*` | A3 |
-| 搬迁、`Cargo.toml`、`.github/**` | A1(A1 完成后,各 agent 只加自己的依赖行) |
-
-**三处共享文件的追加规则**(避免撞车):
-
-| 文件 | 规则 |
-|---|---|
-| `crates/tty7-core/src/lib.rs` | 只有 A2 加 `pub mod host;`,一行 |
-| `crates/tty7-core/src/daemon/mod.rs` | A4 加 `pub mod control;` + `pub mod remote_link;`;A5 加 `pub mod duplex;`。各加各的行,不重排 |
-| `crates/tty7-core/src/core/git.rs` | 只有 A2 动(加 `git_output`),A3 只读 |
-
----
-
-## 10. conformance 测试规格
-
-**位置**:`crates/tty7-core/src/host/conformance.rs`
-**负责人**:A2 定义 + 落地全部 case,A4/A5 只接线
-
-### 10.1 组织方式:`&dyn Host` + 宏展开成独立 `#[test]`
-
-**不用泛型函数** —— `Host` 已经 object-safe(这是 §1 保持阻塞签名的直接收益),`&dyn Host` 更简单且能验证 object safety 本身。
-
-**不用单个大函数** —— 失败必须定位到具体 case,一个 `Vec` 汇总会让 CI 输出难读。
-
-```rust
-// conformance.rs
-
-/// 每个 case 的签名。`h` 是被测 host,`sandbox` 是一个该 host 上的空目录
-/// (本地是 tempdir,远程是服务端上的 tempdir)。
-pub type Case = fn(h: &dyn Host, sandbox: &Path);
-
-/// 全部 case 的注册表:`(名字, 函数)`。
-pub const CASES: &[(&str, Case)] = &[
- ("read_dir_lists_and_sorts", read_dir_lists_and_sorts),
- ("read_dir_missing_is_not_found", read_dir_missing_is_not_found),
- // ...
-];
-
-/// 为一个 host 工厂展开出全部 `#[test]`。
-#[macro_export]
-macro_rules! host_conformance_suite {
- ($modname:ident, $factory:expr) => {
- mod $modname {
- $crate::host::conformance::declare_cases!($factory);
- }
- };
-}
-```
-
-`declare_cases!` 用一个 `const` 列表配合 `seq`-风格展开做不到 —— Rust 宏不能遍历 `const` 数组。**实际做法**:把 case 名字写进宏本身。
-
-```rust
-// conformance.rs 里
-#[macro_export]
-macro_rules! for_each_host_case {
- ($cb:ident) => {
- $cb!(read_dir_lists_and_sorts);
- $cb!(read_dir_missing_is_not_found);
- $cb!(stat_reports_len_and_mtime);
- // ... 一行一个
- };
-}
-
-#[macro_export]
-macro_rules! host_conformance_suite {
- ($modname:ident, $factory:expr) => {
- mod $modname {
- macro_rules! __case {
- ($name:ident) => {
- #[test]
- fn $name() {
- let (h, sandbox) = ($factory)();
- $crate::host::conformance::$name(&*h, sandbox.path());
- }
- };
- }
- $crate::for_each_host_case!(__case);
- }
- };
-}
-```
-
-**用法**:
-
-```rust
-// host/local.rs 的 tests
-host_conformance_suite!(local, || (LocalHost::new(), TempDir::new().unwrap()));
-
-// host/remote.rs 的 tests(A4)
-host_conformance_suite!(remote_stdio, || spawn_stdio_server_and_host());
-```
-
-**新增一个 case = 改两处**:`for_each_host_case!` 加一行,写一个同名 `pub fn`。两个 host 自动都跑上。忘记加进宏 = case 不跑 —— 加一个元测试 `every_pub_case_is_registered` 用 `include_str!` + 计数比对来兜底。
-
-### 10.2 Case 清单
-
-**fs 读**
-
-| case | 断言 |
-|---|---|
-| `read_dir_lists_and_sorts` | 目录在前、然后 lowercase 名字序;与 `file_tree::sort_entries` 逐字一致 |
-| `read_dir_includes_hidden` | `.hidden` 出现在结果里(过滤是 UI 的事) |
-| `read_dir_missing_is_not_found` | `ErrorKind::NotFound` |
-| `read_dir_on_a_file_errors` | `NotADirectory`(Windows 上可能是 `Other`,断言"是 Err"即可) |
-| `read_dir_marks_dotgit_ignored` | `.git` 的 `ignored == true`,即使没有 `.gitignore` |
-| `read_dir_applies_gitignore_chain` | 根 `.gitignore` 有 `*.log`,`src/.gitignore` 有 `!keep.log` → `drop.log` ignored、`src/keep.log` 不 ignored(照抄 `file_tree.rs:1580-1590` 的既有 fixture) |
-| `read_dir_without_root_ignores_nothing` | `root = None` 时除 `.git` 外全 `ignored == false` |
-| `read_dir_symlink_to_dir_is_dir` | `is_dir == true` 且 `is_symlink == true`(Windows 跳过) |
-| `stat_reports_len_and_mtime` | `len` 精确;`mtime` 有值 |
-| `stat_missing_is_not_found` | `NotFound` |
-| `exists_matches_stat` | `exists(p) == stat(p).is_ok()`,覆盖存在/不存在/无权限三态 |
-| `canonicalize_resolves_dotdot` | `a/../b` → `b` |
-| `read_file_roundtrips_bytes` | 含 NUL、非 UTF-8 字节、10MB 内容各一 |
-| `read_file_over_max_bytes_errors` | `FileTooLarge`,且**不传输内容**(远程侧断言帧大小) |
-| `read_file_on_a_dir_errors` | Err |
-
-**fs 写**
-
-| case | 断言 |
-|---|---|
-| `write_file_creates_and_overwrites` | 新建 + 覆盖,返回的 `Meta.mtime` 与随后 `stat` 一致 |
-| `write_file_to_missing_parent_errors` | `NotFound`,且**不创建父目录** |
-| `create_file_new_rejects_existing` | `AlreadyExists` |
-| `create_dir_non_recursive_needs_parent` | 无父目录时 `NotFound` |
-| `create_dir_recursive_makes_chain` | 多级一次建成;已存在时 `Ok` |
-| `rename_moves_and_rejects_existing_target` | 目标存在 → `AlreadyExists`(**实现保证**,不靠调用方先探) |
-| `rename_across_dirs_works` | 同一 host 内跨目录 |
-| `remove_file_then_missing` | 删后 `exists == false`;再删 `NotFound` |
-| `remove_dir_non_recursive_needs_empty` | 非空且 `recursive = false` → `DirectoryNotEmpty` |
-| `remove_dir_recursive_clears_tree` | 深树一次清掉 |
-
-**git**
-
-| case | 断言 |
-|---|---|
-| `repo_root_finds_nearest_git` | 深层子目录 → repo 根;repo 外 → `Ok(None)`(**不是 Err**) |
-| `repo_root_handles_worktree_file` | `.git` 是文件(linked worktree)时也认 |
-| `git_status_porcelain_reflects_changes` | 在 sandbox 里 `git init` + 造一个改动,`git(["status","--porcelain"])` 的 stdout 含该文件 |
-| `git_nonzero_exit_is_ok_not_err` | `git(["rev-parse","--show-toplevel"])` 在非 repo 目录下 → `Ok(Output { status: Some(128), .. })`,**不是 `Err`**。这一条守住 §6.5 的核心约定 |
-| `git_missing_binary_is_err` | `PATH` 剥掉 git 后 → `Err`(本地可测;远程侧用一个注入点) |
-| `git_optional_locks_env_is_set` | 用 `git(["config","--get","--type=bool","x"])` 测不到 —— 改用 `git(["var","-l"])` 的输出或一个 stub `git` shim 断言 `GIT_OPTIONAL_LOCKS=0` 在 env 里 |
-| `git_stdin_is_null` | 造一个会 prompt 的场景(如无 credential helper 的 `git fetch` 到需要密码的 URL),断言**立即失败**而非挂起(带 10s 硬超时) |
-
-**路径算术**
-
-| case | 断言 |
-|---|---|
-| `join_uses_host_separator` | `host.join("/a", "b")` 在远程 host 上恒为 `/a/b`,即使客户端是 Windows |
-| `is_absolute_matches_host_semantics` | 远程 host 上 `/home` 是绝对;Windows 客户端的 `Path::is_absolute` 会说 false,`Host::is_absolute` 必须说 true |
-
-**search**
-
-| case | 断言 |
-|---|---|
-| `search_is_breadth_first` | 浅层命中排在深层前 |
-| `search_skips_ignored_dirs` | `node_modules`(在 `.gitignore` 里)内的命中不出现 |
-| `search_respects_limit` | `limit = 3` 时恰好 3 条 |
-| `search_respects_max_dirs` | 大树 + `max_dirs = 2` 时提前停,不挂起 |
-
-**watch**
-
-| case | 断言 |
-|---|---|
-| `watch_reports_create_and_delete` | 建文件 → 事件里含它的路径(2s 内) |
-| `watch_is_non_recursive` | 子目录内的变更**不**上报(除非该子目录也在集合里) |
-| `watch_set_dirs_adds_and_drops` | `set_dirs` 后新目录有事件、旧目录无事件 |
-| `watch_coalesces_within_window` | 100ms 内 50 次写 → 事件批数 ≤ 2 且路径去重 |
-| `watch_drop_unsubscribes` | drop `WatchSub` 后再改文件,无事件(远程侧断言服务端 watcher 已释放) |
-
-**连接语义(只有 `RemoteHost` 有意义,`LocalHost` 上是平凡真)**
-
-| case | 断言 |
-|---|---|
-| `is_connected_is_true_when_healthy` | |
-| `id_is_stable_across_calls` | |
-| `separator_matches_hello` | |
-
-### 10.3 sandbox 工厂的契约
-
-```rust
-/// 两个 host 的工厂都必须满足:
-/// - 返回一个**空**目录,测试结束时清理
-/// - 该目录在 `h` 的命名空间里(远程时是服务端上的路径)
-/// - `git` 可用(工厂负责在里面 `git init` 或提供一个能 init 的环境)
-pub trait Sandbox {
- fn path(&self) -> &Path;
-}
-```
-
-### 10.4 conformance 之外的测试(设计文档 §18 的其它行)
-
-| 层 | 位置 | 负责人 |
-|---|---|---|
-| control wire round-trip | `daemon/control.rs` 的 `mod tests`,照抄 `protocol.rs:1438` 的真 TcpListener 双线程模式 + `:1503` 的逐 variant Cursor 模式 | A4 |
-| 帧解码的**恶意输入** | `json_n` 溢出、`payload_len < 12`、`CONTROL_EVENT` 带非零 req_id、未知 kind —— 每条一个 `#[test]`,断言 `InvalidData` 而非 panic | A4 |
-| 版本 skew 握手 | `control_version` 不匹配的分支 | A4 |
-| 传输:stdio 回退 | 起真的 `tty7-server --stdio` 子进程 | A5 |
-| 端到端 | 开 workspace → 开 pane → 断开 → 重连补屏 → 接管 | A5(M3 范围内只做前两步) |
-| 回归护栏 | **M1 的现有全部测试逐条绿,不许改测试来适配** | A1 |
-
-### 10.5 M2 的回归护栏(M2 不是零行为变化,所以要额外的)
-
-| 守卫 | 内容 |
-|---|---|
-| `worktree add` / `list` 全路径 | §4.4 给 `worktree` 加了 `GIT_OPTIONAL_LOCKS=0`,必须实测 |
-| `code_editor` mtime 冲突检测 | 保存→外部改→重载三态,异步化后仍然正确(`MTime` 纳秒精度是为它设计的) |
-| `file_tree` 乐观更新回滚 | 新建/改名/删除失败时行必须恢复 |
-
-### 10.6 CI grep 守卫(§4.3 的执行手段)
-
-✅ **已落地**:`.github/scripts/check-host-boundary.sh`,接在 `ci.yml` 的 `host boundary (§10.6)` job 上(**故意不设 required** —— required 只有 `rustfmt` + 三个 `build & test ()`,加进去会当场卡死所有在开的 PR)。
-
-本节原来写的裸 grep **不能直接用**,干净树上 42 个命中、一个都不是 git。脚本对它做了两处修正:
-
-| 问题 | 裸 grep | 脚本 |
-|---|---|---|
-| 测试体 | `grep -v '#\[cfg(test)\]'` 只滤掉属性行本身,后面整个 test mod 还在扫描范围里(42 中的 23 行) | 扫到收尾的 `#[cfg(test)] mod …` 就截断。**只认后面紧跟 `mod ` 的那一个** —— `presets.rs:540` / `search.rs:513` 是挂在函数上的 `#[cfg(test)]`,截在那儿会把真实代码整段漏掉 |
-| 本地路径 | 无豁免,19 处必然本地的路径全报 | (文件, 模式) 二元组允许清单,每条写明"为什么这个路径不可能是远程的";按模式而不是按文件豁免,所以 `app.rs` 豁免了 `create_dir_all` 之后再出现 `std::fs::read_to_string` 照样红 |
-
-清单目前 19 行,分五类:主题/预设(`presets.rs`、`app.rs` 的打开主题目录)、shell 历史(`history.rs`)、补全(`completion.rs` 远程走 `cwd: None` 分支、`signature.rs` 的 bundled specs)、SSH 私钥读取(`ssh_prompt.rs`、`ssh_connect.rs`)、剪贴板截图暂存(`view.rs` 的 `temp_dir()`)。加一条是有意行为:**路径有可能是 workspace 路径,答案就是 `Host`,不是清单。**
-
-脚本自身还守两件事:扫描根不存在 → exit 2;扫到的文件数 < 10 → exit 2。"扫了个空然后报绿"比噪音守卫更糟。
-
----
-
-## 11. 设计文档勘误清单
-
-复核过的每一条,标明设计文档哪一节、实际是什么、本契约怎么处理。
-
-| # | 设计文档 | 说的 | 实际 | 本契约 |
-|---|---|---|---|---|
-| 1 | §4 表格 "传输抽象" | "`Stream = Read + Write + try_clone`",暗示是 trait | **type alias**:`daemon/transport.rs:44` Unix `= UnixStream`,`:397` Windows `= TcpStream` | §7.1 |
-| 2 | §4 同上 | "多一种传输形态不破坏上层" | **结论成立,理由完全不同**:远程流根本不经过 `transport::Stream`,它止步于本地 daemon | §7.1-7.3 |
-| 3 | §9 | "这些调用点现在**全部已经**在 background executor 上跑…调用点的结构一行不用动" | **只有** `file_tree` 的 `read_dir` + gitignore(`request_load` :448-479)。`code_editor` 9 处、`file_tree` 变更操作 5 处、`app.rs` 的 git 2 处**全在 UI 线程同步跑** | §1,含完整清单 + 豁免的行为变化表 |
-| 4 | §19 | "M1 / M2 是纯重构、零行为变化" | **M2 不是**。异步化必然带来 §1 表里那 5 类可见变化 | §1,把变化显式列成豁免清单 |
-| 5 | §9 watch 那行 | 把"只 watch 已展开目录、非递归"写成现状 | `file_tree.rs:413` 是 `RecursiveMode::**Recursive**` watch 所有 root。(`code_editor.rs:520` 确实是 NonRecursive —— 两个 watcher 行为不同) | §2 `WatchSub`,明确这是**新行为** |
-| 6 | §9 `git` 约定 | "`GIT_OPTIONAL_LOCKS=0` 的只读约定(现在在 `git_status::git` helper 里)" | **三套出口**:`git_status.rs:332`(有)、`worktree.rs:63`(**无**,且返回 `Result`)、`app.rs:3995`(**无**,用 `current_dir`,**UI 线程**) | §4.4 统一表 + 三个调用方的逐一适配 |
-| 7 | §8 帧表 | 大 payload 形态 = `[u64 req_id][raw bytes]` | **承载不了参数**。`write_file` 要路径,`read_file` 响应要 `Meta` | §6.2 改为 `[u64 req_id][u32 json_len][JSON][raw]` |
-| 8 | §8 | "`PROTOCOL_VERSION` bump 到 **3**",未给理由 | 按 `protocol.rs:42-48` 自述规则,**新增 kind 是 additive,不需要 bump** | §6.9 给出真实理由(能力探测位)+ 新增 `features` 字段让后续能力不再 bump |
-| 9 | §9 方法表 | 没有 `search` | `file_tree.rs::TreeLoader::search`(:177+) BFS 最多 2000 目录。逐目录 RPC = 2000 次往返 | §2 新增 `Host::search`,服务端执行 |
-| 10 | §9 方法表 | 缺 `exists` / `canonicalize` / `create_file_new` / `create_dir(recursive)` | 既有调用点用得到:`file_tree:963`(`exists`) `:998`(`is_dir`) `:953`(`create_new`)、`code_editor:328`(`canonicalize`)、`worktree` 的 `.tty7/` 要 `create_dir_all` | §2 全部补上 |
-| 11 | §11 | "`git_status` 的 shell-out helper **搬**进 tty7-core" 与 "留在 GUI crate:`terminal/*`" | 自相矛盾 —— `git_status.rs` 就在 `src/terminal/` | ✅ A1 已按"提取"解决:`core::git`(`probe`/`branch_name`/`git`),`git_status.rs` 本体留 GUI |
-| 12 | §11 | "gitignore 解析…搬" | **不是独立模块**,是 `ui/file_tree.rs::TreeLoader::is_gitignored`(:142-175) 的内联实现 | ✅ A1 已提取为 `core::gitignore::GitignoreChain` |
-| 12b | §11 | "留在 GUI crate:`core/{actions, window_state, update}`" | A1 把 `window_state` 搬进了 core | 接受现状,不回滚 |
-| 13 | §9 表格 | 引 `ui/app.rs:3895` 的 agent diff | 实际 **3978-4015**(`send_git_diff_to_agent`) | §1 表已用真实行号 |
-| 14 | §4 | 引 `ui/file_tree.rs:628` 的 repo root | 实际 **626-630**(`repo_root_for`)。另有第二份:`core/worktree.rs:58-60` `is_inside_repo`(返回 `bool`) | §2 `Host::repo_root` 统一两份 |
-| 15 | §6 图 | "RemoteRouter ◄── 新:**纯字节转发,不解析**" | 与 §12 的远程 `ensure_running` 潜在冲突:本地 daemon 若要做远程版本握手就必须解析 | §6.9:**GUI 端到端握手**,router 保持纯转发。远程 workspace 有**两次独立**版本检查 |
-| 16 | — | 未提及 | `mod kind`(`protocol.rs:974`)是**私有**的,跨模块加 kind 需要先改可见性 | §6.1:control kind 定义在 `control.rs` 自己的 `pub mod kind` 里,不动 `protocol::kind` |
-| 17 | — | 未提及 | kind **13** 是**退役**号(曾是 `SPAWN_MANAGED_SSH`),不是空闲号。复用它会让 pre-WS2 daemon 静默 mis-spawn 一个 pane | §6.1:退役号永不复用;选 60-63 |
-| 18 | — | 未提及 | **跨 OS 路径算术**:Windows 客户端上 `PathBuf::join("/home/me", "src")` → `/home/me\src`;`"/home/me".is_absolute()` → `false` | §4.3 完整禁用/可用表 + §2 的 `Host::join` / `is_absolute` |
-| 19 | — | 未提及 | `Output.stdout` 走 JSON 会膨胀 ~4× | §6.4:`serde_bytes` base64 |
-| 20 | 任务简报 | `local_cwd()` 挡板 8 处 | **9 处**:`app.rs:2827/2932/3297/3458/3988/5796`、`view.rs:3798/4590/4750`(漏了 `app.rs:2932`) | A3 按 9 处处理 |
-
-**复核通过、无偏差的**(不必再查):
-
-| 项 | 结论 |
-|---|---|
-| §11"`src/daemon/` 依赖的 core 模块只有 7 个" | ✅ 实测正好 `agent_hooks` `cli_agent` `config` `osc` `proc` `shells` `threads` |
-| 帧格式 / `MAX_FRAME = 64 MiB`(:33) / `write_frame`(:1064) / `read_frame`(:1081) / `take_frame`(:1105) | ✅ |
-| 热路径零序列化、冷路径 JSON tuple(`Spawn` → `(cwd, size)`, :1147) | ✅ |
-| `PROTOCOL_VERSION = 2`(:59),bump 规则 :42-57 | ✅ |
-| 未知 kind → `InvalidData`(:1287-1292) | ✅ |
-| 一条连接一个 pane、控制类短连接、无 req_id、无多路复用 | ✅ |
-| round-trip 测试模式 :1438 / :1503 | ✅ |
-| russh `channel_open_direct_streamlocal` 存在于 fork `0d1d073` 的 `client/mod.rs:854`,签名如简报所述 | ✅ |
-| `file_tree` 的 `read_dir` + gitignore 在 background(:456-466) | ✅ |
-| `GitStatusCache` 5 张 `PathBuf` 表 + `impl gpui::Global`(:128-149) | ✅ |
-| 远程路径补全的分流写法(`view.rs:3953-4020`)可作模板 | ✅ 已定为 §5 的强制模式 |
-| `daemon/ssh/connect.rs:29-33` 已有 `Transport` 枚举(Tcp/Process/Channel),是 §7.2 的现成形状 | ✅ 新发现,直接复用 |
-
----
-
-## 12. 明确不在本契约内
-
-留给后续里程碑,本波 agent **不要**动:
-
-| 项 | 归属 |
-|---|---|
-| 安装 / 下载 / sha256 / `uname` 解析 | M4 |
-| SSH 侧的 `RemoteRouter` 接线、连接复用 | M4 |
-| workspace store 的服务端实现(本契约只定 RPC 位) | M5 |
-| `Workspace.host` 字段、`RemoteRef`、首页「连接主机」 | M5 |
-| 重连退避 / 接管 / 启动排队认证 | M6 |
-| 端口转发的 workspace 维度、SFTP 接线 | M7 |
-| WSL | M8 |
diff --git a/docs/remote-server-assets.md b/docs/remote-server-assets.md
deleted file mode 100644
index 7ca92222..00000000
--- a/docs/remote-server-assets.md
+++ /dev/null
@@ -1,122 +0,0 @@
-# `tty7-server` release assets
-
-Contract between the **release workflow** (which produces the assets) and the
-**client installer** (`§12` of `2026-07-27-remote-workspace-design.md`, which
-downloads and verifies them). Both sides must agree literally — the client
-derives the asset name mechanically from `uname -sm`, with no discovery step and
-no listing of the release.
-
-## Asset names
-
-```
-tty7-server-
-```
-
-| Asset | Target | Linkage |
-|---|---|---|
-| `tty7-server-x86_64-unknown-linux-musl` | `x86_64-unknown-linux-musl` | static (`crt-static`, no interpreter) |
-| `tty7-server-aarch64-unknown-linux-musl` | `aarch64-unknown-linux-musl` | static (`crt-static`, no interpreter) |
-| `checksums.txt` | — | sha256 of **every** asset in the release |
-
-**No version in the filename.** The version lives in the release tag (i.e. in the
-download URL) and in the remote install path (`§12`), never in the asset name.
-That keeps the `uname -sm` → filename mapping a pure function with nothing to
-interpolate, and makes `…/releases/latest/download/tty7-server-` a
-permanently valid "current stable server" URL.
-
-**Static is a guarantee, not a hope.** The release job asserts it
-(`.github/scripts/assert-static.sh`): the binary must report `statically linked`,
-carry no ELF interpreter, and declare no `DT_NEEDED` shared libraries, or the job
-fails. D10 exists so one binary runs on any distro without regard to the target
-machine's glibc — a dynamically-linked build would silently break that on the
-first old CentOS box, far from the change that caused it.
-
-**Size** is roughly **6 MB** (stripped, release, x86_64). Worth knowing because
-§12 requires the first-install confirmation to tell the user how much is about to
-be written to their machine — quote the `Content-Length`, but this is the
-expected order of magnitude.
-
-## `uname -sm` → asset
-
-| `uname -s` | `uname -m` | Asset |
-|---|---|---|
-| `Linux` | `x86_64`, `amd64` | `tty7-server-x86_64-unknown-linux-musl` |
-| `Linux` | `aarch64`, `arm64`, `armv8l`, `armv8b` | `tty7-server-aarch64-unknown-linux-musl` |
-| `Linux` | anything else | **unsupported** — abort with the raw `uname -sm` in the message |
-| anything else | — | **unsupported** — abort |
-
-- **Match on the exact strings, then fail.** No prefix matching, no "probably
- arm" heuristics: installing the wrong architecture produces an `Exec format
- error` far from the cause. An unknown machine string is a clean, explainable
- refusal.
-- **`aarch64` is what Linux actually reports**; `arm64` is accepted because some
- container images and BSD-flavoured userlands normalise to it.
-- **32-bit is deliberately absent.** No `i686`, no `armv7l`, no `riscv64` — add a
- row *and* a CI target together if that ever changes.
-
-## Download URL
-
-```
-https://github.com/l0ng-ai/tty7/releases/download//
-```
-
-| Client version | `` |
-|---|---|
-| `26.7.5` | `v26.7.5` |
-| `26.7.6-nightly.20260727` | `nightly` |
-
-The nightly channel publishes to a **single rolling `nightly` tag** whose assets
-are replaced every night, so a nightly client must not ask for
-`v26.7.6-nightly.20260727` — that tag does not exist. Rule: version contains
-`-nightly.` → tag is `nightly`; otherwise tag is `v` + version.
-
-## Verifying (`§16`)
-
-`checksums.txt` is GNU coreutils `sha256sum` format — 64 lowercase hex chars, two
-spaces, the bare asset filename (digests below are illustrative, not real):
-
-```
-3f786850e387550fdab836ed7e6dc881de23001b4b4d8ec3a1a0b9d5e0d5c0f1 tty7-server-x86_64-unknown-linux-musl
-9e107d9d372bb6826bd81d3542a419d6f0d1b0b6c1c1c1c1c1c1c1c1c1c1c1c1 tty7-server-aarch64-unknown-linux-musl
-```
-
-1. **Fetch `checksums.txt` from the same release** as the binary. HTTPS to
- `github.com` is the trust anchor; the file is not separately signed.
-2. **Find the line whose filename field equals the asset name** — exact match on
- the whole field. Do not substring-search: `tty7-server-x86_64-unknown-linux-musl`
- is a substring of nothing today, but that is an accident, not a rule.
-3. **Compare hex case-insensitively** against the sha256 of the bytes actually
- downloaded.
-4. **Absent line, malformed line, or mismatch → abort the install.** Do not
- retry, do not fall back to an unverified install, do not write the temp file
- through (`§17`). Report the expected and actual digests.
-
-The digest covers the raw asset bytes, i.e. exactly what gets SFTP-put to
-`~/.local/share/tty7/bin/.tty7-server-.tmp` before the `chmod 0755` +
-rename.
-
-## Where this is produced
-
-| Workflow | Job | Note |
-|---|---|---|
-| `.github/workflows/release.yml` | `server-musl` → `draft-release` | tagged releases; `checksums.txt` is generated in the assemble job over all collected assets |
-| `.github/workflows/nightly.yml` | `server-musl` → `publish` | same assets on the rolling `nightly` tag |
-| `.github/workflows/ci.yml` | `server-musl` | compile-only guard on PRs; publishes nothing |
-
-## The Windows build bundles one of them (WSL)
-
-A WSL distro is **not** served from a release download. Design §12: it gets the
-Linux binary the Windows client already shipped with, because the distro is on
-the same machine and there is no network hop worth making.
-
-| | |
-|---|---|
-| Which asset | `tty7-server-x86_64-unknown-linux-musl` only — there is no ARM64 Windows target in the matrix. Add the aarch64 one *with* that target, not before |
-| Where it lands | `\server\`, in both the installer and the portable zip |
-| Who looks there | `daemon::install::wsl` — `BUNDLED_SUBDIR`; it also accepts `\`, and `TTY7_BUNDLED_SERVER_DIR` overrides both |
-| If it is missing | The build still ships (a warning, mirroring `server-musl`'s own skip-don't-fail probe). A WSL connect then fails with `MissingBundled`, naming every directory searched — it never silently falls back to downloading |
-
-**This makes `build` depend on `server-musl`** in `release.yml` and
-`nightly.yml`, so the two no longer run in parallel. The directory name is a
-contract with `wsl.rs`, not a packaging detail — changing it on one side breaks
-WSL on the other.
diff --git a/src/core/session.rs b/src/core/session.rs
index 54e1ee45..f15adee4 100644
--- a/src/core/session.rs
+++ b/src/core/session.rs
@@ -84,6 +84,7 @@ impl WorkspaceStore {
// workspace whose machine is unreachable must open empty. See
// [`claimable_session`].
let reachable = id.is_none_or(|id| Self::machine_is_connected(cx, id));
+ let instance = Self::serving_instance(cx, id);
let Some(store) = Self::try_store(cx) else {
// No store (tests): hand back a detached identity so the window
// still builds, but nothing is persisted.
@@ -99,7 +100,10 @@ impl WorkspaceStore {
};
workspace.open = true;
workspace.touch();
- let claimed = (workspace.id, claimable_session(workspace, reachable));
+ let claimed = (
+ workspace.id,
+ claimable_session(workspace, reachable, instance.as_deref()),
+ );
store.workspaces.active = Some(claimed.0);
store.workspaces.save();
claimed
@@ -118,6 +122,7 @@ impl WorkspaceStore {
// describing that machine's layout, so it does not get to overwrite the
// copy we have of it — see [`record_session`].
let reachable = Self::machine_is_connected(cx, id);
+ let instance = Self::serving_instance(cx, Some(id));
let Some(store) = Self::try_store(cx) else {
return;
};
@@ -126,7 +131,7 @@ impl WorkspaceStore {
// tearing down); nothing to record.
return;
};
- record_session(workspace, session, reachable);
+ record_session(workspace, session, reachable, instance);
if let Some(window) = window {
workspace.window = Some(window);
}
@@ -201,6 +206,29 @@ impl WorkspaceStore {
store.workspaces.save();
}
+ /// Drop the pane ids a workspace claims, keeping its layout. Answers
+ /// whether anything changed, so a caller can skip the follow-up push to a
+ /// remote that owns the record.
+ ///
+ /// Called right after those panes have been killed — see
+ /// [`Workspace::forget_pane_ids`] for why the ids have to go rather than
+ /// being left for the reattach to trip over.
+ pub fn forget_pane_ids(cx: &mut gpui::App, id: WorkspaceId) -> bool {
+ let Some(store) = Self::try_store(cx) else {
+ return false;
+ };
+ let Some(workspace) = store.workspaces.get_mut(id) else {
+ return false;
+ };
+ let forgotten = workspace.forget_pane_ids();
+ if forgotten == 0 {
+ return false;
+ }
+ store.workspaces.save();
+ log::info!("workspace {id} forgot {forgotten} pane id(s): its sessions were ended");
+ true
+ }
+
/// Forget a workspace entirely — the explicit "Close Workspace" action.
/// The caller is responsible for killing its daemon panes first; this only
/// drops the bookkeeping.
@@ -215,7 +243,7 @@ impl WorkspaceStore {
store.workspaces.save();
}
- // ----- the client / remote storage split (design §10) -------------------
+ // ----- the client / remote storage split -------------------
/// The machine a workspace's panes are on. `HostId::LOCAL` for a workspace
/// this client owns, and for an id that is no longer on file — a window
@@ -243,6 +271,60 @@ impl WorkspaceStore {
crate::ui::remote_connect::RemoteConnections::get(cx, host.host_id()).is_some()
}
+ /// The process whose pane ids this workspace's record is about: this
+ /// machine's daemon for a local workspace, the far machine's `tty7-server`
+ /// for a remote one. `None` when it cannot be named — an older peer, a
+ /// machine not connected right now, or a brand-new workspace with no host
+ /// yet — which every reader treats as "no instance check possible".
+ ///
+ /// One function for both because [`Workspace::daemon_instance`] means the
+ /// same thing on both sides. It used to be local-only, on the reasoning
+ /// that a remote server's identity is tracked live per connection instead
+ /// — but that live map lives in memory, so it is empty on the launch that
+ /// matters most: the one where the client was closed while the remote
+ /// server was replaced.
+ pub fn serving_instance(cx: &mut gpui::App, id: Option) -> Option {
+ match id.and_then(|id| Self::remote_ref(cx, id)) {
+ Some(host) => crate::ui::remote_connect::RemoteConnections::get(cx, host.host_id())
+ .map(|h| h.peer().instance.clone())
+ .filter(|instance| !instance.is_empty()),
+ None => crate::daemon::spawn::local_daemon_instance(),
+ }
+ }
+
+ /// Blank `id`'s saved pane ids when they were recorded against a different
+ /// server process than `instance`, and persist that. Answers whether any
+ /// were dropped.
+ ///
+ /// The remote counterpart of the check [`claimable_session`] runs for a
+ /// local workspace at claim time. It cannot run there for a remote one: at
+ /// claim time the machine is usually not connected yet, so there is no
+ /// instance to compare against. The reconnect is the first moment the
+ /// answer exists, which is where this is called from.
+ pub fn forget_stale_pane_ids(cx: &mut gpui::App, id: WorkspaceId, instance: &str) -> bool {
+ let current = (!instance.is_empty()).then_some(instance);
+ let Some(store) = Self::try_store(cx) else {
+ return false;
+ };
+ let Some(workspace) = store.workspaces.get_mut(id) else {
+ return false;
+ };
+ let dropped = workspace.forget_stale_pane_ids(current);
+ if dropped == 0 {
+ return false;
+ }
+ // Stamped now rather than left for the next save: the record has just
+ // been made to describe *this* server, and a crash before the window
+ // saves must not leave it claiming the old process again.
+ workspace.daemon_instance = current.map(str::to_string);
+ store.workspaces.save();
+ log::info!(
+ "workspace {id}: {dropped} saved pane id(s) belong to a previous \
+ tty7-server process; rebuilding from the layout"
+ );
+ true
+ }
+
/// The client-side entry for `host` — the existing one if this machine has
/// seen that workspace before, a fresh one otherwise.
///
@@ -366,10 +448,34 @@ pub(crate) fn crosses_machines(previous: HostId, current: HostId) -> bool {
/// touching the cached layout**, and
/// [`crate::ui::remote_workspace`]'s connect path rebuilds the window the moment
/// the machine answers.
-fn claimable_session(workspace: &mut Workspace, reachable: bool) -> Session {
+/// `current_instance` is the identity of the process serving this workspace's
+/// panes (see [`WorkspaceStore::serving_instance`]). A workspace whose saved ids
+/// were recorded against a different one blanks them first — after a restart the
+/// numbers begin again at 1, so a stale id would otherwise pass the aliveness
+/// check by landing on whatever unrelated pane holds it now. Blanked in the
+/// stored entry too, not just the returned copy, so the record stops claiming
+/// panes that no longer exist even if the window never saves again.
+///
+/// A remote workspace usually reaches the early return above instead: at claim
+/// time its machine is not connected yet, so there is no instance to compare and
+/// no layout to hand back. `remote_workspace::finish_attempt` runs the same
+/// check the moment the connect answers, which is the first point it can.
+fn claimable_session(
+ workspace: &mut Workspace,
+ reachable: bool,
+ current_instance: Option<&str>,
+) -> Session {
if workspace.is_remote() && !reachable {
return Session::default();
}
+ let dropped = workspace.forget_stale_pane_ids(current_instance);
+ if dropped > 0 {
+ log::info!(
+ "workspace {}: {dropped} saved pane id(s) belong to a previous serving \
+ process; restoring with fresh shells (and agent resume where recorded)",
+ workspace.id
+ );
+ }
workspace.session.clone()
}
@@ -381,11 +487,26 @@ fn claimable_session(workspace: &mut Workspace, reachable: bool) -> Session {
/// it records nothing rather than replacing the copy we have with the wreckage.
/// The remote's own `workspaces.json` is still the authority; this entry is the
/// cache the next launch opens from.
-fn record_session(workspace: &mut Workspace, session: Session, reachable: bool) {
+/// The record is stamped with the process its pane ids came from (`instance`):
+/// this machine's daemon for a local workspace, the far machine's
+/// `tty7-server` for a remote one. That is what lets the next launch tell a
+/// surviving process from a replaced one — see [`claimable_session`] and
+/// [`WorkspaceStore::forget_stale_pane_ids`].
+///
+/// The unreachable early return doubles as the guard on that stamp: with the
+/// machine down there is no instance to record, and writing `None` over a good
+/// one would throw away the very comparison the next connect needs.
+fn record_session(
+ workspace: &mut Workspace,
+ session: Session,
+ reachable: bool,
+ instance: Option,
+) {
if workspace.is_remote() && !reachable {
return;
}
workspace.session = session;
+ workspace.daemon_instance = instance;
}
#[cfg(test)]
@@ -427,7 +548,7 @@ mod tests {
#[test]
fn a_local_workspace_stores_its_own_layout() {
let mut workspace = Workspace::default();
- record_session(&mut workspace, local_layout(), true);
+ record_session(&mut workspace, local_layout(), true, None);
assert_eq!(workspace.session.tabs.len(), 1);
assert_eq!(workspace.pane_ids(), vec![7]);
}
@@ -439,7 +560,7 @@ mod tests {
#[test]
fn a_connected_remote_workspace_stores_its_layout() {
let mut workspace = Workspace::on_remote(remote_ref());
- record_session(&mut workspace, local_layout(), true);
+ record_session(&mut workspace, local_layout(), true, None);
assert_eq!(workspace.session.tabs.len(), 1);
assert_eq!(
workspace.pane_ids(),
@@ -448,6 +569,35 @@ mod tests {
);
}
+ /// A remote workspace's record is stamped with the **server's** instance,
+ /// not left blank. That stamp is the only part of "which process minted
+ /// these ids" that survives the client being closed, and it is what the
+ /// next connect compares against before re-attaching anything.
+ #[test]
+ fn a_connected_remote_workspace_records_the_serving_instance() {
+ let mut workspace = Workspace::on_remote(remote_ref());
+ record_session(
+ &mut workspace,
+ local_layout(),
+ true,
+ Some("server-a".to_string()),
+ );
+ assert_eq!(workspace.daemon_instance.as_deref(), Some("server-a"));
+ }
+
+ /// …and an unreachable machine does not un-stamp it. `None` there means
+ /// "nobody to ask", and writing it over a good value would disarm the very
+ /// check the next connect needs — the ids would look current again.
+ #[test]
+ fn an_unreachable_remote_window_does_not_erase_the_recorded_instance() {
+ let mut workspace = Workspace::on_remote(remote_ref());
+ workspace.session = local_layout();
+ workspace.daemon_instance = Some("server-a".to_string());
+ record_session(&mut workspace, Session::default(), false, None);
+ assert_eq!(workspace.daemon_instance.as_deref(), Some("server-a"));
+ assert_eq!(workspace.session.tabs.len(), 1, "and the layout stays too");
+ }
+
/// A local workspace opens on the layout it saved.
#[test]
fn a_local_workspace_reopens_its_saved_layout() {
@@ -455,19 +605,55 @@ mod tests {
session: local_layout(),
..Workspace::default()
};
- let claimed = claimable_session(&mut workspace, true);
+ let claimed = claimable_session(&mut workspace, true, None);
assert_eq!(claimed.tabs.len(), 1);
// And the entry is left alone.
assert_eq!(workspace.session.tabs.len(), 1);
}
+ /// Claiming a local workspace whose ids were recorded against a *different*
+ /// daemon process blanks them — in the returned session **and** in the
+ /// stored entry. After a reboot the numbers restart from 1, so a stale id
+ /// passes the aliveness check by landing on whatever unrelated pane holds
+ /// it now; blanking is what turns that into an honest fresh spawn (with
+ /// the agent resume the leaf recorded).
+ #[test]
+ fn claiming_a_local_workspace_from_another_daemon_process_blanks_its_ids() {
+ let mut workspace = Workspace {
+ session: local_layout(),
+ daemon_instance: Some("previous-boot".into()),
+ ..Workspace::default()
+ };
+ let leaf_id = |session: &Session| match &session.tabs[0].pane {
+ SessionPane::Leaf { pane_id, .. } => *pane_id,
+ SessionPane::Split { .. } => panic!("the fixture is a single leaf"),
+ };
+ let claimed = claimable_session(&mut workspace, true, Some("current-boot"));
+ assert_eq!(claimed.tabs.len(), 1, "the layout still restores");
+ assert_eq!(
+ leaf_id(&claimed),
+ None,
+ "but no leaf may attach by a number from a dead daemon"
+ );
+ assert!(workspace.pane_ids().is_empty(), "the entry agrees");
+
+ // Same process → the ids stay attachable.
+ let mut workspace = Workspace {
+ session: local_layout(),
+ daemon_instance: Some("current-boot".into()),
+ ..Workspace::default()
+ };
+ let claimed = claimable_session(&mut workspace, true, Some("current-boot"));
+ assert_eq!(leaf_id(&claimed), Some(7));
+ }
+
/// A connected remote workspace reopens on the layout its machine last
/// reported — the read half of "reconnecting gets my tabs back".
#[test]
fn a_connected_remote_workspace_reopens_its_layout() {
let mut workspace = Workspace::on_remote(remote_ref());
workspace.session = local_layout();
- let claimed = claimable_session(&mut workspace, true);
+ let claimed = claimable_session(&mut workspace, true, None);
assert_eq!(claimed.tabs.len(), 1);
assert_eq!(workspace.session.tabs.len(), 1);
}
@@ -482,7 +668,7 @@ mod tests {
let mut workspace = Workspace::on_remote(remote_ref());
workspace.session = local_layout();
- let claimed = claimable_session(&mut workspace, false);
+ let claimed = claimable_session(&mut workspace, false, None);
assert!(claimed.tabs.is_empty(), "the window must open with no tabs");
assert_eq!(
workspace.session.tabs.len(),
@@ -498,7 +684,7 @@ mod tests {
fn an_unreachable_remote_window_does_not_overwrite_the_cached_layout() {
let mut workspace = Workspace::on_remote(remote_ref());
workspace.session = local_layout();
- record_session(&mut workspace, Session::default(), false);
+ record_session(&mut workspace, Session::default(), false, None);
assert_eq!(workspace.session.tabs.len(), 1);
}
@@ -550,6 +736,121 @@ mod tests {
});
}
+ /// "End Sessions" kills the panes and then has to say so on file, or
+ /// reopening the workspace walks into the reattach path with ids nothing
+ /// answers to. The second call answering `false` is what lets the caller
+ /// skip the push that follows.
+ #[gpui::test]
+ fn forgetting_a_workspaces_panes_is_recorded_once(cx: &mut gpui::TestAppContext) {
+ cx.update(|cx| {
+ crate::core::config::pin_test_config_dir();
+
+ let mut entry = Workspace::on_remote(remote_ref());
+ entry.session = local_layout();
+ let id = entry.id;
+ WorkspaceStore::install_for_test(
+ cx,
+ Workspaces {
+ workspaces: vec![entry],
+ active: None,
+ },
+ );
+ assert_eq!(WorkspaceStore::all(cx).get(id).unwrap().pane_ids(), vec![7]);
+
+ assert!(WorkspaceStore::forget_pane_ids(cx, id));
+ let after = WorkspaceStore::all(cx).get(id).unwrap();
+ assert!(after.pane_ids().is_empty());
+ assert_eq!(
+ after.session.tabs.len(),
+ 1,
+ "the layout is exactly what reopening rebuilds from"
+ );
+ assert!(
+ !WorkspaceStore::forget_pane_ids(cx, id),
+ "nothing left to forget"
+ );
+ });
+ }
+
+ /// **The cold-launch half of the restart check.** `RemoteLinks::instances`
+ /// is in memory, so on the first connect after the client starts every
+ /// machine is a first sighting and nothing is judged a restart. A server
+ /// replaced while the client was closed would therefore sail through, and
+ /// its recycled ids — daemons number panes from 1 — would attach to
+ /// whatever unrelated shells hold those numbers now. The stamp on the
+ /// record is what closes that, so this is the test that has to hold.
+ #[gpui::test]
+ fn a_remote_workspace_drops_pane_ids_minted_by_a_previous_server(
+ cx: &mut gpui::TestAppContext,
+ ) {
+ cx.update(|cx| {
+ crate::core::config::pin_test_config_dir();
+
+ let mut entry = Workspace::on_remote(remote_ref());
+ entry.session = local_layout();
+ entry.daemon_instance = Some("server-a".to_string());
+ let id = entry.id;
+ WorkspaceStore::install_for_test(
+ cx,
+ Workspaces {
+ workspaces: vec![entry],
+ active: None,
+ },
+ );
+
+ // Same process: these ids still name the panes they always did.
+ assert!(!WorkspaceStore::forget_stale_pane_ids(cx, id, "server-a"));
+ assert_eq!(WorkspaceStore::all(cx).get(id).unwrap().pane_ids(), vec![7]);
+
+ // An unknown instance is never judged — a peer too old to report
+ // one must not cost the user every pane on the machine.
+ assert!(!WorkspaceStore::forget_stale_pane_ids(cx, id, ""));
+ assert_eq!(WorkspaceStore::all(cx).get(id).unwrap().pane_ids(), vec![7]);
+
+ // Replaced: the claims go, the layout stays, and the stamp moves on.
+ assert!(WorkspaceStore::forget_stale_pane_ids(cx, id, "server-b"));
+ let after = WorkspaceStore::all(cx).get(id).unwrap();
+ assert!(after.pane_ids().is_empty());
+ assert_eq!(
+ after.session.tabs.len(),
+ 1,
+ "the layout is exactly what the rebuild draws from"
+ );
+ assert_eq!(
+ after.daemon_instance.as_deref(),
+ Some("server-b"),
+ "stamped now, so a crash before the next save cannot re-arm the old claim"
+ );
+
+ // And the same server is not a restart twice over.
+ assert!(!WorkspaceStore::forget_stale_pane_ids(cx, id, "server-b"));
+ });
+ }
+
+ /// **Why clearing the ids locally is not enough.** The remote owns the
+ /// record, so reopening pulls its copy over the client's — and
+ /// a copy that still claims the killed panes puts them straight back. This
+ /// is the constraint `windows::forget_killed_panes` pushes to satisfy; if
+ /// this assertion ever flips, that push is dead weight.
+ #[test]
+ fn a_remote_record_reinstates_pane_ids_a_client_only_clear_dropped() {
+ let mut theirs = Workspace::on_remote(remote_ref());
+ theirs.session = local_layout();
+ let record = theirs.to_remote_json();
+
+ let mut ours = Workspace::on_remote(remote_ref());
+ ours.session = local_layout();
+ ours.forget_pane_ids();
+ assert!(ours.pane_ids().is_empty());
+
+ ours.apply_remote_json(&record).unwrap();
+ assert_eq!(
+ ours.pane_ids(),
+ vec![7],
+ "the machine's copy wins, so the clear has to reach it"
+ );
+ }
+
/// The remote-bound payload travels under the *remote's* id, so a record
/// pushed and pulled back names the same workspace both times.
#[test]
@@ -575,7 +876,7 @@ mod tests {
/// **The window/host invariant, as a test.**
///
- /// Design §2: a window is one machine. Design §3 puts the inverse under
+ /// A window is one machine. The inverse is listed under
/// *never do this*, and the M5 data layer spends that guarantee — a
/// workspace stores `host` once instead of per pane, and `sidebar_group`
/// stays a bare `PathBuf` — so it has to be nailed down rather than
diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs
index 4540b73b..738cd53b 100644
--- a/src/terminal/remote.rs
+++ b/src/terminal/remote.rs
@@ -138,7 +138,7 @@ struct ReaderSignals {
}
/// The remote workspace a pane belongs to, and how the local daemon reaches its
-/// machine (design §15).
+/// machine.
///
/// A pane of a remote workspace runs on the *remote* `tty7-server`, so nothing
/// about it is addressable here by `pane_id`. This is what a pane carries
@@ -166,7 +166,7 @@ pub struct PaneWorkspace {
impl PaneWorkspace {
/// Whether this workspace shares `localhost` with the client, so a
- /// `localhost:PORT` link resolves without any forward (design §15's WSL
+ /// `localhost:PORT` link resolves without any forward (the WSL
/// exception).
pub fn shares_localhost(&self) -> bool {
matches!(self.target, crate::core::session::RemoteTarget::Wsl { .. })
@@ -222,7 +222,7 @@ impl PaneWorkspace {
/// the window showing it, and this is the whole of how it says so. The transport
/// underneath is identical either way — the same local socket, the same
/// `try_clone`, the same reader thread — because the local daemon forwards a
-/// routed connection byte for byte (design §6).
+/// routed connection byte for byte.
#[derive(Clone, Debug, Default)]
pub enum PaneRoute {
/// This machine's daemon. Every pane before remote workspaces existed, and
@@ -398,7 +398,7 @@ impl RemoteTerminal {
cwd: Option,
shell: Option,
) -> anyhow::Result<(Self, u64)> {
- Self::spawn_on(&PaneRoute::Local, size, cell_w, cell_h, cwd, shell)
+ Self::spawn_on(&PaneRoute::Local, size, cell_w, cell_h, cwd, shell, None)
}
/// [`spawn`](Self::spawn) onto a particular machine.
@@ -409,6 +409,11 @@ impl RemoteTerminal {
/// What it deliberately does *not* do is restart anything on the far side; a
/// remote daemon that is missing or mismatched is `install`'s business, and
/// it has already run by the time the ack arrives.
+ /// `owner` is the workspace the pane will belong to. It only ever reaches
+ /// the wire for a **local** spawn against a daemon that advertises
+ /// `pane-owner` — the gate lives in [`spawn_once`](Self::spawn_once), so
+ /// the retry legs (which may talk to a *different*, freshly started
+ /// daemon) re-decide it per attempt.
pub fn spawn_on(
route: &PaneRoute,
size: TermSize,
@@ -416,10 +421,12 @@ impl RemoteTerminal {
cell_h: u16,
cwd: Option,
shell: Option,
+ owner: Option,
) -> anyhow::Result<(Self, u64)> {
let retry_cwd = cwd.clone();
let retry_shell = shell.clone();
- match Self::spawn_once(route, size, cell_w, cell_h, cwd, shell) {
+ let retry_owner = owner.clone();
+ match Self::spawn_once(route, size, cell_w, cell_h, cwd, shell, owner) {
Ok(term) => Ok(term),
Err(first_err) if daemon_not_listening(&first_err) => {
// Nothing is on the socket: the daemon died (crash, OOM, a stray
@@ -431,13 +438,12 @@ impl RemoteTerminal {
"daemon not running ({first_err}); starting one failed: {start_err}"
));
}
- Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell).map_err(
- |second_err| {
+ Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell, retry_owner)
+ .map_err(|second_err| {
anyhow::anyhow!(
"daemon not running ({first_err}); started one but Spawn still failed: {second_err}"
)
- },
- )
+ })
}
// **Local panes only.** On a routed pane the connection this reads
// as "disconnected" belongs to the *remote* — the local daemon is
@@ -458,7 +464,7 @@ impl RemoteTerminal {
"daemon disconnected before Spawn reply ({first_err}); restart failed: {restart_err}"
));
}
- Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell).map_err(|second_err| {
+ Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell, retry_owner).map_err(|second_err| {
anyhow::anyhow!(
"daemon disconnected before Spawn reply ({first_err}); restarted daemon but Spawn still failed: {second_err}"
)
@@ -475,10 +481,22 @@ impl RemoteTerminal {
cell_h: u16,
cwd: Option,
shell: Option,
+ owner: Option,
) -> anyhow::Result<(Self, u64)> {
let mut stream = connect_routed(route)?;
let win = win_size(size, cell_w, cell_h);
+ // An owner only goes on the wire when this daemon is known to read the
+ // `SPAWN_OWNED` frame — an older one drops the connection over the
+ // unknown kind. Local only for now: a routed spawn's capability set is
+ // the *remote* server's, which nothing here has interrogated.
+ let owner = owner.filter(|_| {
+ route.is_local()
+ && crate::daemon::spawn::local_daemon_supports(
+ crate::daemon::protocol::FEATURE_PANE_OWNER,
+ )
+ });
+
// Ask the daemon to create the pane, then read its assigned id back. The
// very next frames on this connection are this pane's Snapshot + Output,
// which the reader thread (started below) will consume.
@@ -486,6 +504,7 @@ impl RemoteTerminal {
cwd,
size: win,
shell,
+ owner,
}
.encode(&mut stream)?;
let pane_id = match DaemonMsg::read(&mut stream)? {
@@ -527,16 +546,17 @@ impl RemoteTerminal {
let mut stream = connect_routed(route)?;
let win = win_size(size, cell_w, cell_h);
- // Unlike Spawn there's no synchronous reply to wait for here: the Snapshot
- // arrives as the first framed message and is handled uniformly by the
- // reader thread (advance + Wakeup), so the screen rebuilds asynchronously.
ClientMsg::Attach { pane_id, size: win }.encode(&mut stream)?;
- let mut term = Self::from_stream(stream, size)?;
+ // Far enough into the reply to know whether the pane is still there.
+ // Everything read here is handed to the reader thread rather than
+ // consumed: a successful attach's first frame is part of the replay.
+ let buffered = attach_reply_prefix(&mut stream, pane_id, attach_reply_wait(route))?;
+ let mut term = Self::from_stream_with(stream, size, buffered)?;
term.route = route.clone();
Ok(term)
}
- // ── Design §10's pane half of a reconnect ────────────────────────────────
+ // ── The pane half of a reconnect ────────────────────────────────
//
// For one pane: **reopen the channel, `Attach`, take the replay, resize to
// this client's geometry.** It happens *in place* — the same `Term`, the
@@ -578,7 +598,7 @@ impl RemoteTerminal {
/// start. Advancing that onto a grid that still holds the pre-disconnect
/// screen would append a second copy of everything. So the mirror is reset
/// and the machine's own record becomes the whole truth — which is also the
- /// honest presentation of the replay boundary (design §10): the ring holds
+ /// honest presentation of the replay boundary: the ring holds
/// 8 MiB, a pane that outran it comes back with the daemon's current grid
/// and **the middle is genuinely gone**. Nothing here interpolates it, and
/// nothing upstream may imply it will fill in later.
@@ -626,6 +646,12 @@ impl RemoteTerminal {
self.term.clone(),
self.proxy.clone(),
read_half,
+ // Nothing pre-read: unlike `attach_on`, a relink does not classify
+ // the reply. A pane that is gone leaves this one disconnected on
+ // purpose — the supervisor's retry is the answer here, and spawning
+ // a fresh shell into a pane the user is still looking at would
+ // discard the screen it is showing.
+ Vec::new(),
ReaderSignals {
cwd: self.cwd.clone(),
shell: self.shell_state.clone(),
@@ -646,7 +672,7 @@ impl RemoteTerminal {
}
self.reader_thread = Some(reader);
self.route = route.clone();
- // Design §10's last step: "以新客户端的尺寸 Resize". `Attach` carries a
+ // The last step: "以新客户端的尺寸 Resize". `Attach` carries a
// size but deliberately does not resize the PTY, so the geometry only
// becomes real when this frame lands — and `synced_size = false` is what
// lets it through when the size happens to equal the last one.
@@ -658,6 +684,18 @@ impl RemoteTerminal {
/// Shared tail of `spawn`/`attach`: build the local `Term`, split the socket
/// into read/write halves, and launch the reader thread.
pub(super) fn from_stream(stream: Stream, size: TermSize) -> anyhow::Result {
+ Self::from_stream_with(stream, size, Vec::new())
+ }
+
+ /// [`from_stream`](Self::from_stream) for a caller that has already read
+ /// part of the stream. `buffered` is where the reader thread starts, ahead
+ /// of anything still on the socket — `attach_reply_prefix` reads far enough
+ /// to classify the reply, and those bytes are the front of the replay.
+ pub(super) fn from_stream_with(
+ stream: Stream,
+ size: TermSize,
+ buffered: Vec,
+ ) -> anyhow::Result {
// Two independent handles to the same connection: the reader thread owns
// the read half, the UI thread writes through the (mutex-guarded) write
// half. Reads and writes are independent directions, so this is safe.
@@ -696,6 +734,7 @@ impl RemoteTerminal {
term.clone(),
proxy.clone(),
read_half,
+ buffered,
ReaderSignals {
cwd: cwd.clone(),
shell: shell_state.clone(),
@@ -744,7 +783,7 @@ impl RemoteTerminal {
/// Close this pane's link, leaving the pane running on its machine.
///
- /// The same two frames `Drop` sends, without dropping: design §10's
+ /// The same two frames `Drop` sends, without dropping: the
/// takeover needs the client to *stop being attached* while the view stays
/// on screen in its read-only state.
pub fn detach_link(&mut self) {
@@ -754,7 +793,7 @@ impl RemoteTerminal {
}
// The reader observes the close and runs its own teardown, so the pane
// lands in exactly the state a dropped network link leaves it in — which
- // is the state design §10 wants after a takeover, reached by the code
+ // is the state wanted after a takeover, reached by the code
// path that is already exercised every time a connection fails.
if let Some(handle) = self.reader_thread.take() {
let _ = handle.join();
@@ -783,6 +822,9 @@ impl RemoteTerminal {
term: Arc>>,
proxy: EventProxy,
read_half: Stream,
+ // Bytes already off the socket (see `from_stream_with`), which the loop
+ // resumes from before its first read.
+ buffered: Vec,
signals: ReaderSignals,
) -> JoinHandle<()> {
std::thread::Builder::new()
@@ -842,7 +884,7 @@ impl RemoteTerminal {
// applied history, and the next pair's Size (ultimately the
// final pair, which carries the PTY's current geometry)
// restores the recorded width before more bytes advance.
- let mut pending: Vec = Vec::new();
+ let mut pending: Vec = buffered;
let mut pending_size: Option = None;
// Sized to the daemon writer's coalesced-frame cap so one large
// Output frame lands in a few reads instead of dozens.
@@ -1798,7 +1840,7 @@ impl RemoteTerminal {
query(pane_id).unwrap_or_default()
}
- // ── Remote workspaces (design §15) ───────────────────────────────────────
+ // ── Remote workspaces ───────────────────────────────────────
/// Send one workspace-scoped request and return the daemon's reply.
///
@@ -1920,6 +1962,139 @@ fn daemon_not_listening(err: &anyhow::Error) -> bool {
})
}
+/// How long to wait for the daemon's first frame after an `Attach` before
+/// giving up on *classifying* the reply. Not a deadline on the attach — only on
+/// being able to tell "this pane is gone" from "this pane has not said anything
+/// yet" — so lapsing costs nothing but the old behaviour. The connection is
+/// already open by the time the wait starts (the SSH setup happened inside
+/// `connect_routed`), so what is being waited on is one round trip.
+///
+/// **The two routes are not the same wait.** A remote attach runs on a
+/// background thread and answers over an SSH channel, so it can afford to be
+/// patient. A local one is on the UI thread — `ui::pending_pane` explains why
+/// that path stayed synchronous — where the ceiling is a window freeze, and a
+/// local daemon that has not answered in two seconds is not about to.
+fn attach_reply_wait(route: &PaneRoute) -> std::time::Duration {
+ match route.is_local() {
+ true => std::time::Duration::from_secs(2),
+ false => std::time::Duration::from_secs(15),
+ }
+}
+
+/// Read the head of an `Attach` reply, turning "no such pane" into an `Err`, and
+/// hand back whatever was read so the reader thread starts from it.
+///
+/// # Why this exists
+///
+/// `Attach` has no synchronous reply, so for a long time the client's attach
+/// could not fail: it wrote the frame and returned `Ok`, and a pane id that was
+/// gone showed up much later as the reader thread hitting EOF — which the view
+/// paints as `tty7 — disconnected` and deliberately does *not* close, because
+/// on a remote workspace a dropped link and a dead pane look the same from
+/// there. So the ordinary case of "that pane isn't there any more" landed the
+/// user in the failure state meant for "your machine is unreachable", and
+/// `start_pane_spawn`'s fall back to a fresh pane — the whole reason a stale id
+/// is survivable — never ran.
+///
+/// The daemon does answer, it just answers out of band: `Error` on a miss
+/// (`daemon::server`), `Size` + `Snapshot` on a hit. Classifying on the **kind
+/// byte** rather than the decoded message is what keeps this cheap — the header
+/// is 5 bytes and the snapshot behind it can be megabytes.
+///
+/// Two non-answers are deliberately *not* failures, because neither is evidence
+/// the pane is gone and both used to work:
+///
+/// | | |
+/// |---|---|
+/// | The read times out | The pane is quiet. Return what we have and let the reader carry on |
+/// | Anything but `Error` arrives | It is the replay. Same |
+fn attach_reply_prefix(
+ stream: &mut Stream,
+ pane_id: u64,
+ wait: std::time::Duration,
+) -> anyhow::Result